diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index 2d43493299..a9e2154307 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -118,7 +118,8 @@ Run `bin/fm-doc-audience-check.sh`; it enforces classification, README setup rou - Plain dash `-`, never an em dash. - Never add an agent name as a commit co-author. - `bin/*.sh` and `bin/backends/*.sh` must pass `shellcheck`. -- Run `bin/fm-lint.sh` before treating a script change as done; it is the single owner of the lint definition (file set, config, and pinned shellcheck version) that CI and the no-mistakes pre-push gate both invoke, and it refuses to run under any other shellcheck version. +- Run `bin/fm-lint.sh` before treating a script change as done; it is the single owner of the lint definition (file set, config, pinned shellcheck version, and pinned actionlint workflow lint) that CI and the no-mistakes pre-push gate both invoke, and it refuses to run under any other version of either linter. +- When a task names a specific tool, implement the work with that tool, or explicitly flag the substitution and its new dependency footprint for review before shipping. - Colocate tests with the existing pattern in `tests/`, name them `.test.sh`, and extend an existing script rather than inventing a new runner. - Tests must exercise behavior through an executable or public interface and must never assert implementation-source bytes, including through parsers, regexes, snapshots, or indirect wrappers. - A maintainer-verification record under `docs/verification/` records active empirical facts, not assumptions or task chronology. diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index 5251f88e8f..05c43a1dfc 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -25,7 +25,7 @@ Firstmate registers a source, keeps working, and is woken when that process comp ## Arming a source Use the adapter, not the generic runner, for a real source. -For an Atelier review artifact: +For an Atelier review artifact firstmate owns (a live investigating scout should host its own loop): ```sh bin/fm-procevent-atelier.sh arm diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md index 11b8405812..24c0e44de5 100644 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -2,7 +2,8 @@ name: quota-array-dispatch description: >- Agent-only decision procedure for resolving a matched crew-dispatch profile - array from current quota-axi output, including effective headroom and usable-runway evidence. + array from quota-axi's default TOON, ranking by spendPriority after three + orthogonal gates. Load when a dispatch rule or default resolves to more than one profile candidate. user-invocable: false metadata: @@ -14,43 +15,46 @@ metadata: This skill is the single owner of the completion-aware profile-array selection procedure. `AGENTS.md` section 4 owns the always-loaded intake boundary, load trigger, malformed-config refusal, every-candidate accounting, and strongest-reasoning/tie safety rules. `harness-adapters` owns harness verification, model/provider discovery, and effort fallback. -`quota-axi` remains data-only, reports whatever granularity the vendor supplies, and never recommends, selects, ranks, or infers a route. +`quota-axi` remains data-only: it publishes `spendPriority` as a comparable scalar and never recommends, selects, ranks, or infers a route. Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy, or producer-side route recommendation. Deterministic shell owns only schema, configuration, and version validation plus concrete spawn safeguards; every model-to-provider, provider-to-credential, and quota-applicability relation is yours to establish transparently and to show your evidence for. -## Collect facts +## Read the default TOON -Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. -Do not take a second snapshot to settle a candidate, and read `quota-axi auth --json` when a candidate's credential surface is in question. -For each candidate, preserve explicit `harness`, `model`, and `provider`; `harness-adapters` owns identity, and model/provider never infer harness: +Start each intake by running `quota-axi` once with no `--json`, and reuse that TOON for every candidate. +Post-consolidation quota-axi (the floor owned by `bin/fm-quota-axi-lib.sh`) puts `spendPriority` in the default `quota[]` block beside `effectivePercentRemaining`, `runway`, `confidence`, `limitedBy`, and `resetsAt`. +Sparse `exhaustion[]` carries finite-runway seconds only for `projected_exhaustion` and `exhausted_now`. +Sparse `attention[]` names auth, stale, and unmeasurable facts. +`spendPriority` is THE quota-perspective ranker. +It already computes the economics that older instructions reconstructed by hand from headroom, pace, reserve, and window-id lists; do not recompute those. +Do not read `--json` on the normal path, and do not reach for `--full` to rebuild that economics. -- task/profile fit and required reasoning class -- applicable effective headroom (`effectivePercentRemaining`) from the established provider/model scope -- usable runway status, `usableRunwaySeconds`, `projectedExhaustedAt`, `limitingWindowId`, `projectionConfidence`, `projectionBasis`, and any `unmeasurableWindowIds` -- the task-completion horizon and the evidence and confidence used to estimate it -- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) for later diagnostic tie-breaking -- schema notes when runway or pace fields are absent +After reading the TOON, fall back to one `quota-axi --json` call only when that TOON is genuinely ambiguous for the decision, or when the installed quota-axi is somehow below the floor so its TOON lacks `spendPriority`. +Ambiguous means a candidate's `spendPriority` is the literal `unknown` or unmeasurable, a real tie still needs extra evidence, or a candidate's eligibility is unclear from `quota[]` plus `attention[]`. +The fallback therefore has an explicit TOON-then-JSON call sequence; reuse its JSON result and do not take any further quota snapshots. +Below-floor is rare: bootstrap enforces `FM_QUOTA_AXI_MIN` and normally reports `MISSING` before dispatch; if an intake somehow reaches an older build whose TOON lacks `spendPriority`, use the defensive `--json` fallback rather than treating the missing scalar as healthy. +`--json` is a defensive belt, not a habit; never reach for it because it feels more complete. +Read `quota-axi auth --json` only when a candidate's credential surface is in question. -Stale raw windows are diagnostic, never headroom or fabricated runway. -Grok's `credits.remaining` is a prepaid balance unrelated to `percentRemaining`; never read it as exhaustion. -Read all windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, `unknownWindowIds`, and `unmeasurableWindowIds`. -The compact default output intentionally omits numeric reserve, while `--json` and `--full` retain reserve diagnostics. +For each candidate, preserve explicit `harness`, `model`, and `provider`; `harness-adapters` owns identity, and model/provider never infer harness. -## Establish the provider relation before reading quota +## Three gates, then spendPriority + +Apply the three cheap orthogonal gates first. +`spendPriority` ranks only among candidates that pass all three. +It cannot override a hard-gate failure, and it is never hidden inside a new composite score. + +### 1. Eligibility Deterministic shell must never map a model to a provider, a provider to a credential store, or a name prefix to a family. You establish those relations yourself, in the open, from the candidate's own authoritative catalog (`harness-adapters` owns the per-harness discovery surface) plus the one intake snapshot. -Name the evidence for each relation you assert so the conclusion is inspectable. - -1. Confirm the catalog lists the candidate's model and record the provider family it reports. - A model the authoritative catalog does not list is concrete contradictory evidence: block that candidate and quote the catalog result. -2. Apply quota at the granularity the vendor actually supplies. - A provider-level or `all_models`/`all_products` scope bounds every model you established in that family, including one with no window of its own. - A named-model or named-product scope is an additional bound for that model alone and is irrelevant to every other model in the family. - Read `quotaSemantics.description`, which states the vendor's own bounding rule. -3. Record what remains unknown instead of converting it into a verdict. -## Authentication is scoped to the selected surface +Confirm the catalog lists the candidate's model and record the provider family it reports. +A model the catalog does not list is concrete contradictory evidence: block that candidate and quote the catalog result. +Apply quota at the granularity the vendor actually supplies. +A provider-level or `all_models`/`all_products` scope bounds every model you established in that family, including one with no window of its own. +A named-model or named-product scope is an additional bound for that model alone. +Match the candidate to its `quota[]` row by that established provider and scope; a stale, auth-required, or unmeasurable scope is named in `attention[]` instead of a fabricated number. A candidate authenticates through its own tuple's surface; another harness's CLI can never gate it, and `harness=pi` with `model=xai/grok-*` is Pi using xAI rather than the standalone Grok CLI. `quota-axi auth --json` lists each provider's credential sources independently, so read the one source the candidate actually uses rather than collapsing a provider to a single status. @@ -59,8 +63,8 @@ A Pi-hosted family may authenticate through the vendor's own store with no `pi:` Uncertainty and ineligibility are different findings: -- No model-level window, no matching auth source, an absent `state.authStatus`, an unmeasurable or `unknown` scope, or a surface quota-axi does not model at all is disclosed uncertainty. - Keep the candidate eligible, state the unknown, and prefer known sustainable evidence when otherwise comparable. +- No model-level window, no matching auth source, an unmeasurable or `unknown` scope, or a surface quota-axi does not model at all is disclosed uncertainty. + Keep the candidate eligible, state the unknown, and prefer known viable evidence when otherwise comparable. - An expired credential is a short-lived session token the owning vendor renews on next use, not a sign-out. - Only concrete contradictory evidence blocks: an authoritative catalog proving the model unsupported, or proof that the credential the candidate actually selects is unusable. - Reserve login wording for that proven-unusable case, and name the harness, model, surface, and evidence. @@ -69,45 +73,45 @@ When a credential's local classification is the only thing standing between a ca `bin/fm-vendor-auth-probe.sh` is the only approved vendor-credential probe; its `--help` owns the registered probes and mechanics. It takes no harness, model, or provider and returns a fact, not a route: only `authenticated` and `unauthenticated` are ground truth, while `indeterminate`, `timeout`, and `unavailable` establish nothing and must never be read as either outcome. Never launch a vendor CLI yourself, and never probe a credential store the candidate does not use. +Grok prepaid `credits` are unrelated to paid-window headroom; never read them as exhaustion. + +Malformed configuration is an actionable error, not a candidate to rank around. + +### 2. Reasoning-class fit + +Keep only candidates that meet the required reasoning class for this task (a simple bug fix versus very-difficult design). +Never use `spendPriority` or remaining quota to silently replace that class. +When every remaining candidate is tight, dispatch inside the strongest-reasoning class if one of those candidates can proceed, or stop and report that the strongest-class choice cannot proceed rather than downgrading it to spend or conserve quota. + +### 3. Runway feasibility floor + +Known runway that will not last until the inspectable likely-completion horizon fails this gate, even when that candidate has the highest `spendPriority`. +Read `runway` from the `quota[]` row: `through_reset` passes this generic feasibility floor because the window reaches its refill without exhausting; never compare its `resetsAt` with the completion horizon as though reset were an exhaustion deadline. +`exhausted_now` is zero, and `projected_exhaustion` uses the matching `exhaustion[]` row's `usableRunwaySeconds`. +A high `spendPriority` on a nearly empty window that will exhaust soon must not route into a mid-task stall. +Unknown or unmeasurable runway stays eligible with disclosed uncertainty and is never assumed to pass. +Do not invent a generic percentage floor, and honor an explicit captain floor for a candidate when one exists. + +## Rank by spendPriority + +Among candidates that pass all three gates, pick the highest known `spendPriority`. +A higher known scalar is better: positive means paid allowance is on track to reach reset unused, `0` is exact utilization, and negative means overdrawn against the reset clock. +Rank only from comparable known scalars. +Never treat absent, `unknown`, or unmeasurable `spendPriority` as zero or as healthy; `0` means exact utilization, a different claim from unknown. +An unknown `spendPriority` keeps the candidate eligible with disclosed uncertainty. +Prefer known viable evidence when otherwise comparable. +After the permitted TOON-to-JSON fallback, escalate to Firstmate instead of routing if no candidate can be ranked or runway uncertainty prevents proving the feasibility floor for any candidate that could be selected. +Never resolve that terminal uncertainty by treating unknown as healthy or by choosing arbitrarily. +Show the scalar or the literal `unknown` in the rationale; do not hide it in a score. + +Do not compare headroom against runway by hand. +Do not use pace or signed reserve as a later tie-break layer. +Do not read `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, `limitingWindowIds`, or other window-id lists to reconstruct what `spendPriority` already computed. + +Genuine ties: stop and report every tied candidate for captain choice. +Do not select by array order, harness name, or another arbitrary identity ordering. +Report duplicate concrete profiles as a configuration error. -## Pace semantics - -`reservePercentPoints = percentRemaining - timeRemainingPercent`. -Negative reserve means usage is ahead of reset pace and creates conservation pressure. -Positive reserve means usage is behind reset pace. -`on_pace` is neutral. -Conservation pressure is present for effective pace status `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or a bounding window is `ahead`. -`unknown` is valid explicit uncertainty from quota-axi, not parser failure or permission to assume health. - -## Selection order - -Apply only among candidates satisfying required fit and strongest reasoning class. -Never use headroom, runway, pace, or reserve to silently replace that reasoning class. - -1. Concrete contradictory evidence or malformed configuration: stop and report the tuple and that evidence. - Unmeasurable quota, a missing model-level window, an absent runway field, and a credential surface quota-axi does not model are uncertainty, never this rule. -2. Honor any explicit captain instruction that sets a floor for that candidate before the generic comparison. - Do not invent a generic percentage floor or treat a low percentage as an automatic failure. -3. Keep the strongest-reasoning class when every candidate is tight or completion evidence is poor. - Dispatch inside that class when a candidate can proceed, or report that its strongest-class choice cannot proceed rather than downgrading it to conserve quota. -4. Compare comparable-fit candidates on their applicable effective headroom and usable runway. - Eliminate a candidate only when another candidate Pareto-dominates it on both dimensions, with at least one dimension strictly better. - Establish dominance only from comparable known evidence, never by treating absent, `unknown`, or unmeasurable headroom or runway as zero or as a healthy value. -5. Prefer supported runway evidence that projects availability through the inspectable likely-completion horizon. - Known evidence that does not reach that horizon is inferior to known evidence that does, even when its signed reserve is less negative. - Preserve projection confidence and basis, the limiting window, and the horizon estimate in the rationale rather than hiding them in a score or model-specific heuristic. -6. Resolve remaining uncertainty explicitly. - An authenticated candidate with unknown or unmeasurable headroom or runway stays eligible and cannot be silently excluded or assumed sustainable. - Prefer known viable evidence when otherwise comparable, and report uncertainty or ask the captain when it still prevents a justified choice. -7. Use pace and signed reserve only as later diagnostic tie-break evidence among candidates still unresolved after headroom, runway, likely-completion viability, and uncertainty. - Pace and reserve never rescue a clearly inferior completion prospect. - Do not collapse these facts into an opaque composite score. -8. Older schemas or absent runway/pace fields: do not crash, fabricate runway or pace, treat absence as healthy, or silently exclude a candidate. - State which evidence is unavailable, retain the candidate, and apply only the comparisons the snapshot supports. -9. Genuine ties: stop and report every tied candidate for captain choice. - Do not select by array order, harness name, or another arbitrary identity ordering. - Report duplicate concrete profiles as a configuration error. - -Account for every candidate visibly before selecting or escalating, naming its catalog evidence, provider relation, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, effective headroom, usable runway, likely-completion reasoning, and later pace or reserve evidence when used. +Account for every candidate visibly before selecting or escalating, naming its catalog evidence, provider relation, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, `spendPriority`, and runway-versus-horizon result. A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence; never emit a bare `Grok unauthenticated` statement. Never conclude with an unexplained "best quota" label. diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index cf741b9d95..b9b94b27d4 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -23,7 +23,7 @@ The target window's harness is recorded as `harness=` in `state/.meta`. This procedure covers ordinary `kind=ship` and `kind=scout` direct reports. Load `secondmate-provisioning` instead for `kind=secondmate` recovery. -For a REMOTE secondmate, `fm-crew-state`'s `unknown`/`worktree gone` and `fm-send`'s `remote send failed`/`delivery unconfirmed` verdicts are unreliable and routinely false-negative; do not conclude the mate is dead or the send failed from those alone, confirm against the actual remote pane first. +For a REMOTE secondmate, `fm-crew-state` and `fm-peek` read the actual remote endpoint over `fm-on.sh`, and `fm-send` reports a delivered-with-pending-confirmation steer as delivered (their headers own the contracts); an `unknown-remote` read or unreachable-host failure means the remote state could not be read, never that the mate is dead or the send failed. Recover a genuinely stuck remote mate only through `bin/fm-spawn.sh --secondmate`, never raw herdr pane close/kill surgery, which strands the endpoint binding. Treat the digest's endpoint result as a presence signal, not proof that the task's work or validation run is gone. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90c5d6e895..fcfc4cb2df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: lint: - name: Lint shell scripts + name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -20,8 +20,15 @@ jobs: set -eu bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - # Single owner of the lint definition (file set + config + version). Do not - # re-spell the shellcheck command here; keep CI and the pre-push gate on it. + - name: Install pinned actionlint + run: | + set -eu + bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + # Single owner of the lint definition (shell file set, config, version, + # and GitHub workflow lint). Do not re-spell the checks here; keep CI + # and the pre-push gate on this script so a self-broken ci.yml still + # fails locally before merge. - run: bin/fm-lint.sh # Deterministic proof that portable parallel shards + portable serial + Herdr @@ -52,6 +59,11 @@ jobs: set -eu bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Install pinned actionlint + run: | + set -eu + bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - name: Install tasks-axi run: | set -eu @@ -84,6 +96,11 @@ jobs: set -eu bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Install pinned actionlint + run: | + set -eu + bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - name: Install tasks-axi run: | set -eu @@ -130,6 +147,11 @@ jobs: set -eu bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Install pinned actionlint + run: | + set -eu + bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - name: Require tmux for e2e tests run: | set -eu diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index 62bb9e7284..f825543372 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -24,9 +24,10 @@ document: # Pin lint to the same owner CI runs instead of leaving it to no-mistakes' # default handling, which does not invoke the repository's canonical lint gate. -# `bin/fm-lint.sh` owns the complete lint definition and +# `bin/fm-lint.sh` owns the complete lint definition, including GitHub workflow +# lint via pinned actionlint in `bin/fm-lint-workflows.sh`, and # `.github/workflows/ci.yml` invokes it directly, with parity asserted by -# `tests/fm-lint.test.sh`. +# `tests/fm-lint.test.sh` and `tests/fm-lint-workflows.test.sh`. # # Do not set commands.test to a complete tests/*.test.sh walk. Local no-mistakes # Test is intent-targeted validation of whether the change meets its brief; @@ -36,7 +37,8 @@ document: commands: lint: 'bin/fm-lint.sh' -# Store test evidence in this repo so it is committed alongside the change instead of kept in a temp dir. +# Publish each run's test evidence to the orphan no-mistakes/evidence branch linked from the PR. +# The evidence is not committed to the feature or default branch. test: evidence: store_in_repo: true diff --git a/AGENTS.md b/AGENTS.md index 80e3c0a5b5..b67e9cda12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -190,8 +190,8 @@ If static `config/crew-harness` or `config/secondmate-harness` names an unverifi `docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. When dispatch profiles exist, consult them at every crewmate or scout intake and pass the resolved concrete profile required by `fm-spawn`. Routing precedence is an explicit per-task captain override, then the best-fit configured rule, then the configured default, then the static crewmate harness. -Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose with inspectable effective headroom and usable runway, using pace and reserve only later when needed. -Account for every candidate with the catalog evidence, provider relationship, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, and the headroom, runway, and later pace or reserve evidence used in selection; never omit a candidate, guess, fall back silently, or call the result quota-informed without them. +Firstmate alone resolves a matched profile array: begin with `quota-axi`'s default TOON at that intake, using the skill's narrow TOON-then-`--json` fallback only for genuine ambiguity, evaluate every configured candidate against that current output, and choose with inspectable `spendPriority` as the one quota-perspective ranker after the skill's eligibility, reasoning-class, and runway-feasibility gates. +Account for every candidate with the catalog evidence, provider relationship, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, and the spendPriority and runway evidence used in selection; never omit a candidate, guess, fall back silently, or call the result quota-informed without them. Establish model support and provider family from that harness's own authoritative catalog, then read `quota-axi` at the granularity the vendor actually supplies: provider-level or all-model evidence applies to every model established in that family, and a named-model window bounds only that model. Missing model-level quota, a missing authentication source, unmeasurable headroom, or unmodeled authentication is disclosed uncertainty that keeps a candidate eligible, never a credential or login escalation. Only concrete contradictory evidence blocks a candidate, such as an authoritative catalog proving the model unsupported or proof that the credential selected for that surface is unusable; never infer a credential store, provider family, or quota mapping from a harness, model, or source name, and never launch another harness's CLI to judge a candidate. @@ -199,7 +199,7 @@ Preserve malformed profile configuration as an actionable error rather than sele When every candidate is tight, preserve the captain's strongest-reasoning class rather than silently downgrading it solely to conserve quota; stop and report the tight choice if that class cannot proceed. Break genuine evidence ties without array-order or harness bias. `quota-axi` owns how model or product windows relate to bounding account windows and remains data-only. -Load `quota-array-dispatch` before choosing among a matched profile array; that skill is the single owner of the completion-aware selection procedure. +Load `quota-array-dispatch` before choosing among a matched profile array; that skill is the single owner of the TOON-first spendPriority selection procedure. The generic effort fallback and its precedence are owned by `harness-adapters`: explicit captain and standing configured effort win; otherwise use low for well-understood explicit work, xhigh for ambiguous investigation or design, intermediate levels proportionally, and never max without explicit captain preference. Do not add model-specific versions of that policy. @@ -373,6 +373,7 @@ Retire one only on an explicit captain or main-firstmate decision, after loading A completed scout must leave a self-contained report before its scratch worktree can be discarded; read and relay its findings, record the report as the Done artifact, and re-evaluate the queue. A report may recommend implementation but does not authorize it. Before treating the investigation or any visual review as complete, load `decision-hold-lifecycle`; teardown enforces that shared completion gate. +When a scout's deliverable is a visual artifact the captain will iterate on, prefer keeping that scout alive to host its own Atelier loop rather than tearing it down and mediating from firstmate, so the scout keeps its investigation context and the captain iterates in one continuous session. When implementation is separately authorized, promote the existing scout through `bin/fm-promote.sh` rather than creating a duplicate task. The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test. @@ -524,7 +525,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `NETWORK_CHECKS:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `ask-user-authority` - load before deciding any ask-user finding, regardless of the project's `yolo` posture. -- `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi output. +- `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi default TOON. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. - `project-management` - load before adding, creating, removing, or initializing a project. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65305797be..cef1f1180f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,8 +45,11 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star - Helper scripts in `bin/` are plain bash. Each starts with a usage header comment; keep it accurate when you change behavior. Test scripts and helpers in `tests/` are plain bash too. - `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, and pinned shellcheck version), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge. - It pins one exact shellcheck version and refuses to run under any other; print it with `bin/fm-lint.sh --required-version` and install that build locally. + `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, pinned shellcheck version, and pinned actionlint workflow lint), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge. + A malformed `.github/workflows/*.yml`, including a self-broken `ci.yml`, fails that local lint path before merge because a broken workflow cannot report its own breakage. + It pins one exact shellcheck version and one exact actionlint version and refuses to run under any other. + Print the shellcheck pin with `bin/fm-lint.sh --required-version` and the actionlint pin with `bin/fm-lint-workflows.sh --required-version`. + Use `bin/fm-install-shellcheck.sh` and `bin/fm-install-actionlint.sh` to install those exact builds locally; each installer's header owns its destination usage and supported platforms. - Harness-adapter ownership spans detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `bin/fm-composer-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`; the `firstmate-coding-guidelines` skill owns the validation policy for checks that depend on those harnesses. - Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) keep current setup and limits in the relevant backend guide and active empirical evidence in [`docs/verification/runtime-backends.md`](docs/verification/runtime-backends.md). - [`docs/documentation-audiences.md`](docs/documentation-audiences.md) and its machine-consumed inventory own prose classification; run `bin/fm-doc-audience-check.sh` after documentation changes. @@ -64,15 +67,15 @@ A crewmate picking up such a brief should load the skill even if the brief preda When supervising live crewmates, keep firstmate's own long validation or build commands in the background so watcher wakes can still be handled. Crewmate validation follows the installed no-mistakes version's SKILL.md and live `axi` help instead of duplicating gate mechanics in firstmate docs. Firstmate's wrapper still matters: crewmates route every `ask-user` finding to firstmate, which applies the authority contract in `AGENTS.md`, and crewmates avoid `--yes` because it would bypass that check and any required captain escalation. -Local `.no-mistakes/` state and test evidence stay out of this repo; `.no-mistakes.yaml` keeps evidence in a temp directory and pins the gate's lint command to `bin/fm-lint.sh`, matching the Linux CI lint job. +`.no-mistakes.yaml` publishes test evidence to the orphan `no-mistakes/evidence` branch, which shares no history with code branches, and pins the gate's lint command to `bin/fm-lint.sh`, matching the Linux CI lint job. Local no-mistakes Test is intent-targeted and must not re-run every `tests/*.test.sh`; `.github/workflows/ci.yml` owns the broad behavior suite plus platform-specific compatibility lanes. -That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even when another no-mistakes-managed target project keeps committed PR evidence. +The pipeline publishes that evidence itself, so never hand-commit `.no-mistakes/` paths onto a feature branch; CI rejects them as tracked personal fleet paths. Check and test the toolbelt before pushing: ```sh while IFS= read -r script; do /bin/bash -n "$script" || exit; done < <(bin/fm-lint.sh --list-files) # syntax-check the shell surface fm-lint.sh will cover (changed files locally, full set in CI/on main) -bin/fm-lint.sh # lint that same surface; the single owner CI and the no-mistakes gate both run, full set in CI +bin/fm-lint.sh # lint that shell surface plus GitHub workflows via pinned actionlint; the single owner CI and the no-mistakes gate both run bin/fm-test-run.sh tests/.test.sh # one script (primary local focus path, timed) bin/fm-test-run.sh --family pure-contract-unit # ordinary family-scoped local path (serial, timed) bin/fm-test-run.sh --changed # conservative changed-file-informed set (never silent full suite) diff --git a/GROK_BOT.md b/GROK_BOT.md new file mode 100644 index 0000000000..69ca686d5b --- /dev/null +++ b/GROK_BOT.md @@ -0,0 +1,27 @@ +You are Firstmate: the single agent the captain talks to. They bring you everything; you make sure it gets done. + +Other bots are your crewmates: persistent and role-based, each holding a stable charter - e.g. one for the inbox, one for documents like PDFs and decks, one for research. +Before signing on a new crewmate, check whether an existing one already covers a related charter: if a charter matches or highly overlaps, reuse that crewmate; +if the overlap is only limited, sign on the new crewmate and clarify the distinction in both crewmates' charters. +Sign on a genuinely new crewmate only when no existing one fits. When you sign one on, write into its charter that it reports its outcomes and blockers back to you (Firstmate), never to the captain directly - the captain only ever talks to you. +Delegate by messaging a crewmate; it wakes, does the work, and messages you back. + +Default to handing work off. If a job is more than one tool call, especially computer or browser work or anything that will take minutes, give it to the crewmate whose charter fits. Do not keep that grind in this chat because you already have a login, a token, or an open page. The computer is shared across the crew. Browser logins persist for every bot. A login on your screen is not a reason to do the work yourself. Secrets are per-bot. They do not propagate to the crew. If a crewmate needs a credential, tell the crewmate to request it and then tell the captain to give that secret to that bot on a secure card. Do not keep the secret and do the work yourself. Do not paste or forward secrets in chat. After the captain has given the secret to that bot, hand the task off and wait for the outcome. + +Software and code go through a crewmate, never through you directly: sign on a crewmate per project or project area - once the captain has expressed how its charter should be set - and let that crewmate drive the code work with cursor cloud agents. You never call a cursor cloud agent yourself. + +Don't reach for subagents. Needing one means the work is substantial, which means it belongs with a crewmate, not with you. Subagents are a tool for crewmates to break down their own work. + +Mark every task you hand off as coming from you, with a short task id, and ask for the outcome back against that id - so the crewmate routes its result and any blockers to you rather than just handling them in its own chat, and you can match a reply to the right task. +The marker is visible in the chat; that's fine. + +Work asynchronously. Delegating doesn't block you - a crewmate replies on a later turn and shows up in this chat. +So hand off, tell the captain what's under way, and relay each result as it lands. Reserve a priority send for when something must interrupt a crewmate's current task. + +When you notice crewmates making mistakes or working inefficiently, update their description to refine their behavior so your crew does better next time. + +How you talk. Address the captain as "captain" at least once in every reply - always, even when the news is bad ("Captain, that didn't work..."). +Let light nautical seasoning land only when it fits naturally - an occasional "aye", "on deck", "shipshape", "under way", "ahoy" - never letting it crowd out the substance, and drop it entirely for bad news or serious findings. +Speak in outcomes and consequences, not internal mechanics. + +Keep it simple for the captain. Focus on communicating outcomes, not mechanics. They scale by talking only to you; protect that. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 830e51c43c..a84f587109 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -350,6 +350,7 @@ $CONTEXT_RULE # Definition of done Write your findings to \`$DATA/$ID/report.md\`. The report must stand alone: what you did, what you found, the evidence (commands run, output, file:line references), and what you recommend. +If your deliverable is a visual artifact the captain will review and iterate on, you may host the Atelier review loop yourself (poll, revise, re-serve, staying alive) instead of handing it back to firstmate. Before reporting done, read and follow \`$FM_ROOT/.agents/skills/decision-hold-lifecycle/SKILL.md\` and pass its shared completion gate for the report and any visual review. When the report is complete, append \`done: {one-line conclusion}\` to the status file and stop. If your findings reveal work that should ship (e.g. you reproduced a bug and the fix is clear), say so in the report; firstmate may promote this task in place, and you would then receive mode-specific ship instructions as a follow-up message. diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 98ce3b42dc..4520ceb818 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -16,10 +16,17 @@ # fixed mapping logic, no heuristics and no LLM. Output is one stable, parseable, # token-tight line firstmate can read every heartbeat: # -# state: · source: · +# state: · source: · # # Logic, in order: -# 1. Resolve worktree + backend target + kind from state/.meta. +# 1. Resolve worktree + backend target + kind from state/.meta. A meta +# recording remote_host= is a remote secondmate: its worktree and endpoint +# live on that host, so the local worktree and pane reads are skipped and +# the remote host is asked for the endpoint's recovery-grade state +# (fm-on.sh + fm-remote-secondmate-control.sh state). alive falls through +# to the routed status log; dead/missing report the remote verdict; an +# unreachable or unreadable remote reports unknown-remote, never a false +# gone/dead. # 2. Matching no-mistakes run for this crew's branch AND current code identity, # active or terminal (from `axi status`, or the coarse `no-mistakes runs` # fallback)? Branch name alone is not enough: a historical run on a reused @@ -109,10 +116,13 @@ meta_value() { # WT=$(meta_value worktree) KIND=$(meta_value kind) HARNESS=$(meta_value harness) +REMOTE_HOST=$(meta_value remote_host) [ -n "$KIND" ] || KIND=ship -# A torn-down (or never-created) worktree has no current state to read. -if [ -z "$WT" ] || [ ! -d "$WT" ]; then +# A torn-down (or never-created) worktree has no current state to read. A +# remote secondmate's recorded worktree is a path on ITS host, so the local +# probe proves nothing for it - the remote arm below reads the true source. +if [ -z "$REMOTE_HOST" ] && { [ -z "$WT" ] || [ ! -d "$WT" ]; }; then emit unknown none "worktree gone (torn down?)" fi @@ -146,6 +156,45 @@ map_log_state() { # LOG_LINE=$(log_last_line || true) LOG_VERB=$(status_line_verb "$LOG_LINE") +# --- remote secondmate: the true source is the remote endpoint --------------- +# A remote mate's recorded worktree and backend target live on its own host, so +# the local worktree probe above and the local pane reads below would misreport +# a healthy remote mate as gone or dead. Ask the remote host for the endpoint's +# recovery-grade state over the same fm-on.sh transport fm-send uses, then read +# current activity from the routed status log exactly as for a local +# secondmate (an idle endpoint is healthy for a secondmate either way). An +# unreachable host or unreadable endpoint is reported as unknown-remote - +# explicitly NOT proof of death - so a transport blip never reads as a torn +# down or dead mate; only the remote host's own dead/missing verdict may say +# the endpoint is actually gone. +if [ -n "$REMOTE_HOST" ]; then + if ! REMOTE_STATE=$(FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-on.sh" "$ID" \ + fm-remote-secondmate-control.sh state "$ID" < /dev/null 2>/dev/null); then + REMOTE_STATE= + fi + REMOTE_STATE=$(printf '%s\n' "$REMOTE_STATE" | tail -1) + case "$REMOTE_STATE" in + alive) + if [ -n "$LOG_VERB" ]; then + LOG_STATE=$(map_log_state "$LOG_LINE") + if [ "$LOG_STATE" != unknown ]; then + emit "$LOG_STATE" status-log "$(status_line_note "$LOG_LINE")${SEP}remote endpoint alive on $REMOTE_HOST" + fi + fi + emit unknown remote-endpoint "alive on $REMOTE_HOST (an idle secondmate is healthy)" + ;; + dead|missing) + emit unknown remote-endpoint "remote endpoint $REMOTE_STATE on $REMOTE_HOST" + ;; + '') + emit unknown remote-endpoint "unknown-remote: $REMOTE_HOST unreachable or endpoint unreadable (not proof of death)" + ;; + *) + emit unknown remote-endpoint "unknown-remote: endpoint state '$REMOTE_STATE' on $REMOTE_HOST (not proof of death)" + ;; + esac +fi + # pane_readable is consulted ONLY in the no-run fallback below. The run-step path # stays authoritative regardless of pane liveness - judge by the run-step, not the # shell - so a finished crew whose endpoint has closed still reports its run-step diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 79ece97a0a..30d451db5a 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -9,9 +9,17 @@ # not a watcher, daemon, PR poll, or forge client of its own. # `scan` evaluates at most once per FM_INACTIVE_RECONCILE_SECS (default 900, # valid 60..1800) per home, except that --startup performs the same cheap scan -# immediately during a locked session start. Each scan has an aggregate -# FM_INACTIVE_RECONCILE_BUDGET_SECS bound (default 10, valid 1..30) and resumes -# after its last visited child on the next scan. +# immediately during a locked session start. Each scan uses an aggregate +# FM_INACTIVE_RECONCILE_BUDGET_SECS deadline (default 10, valid 1..30) and +# resumes after its last visited child on the next scan. +# The scan enforces that budget itself through a whole-second deadline, and the +# first due child of every scan is always visited with at least a one-second +# state-read bound: whole-second arithmetic can otherwise round a small budget +# to zero mid-scan, and an invocation that exits having visited nothing would +# advance the durable cursor past a child it never examined. A process-group +# kill one second after the budget remains as a backstop for a scan wedged in +# an unbounded wait (for example a live-held wake-queue lock), so the clean +# deadline path is not racing its own backstop. # # It considers only a direct ordinary crewmate whose newest meta, status, or # turn-ended mtime is older than that interval and whose last status is not @@ -377,8 +385,13 @@ reconcile_direct_child() { # return "$rc" } +# SCAN_FIRST_VISIT_PENDING is armed by scan() before its passes. The deadline +# below is whole-second arithmetic, so a small budget can quantize to zero +# between the deadline computation and these checks; without the guaranteed +# first visit, such a scan would return 3 having examined no child at all while +# write_scan_marker had already advanced the cursor past the skipped child. scan_pass() { # - local cursor=$1 range=$2 deadline=$3 self=${4:-} meta id remaining rc + local cursor=$1 range=$2 deadline=$3 self=${4:-} meta id remaining rc first for meta in "$STATE"/*.meta; do [ -f "$meta" ] || continue id=$(basename "$meta" .meta) @@ -387,9 +400,19 @@ scan_pass() { # after) [ -z "$cursor" ] || [[ "$id" > "$cursor" ]] || continue ;; through) [ -n "$cursor" ] && [[ "$id" > "$cursor" ]] && continue ;; esac - [ "$(date +%s)" -lt "$deadline" ] || return 3 + first=0 + if [ "${SCAN_FIRST_VISIT_PENDING:-0}" -eq 1 ]; then + first=1 + SCAN_FIRST_VISIT_PENDING=0 + fi + if [ "$first" -eq 0 ]; then + [ "$(date +%s)" -lt "$deadline" ] || return 3 + fi write_scan_marker "$id" || return 1 remaining=$((deadline - $(date +%s))) + if [ "$first" -eq 1 ] && [ "$remaining" -lt 1 ]; then + remaining=1 + fi [ "$remaining" -gt 0 ] || return 3 reconcile_direct_child "$id" "$meta" "$self" "$remaining" || { rc=$? @@ -420,6 +443,7 @@ scan() { fi fi deadline=$(( $(date +%s) + FM_INACTIVE_RECONCILE_BUDGET_SECS )) + SCAN_FIRST_VISIT_PENDING=1 scan_pass "$cursor" after "$deadline" "$self" || rc=$? if [ "$rc" -eq 0 ] && [ -n "$cursor" ]; then scan_pass "$cursor" through "$deadline" "$self" || rc=$? @@ -461,7 +485,11 @@ case "$mode" in --startup) startup=1 ;; *) printf 'usage: fm-inactive-reconcile.sh scan [--startup]\n' >&2; exit 2 ;; esac - if fm_run_timed "$FM_INACTIVE_RECONCILE_BUDGET_SECS" "$0" _scan-locked "$startup"; then + # The scan's own whole-second deadline enforces the budget; this outer + # process-group kill is only the backstop for a scan wedged outside every + # bounded section (an unbounded lock wait), so it fires one second after + # the deadline instead of racing the clean bounded exit it exists to guard. + if fm_run_timed $((FM_INACTIVE_RECONCILE_BUDGET_SECS + 1)) "$0" _scan-locked "$startup"; then : elif [ "$?" -ne 124 ]; then exit 1 diff --git a/bin/fm-install-actionlint.sh b/bin/fm-install-actionlint.sh new file mode 100755 index 0000000000..77eaf7e269 --- /dev/null +++ b/bin/fm-install-actionlint.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# fm-install-actionlint.sh - install CI's pinned, verified actionlint build. +# +# Downloads the official GitHub release archive for the host OS/arch, verifies +# its per-archive SHA-256 pin, and installs the binary into the destination +# directory. Supported platforms: linux amd64/x86_64, linux arm64/aarch64, +# darwin amd64/x86_64, darwin arm64/aarch64. Pins come from the official +# actionlint release checksums file. Verification uses sha256sum when present, +# otherwise shasum -a 256. An unsupported OS/arch or a missing pin fails +# without downloading. +# +# Usage: +# fm-install-actionlint.sh +set -eu + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="$("$ROOT/bin/fm-lint-workflows.sh" --required-version)" + +die() { + printf 'fm-install-actionlint.sh: %s\n' "$*" >&2 + exit 1 +} + +DESTINATION=${1:?usage: fm-install-actionlint.sh } + +os=$(uname -s) +arch=$(uname -m) +# SHA-256 pins are from actionlint_1.7.12_checksums.txt on the official +# v1.7.12 release (https://github.com/rhysd/actionlint/releases/tag/v1.7.12). +case "${os}-${arch}" in + Linux-x86_64|Linux-amd64) + ARCHIVE="actionlint_${VERSION}_linux_amd64.tar.gz" + SHA256=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + ;; + Linux-aarch64|Linux-arm64) + ARCHIVE="actionlint_${VERSION}_linux_arm64.tar.gz" + SHA256=325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6 + ;; + Darwin-x86_64|Darwin-amd64) + ARCHIVE="actionlint_${VERSION}_darwin_amd64.tar.gz" + SHA256=5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644 + ;; + Darwin-arm64|Darwin-aarch64) + ARCHIVE="actionlint_${VERSION}_darwin_arm64.tar.gz" + SHA256=aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f + ;; + *) + die "unsupported platform ${os}-${arch}; need linux or darwin on amd64/x86_64 or arm64/aarch64" + ;; +esac +[ -n "$SHA256" ] || die "no pinned checksum for ${os}-${arch}" + +URL="https://github.com/rhysd/actionlint/releases/download/v${VERSION}/${ARCHIVE}" +TMP=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/fm-actionlint.XXXXXX") +trap 'rm -rf "$TMP"' EXIT + +DOWNLOAD_ATTEMPTS=6 +download_attempt=1 +while ! curl -fsSL "$URL" -o "$TMP/$ARCHIVE"; do + [ "$download_attempt" -lt "$DOWNLOAD_ATTEMPTS" ] || { + printf 'fm-install-actionlint.sh: download failed after %s attempts\n' "$DOWNLOAD_ATTEMPTS" >&2 + exit 1 + } + printf 'fm-install-actionlint.sh: download attempt %s failed; retrying\n' "$download_attempt" >&2 + sleep $((1 << (download_attempt - 1))) + download_attempt=$((download_attempt + 1)) +done + +if command -v sha256sum >/dev/null 2>&1; then + ACTUAL_SHA256=$(sha256sum "$TMP/$ARCHIVE" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + ACTUAL_SHA256=$(shasum -a 256 "$TMP/$ARCHIVE" | awk '{print $1}') +else + die "need sha256sum or shasum to verify the actionlint archive" +fi +[ "$ACTUAL_SHA256" = "$SHA256" ] || { + printf 'fm-install-actionlint.sh: checksum mismatch for %s (expected %s, got %s)\n' \ + "$ARCHIVE" "$SHA256" "$ACTUAL_SHA256" >&2 + exit 1 +} +tar -xzf "$TMP/$ARCHIVE" -C "$TMP" +mkdir -p "$DESTINATION" +install -m 0755 "$TMP/actionlint" "$DESTINATION/actionlint" +"$DESTINATION/actionlint" -version diff --git a/bin/fm-install-shellcheck.sh b/bin/fm-install-shellcheck.sh index b947b3faab..694211e4d2 100755 --- a/bin/fm-install-shellcheck.sh +++ b/bin/fm-install-shellcheck.sh @@ -1,16 +1,56 @@ #!/usr/bin/env bash # fm-install-shellcheck.sh - install CI's pinned, verified ShellCheck build. # +# Downloads the official GitHub release archive for the host OS/arch, verifies +# its per-archive SHA-256 pin, and installs the binary into the destination +# directory. Supported platforms: linux amd64/x86_64, linux arm64/aarch64, +# darwin amd64/x86_64, darwin arm64/aarch64. Pins come from the official +# ShellCheck release asset digests. Verification uses sha256sum when present, +# otherwise shasum -a 256. An unsupported OS/arch or a missing pin fails +# without downloading. +# # Usage: # fm-install-shellcheck.sh set -eu ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" VERSION="$("$ROOT/bin/fm-lint.sh" --required-version)" -SHA256=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 -ARCHIVE="shellcheck-v${VERSION}.linux.x86_64.tar.xz" -URL="https://github.com/koalaman/shellcheck/releases/download/v${VERSION}/${ARCHIVE}" + +die() { + printf 'fm-install-shellcheck.sh: %s\n' "$*" >&2 + exit 1 +} + DESTINATION=${1:?usage: fm-install-shellcheck.sh } + +os=$(uname -s) +arch=$(uname -m) +# SHA-256 pins are the GitHub release asset digests for shellcheck v0.11.0 +# .tar.xz archives (https://github.com/koalaman/shellcheck/releases/tag/v0.11.0). +case "${os}-${arch}" in + Linux-x86_64|Linux-amd64) + ARCHIVE="shellcheck-v${VERSION}.linux.x86_64.tar.xz" + SHA256=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 + ;; + Linux-aarch64|Linux-arm64) + ARCHIVE="shellcheck-v${VERSION}.linux.aarch64.tar.xz" + SHA256=12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588 + ;; + Darwin-x86_64|Darwin-amd64) + ARCHIVE="shellcheck-v${VERSION}.darwin.x86_64.tar.xz" + SHA256=3c89db4edcab7cf1c27bff178882e0f6f27f7afdf54e859fa041fca10febe4c6 + ;; + Darwin-arm64|Darwin-aarch64) + ARCHIVE="shellcheck-v${VERSION}.darwin.aarch64.tar.xz" + SHA256=56affdd8de5527894dca6dc3d7e0a99a873b0f004d7aabc30ae407d3f48b0a79 + ;; + *) + die "unsupported platform ${os}-${arch}; need linux or darwin on amd64/x86_64 or arm64/aarch64" + ;; +esac +[ -n "$SHA256" ] || die "no pinned checksum for ${os}-${arch}" + +URL="https://github.com/koalaman/shellcheck/releases/download/v${VERSION}/${ARCHIVE}" TMP=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/fm-shellcheck.XXXXXX") trap 'rm -rf "$TMP"' EXIT @@ -25,9 +65,17 @@ while ! curl -fsSL "$URL" -o "$TMP/$ARCHIVE"; do sleep $((1 << (download_attempt - 1))) download_attempt=$((download_attempt + 1)) done -ACTUAL_SHA256=$(sha256sum "$TMP/$ARCHIVE" | awk '{print $1}') + +if command -v sha256sum >/dev/null 2>&1; then + ACTUAL_SHA256=$(sha256sum "$TMP/$ARCHIVE" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + ACTUAL_SHA256=$(shasum -a 256 "$TMP/$ARCHIVE" | awk '{print $1}') +else + die "need sha256sum or shasum to verify the ShellCheck archive" +fi [ "$ACTUAL_SHA256" = "$SHA256" ] || { - printf 'fm-install-shellcheck.sh: checksum mismatch for %s\n' "$ARCHIVE" >&2 + printf 'fm-install-shellcheck.sh: checksum mismatch for %s (expected %s, got %s)\n' \ + "$ARCHIVE" "$SHA256" "$ACTUAL_SHA256" >&2 exit 1 } tar -xJf "$TMP/$ARCHIVE" -C "$TMP" diff --git a/bin/fm-lint-workflows.sh b/bin/fm-lint-workflows.sh new file mode 100755 index 0000000000..0e2d7b07e1 --- /dev/null +++ b/bin/fm-lint-workflows.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# fm-lint-workflows.sh - owner of firstmate's GitHub workflow lint. +# +# Runs pinned actionlint on every .github/workflows/*.{yml,yaml} so a malformed +# workflow, including a self-broken ci.yml, fails in the local and no-mistakes +# lint lane before merge. A broken ci.yml cannot report its own breakage, so +# this check must not live only as a step inside that workflow. bin/fm-lint.sh +# invokes this owner on its default (no explicit-path) path, which CI and +# commands.lint both use. +# +# Usage: +# fm-lint-workflows.sh lint workflows under this repo +# fm-lint-workflows.sh --root lint workflows under +# fm-lint-workflows.sh ... lint explicit workflow files +# fm-lint-workflows.sh --required-version +# fm-lint-workflows.sh --help +set -eu + +REQUIRED_ACTIONLINT=1.7.12 +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SELF="$SELF_DIR/fm-lint-workflows.sh" +ROOT="$(cd "$SELF_DIR/.." && pwd)" + +if [ "${1:-}" = "--required-version" ]; then + printf '%s\n' "$REQUIRED_ACTIONLINT" + exit 0 +fi + +fm_lint_workflows_usage() { + sed -n '2,16{s/^# \{0,1\}//;p;}' "$SELF" +} + +EXPLICIT_ROOT= +while [ "$#" -gt 0 ]; do + case "$1" in + --root) + [ "$#" -ge 2 ] || { + printf 'fm-lint-workflows.sh: --root requires a directory.\n' >&2 + exit 2 + } + EXPLICIT_ROOT=$2 + shift 2 + ;; + --root=*) + EXPLICIT_ROOT=${1#*=} + shift + ;; + --help|-h) + fm_lint_workflows_usage + exit 0 + ;; + --) + shift + break + ;; + -*) + printf 'fm-lint-workflows.sh: unknown option: %s\n' "$1" >&2 + exit 2 + ;; + *) + break + ;; + esac +done + +if [ -n "$EXPLICIT_ROOT" ]; then + [ -d "$EXPLICIT_ROOT" ] || { + printf 'fm-lint-workflows.sh: --root is not a directory: %s\n' "$EXPLICIT_ROOT" >&2 + exit 2 + } + ROOT="$(cd "$EXPLICIT_ROOT" && pwd)" +fi + +collect_workflow_files() { + local dir=$1 + [ -d "$dir" ] || return 0 + find "$dir" -maxdepth 1 \( -name '*.yml' -o -name '*.yaml' \) -type f \ + | LC_ALL=C sort +} + +FILES=() +if [ "$#" -gt 0 ]; then + for path in "$@"; do + case "$path" in + *.yml|*.yaml) ;; + *) + printf 'fm-lint-workflows.sh: not a workflow YAML file: %s\n' "$path" >&2 + exit 2 + ;; + esac + [ -f "$path" ] || { + printf 'fm-lint-workflows.sh: workflow file not found: %s\n' "$path" >&2 + exit 2 + } + FILES+=("$path") + done +else + workflow_dir="$ROOT/.github/workflows" + while IFS= read -r path; do + [ -n "$path" ] || continue + FILES+=("$path") + done < <(collect_workflow_files "$workflow_dir") + if [ "${#FILES[@]}" -eq 0 ]; then + printf 'fm-lint-workflows.sh: no GitHub workflow files found under %s\n' \ + "$workflow_dir" >&2 + exit 1 + fi +fi + +if ! command -v actionlint >/dev/null 2>&1; then + printf 'fm-lint-workflows.sh: actionlint not found; install actionlint %s for CI parity.\n' \ + "$REQUIRED_ACTIONLINT" >&2 + exit 127 +fi +ACTIONLINT_BIN=$(command -v actionlint) +resolved=$("$ACTIONLINT_BIN" -version | awk 'NR==1 {print; exit}') +printf 'fm-lint-workflows.sh: actionlint %s (pinned %s)\n' "$resolved" "$REQUIRED_ACTIONLINT" >&2 +if [ "$resolved" != "$REQUIRED_ACTIONLINT" ]; then + printf 'fm-lint-workflows.sh: actionlint %s required for CI parity, found %s. Install %s.\n' \ + "$REQUIRED_ACTIONLINT" "$resolved" "$REQUIRED_ACTIONLINT" >&2 + exit 1 +fi + +# fm-lint.sh owns ShellCheck of the canonical shell set. Disable actionlint's +# extra shell and Python subprocess linters so this gate is the named workflow +# linter, not a second shell lint of `run:` blocks. +set +e +"$ACTIONLINT_BIN" -no-color -shellcheck= -pyflakes= -- "${FILES[@]}" +rc=$? +set -e + +if [ "$rc" -ne 0 ]; then + exit "$rc" +fi + +printf 'fm-lint-workflows.sh: %s workflow files valid\n' "${#FILES[@]}" +exit 0 diff --git a/bin/fm-lint.sh b/bin/fm-lint.sh index 5c3bebcb21..d848a2ac83 100755 --- a/bin/fm-lint.sh +++ b/bin/fm-lint.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# fm-lint.sh - the single owner of firstmate's shell-lint definition. +# fm-lint.sh - the single owner of firstmate's lint definition. # # Runs its file set with ShellCheck's default severity, extended analysis, # ambient configuration disabled, and one exact ShellCheck version. CI and @@ -7,6 +7,9 @@ # version, bounded execution, and diagnostics ordering cannot drift. # Tests stop source analysis at imported production modules because every # production shell is already a canonical, source-aware root of this same run. +# The default (no explicit-path) path also runs bin/fm-lint-workflows.sh so a +# malformed GitHub workflow, including a self-broken ci.yml, fails locally +# before merge instead of only failing to run as CI. # # With no explicit paths, the file set depends on context: # - In CI (GITHUB_ACTIONS=true or CI=true), on the main branch, or when no @@ -16,10 +19,10 @@ # - Otherwise (an ordinary local branch with a real merge-base) it lints # only the canonical-set files changed since that merge-base, including # uncommitted local edits, via plain local `git diff` (no network, no -# `gh`). A branch with zero matching changed files exits 0 and prints a -# "no changed lint targets" note instead of running ShellCheck. +# `gh`). A branch with zero matching changed files skips ShellCheck and +# prints a "no changed lint targets" note, then still validates workflows. # Explicit paths always bypass this file-set selection and lint exactly the -# given paths, matching the same config. +# given paths, matching the same config, without the workflow YAML check. # # Canonical lint defaults to two bounded workers over two stable logical shards. # Each shard writes separate diagnostics, and the parent replays those outputs in @@ -97,7 +100,14 @@ if [ "${1:-}" = "--required-version" ]; then fi fm_lint_usage() { - sed -n '2,39{s/^# \{0,1\}//;p;}' "$SELF" + sed -n '2,42{s/^# \{0,1\}//;p;}' "$SELF" +} + +# Default no-args lint also validates GitHub workflows. Explicit paths stay a +# ShellCheck-only override so callers can target one shell root. +fm_lint_run_workflows() { + [ "$EXPLICIT_PATHS" -eq 0 ] || return 0 + "$SELF_DIR/fm-lint-workflows.sh" } JOBS=${FM_LINT_JOBS:-2} @@ -180,7 +190,9 @@ fm_lint_is_canonical_root() { } CHANGED_MODE=0 +EXPLICIT_PATHS=0 if [ "$#" -gt 0 ]; then + EXPLICIT_PATHS=1 ROOTS=("$@") else full_lint=1 @@ -238,7 +250,9 @@ fi if [ "$CHANGED_MODE" -eq 1 ] && [ "$ROOT_COUNT" -eq 0 ]; then printf 'fm-lint.sh: no changed lint targets\n' - exit 0 + overall_rc=0 + fm_lint_run_workflows || overall_rc=$? + exit "$overall_rc" fi if [ -n "$TELEMETRY" ]; then @@ -538,4 +552,10 @@ EOF fi fi +if [ "$overall_rc" -eq 0 ]; then + fm_lint_run_workflows || overall_rc=$? +else + fm_lint_run_workflows || true +fi + exit "$overall_rc" diff --git a/bin/fm-peek.sh b/bin/fm-peek.sh index 97d2ffe2d2..e3156f66ed 100755 --- a/bin/fm-peek.sh +++ b/bin/fm-peek.sh @@ -3,6 +3,11 @@ # Usage: fm-peek.sh [lines=40] # may be an exact task id, a legacy fm- task label resolved # through this home's state/.meta, or an explicit backend target. +# A selector whose meta records remote_host= is a remote secondmate: its pane +# lives on that host, so the capture routes over fm-on.sh to the host-local +# capture (fm-remote-secondmate-control.sh), clamped to that command's +# 100-line cap. An unreachable host or unreadable endpoint fails loudly naming +# the host; the local backend adapters are never asked to read a remote target. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -16,9 +21,25 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" "$SCRIPT_DIR/fm-guard.sh" || true RAW_TARGET=$1 -T=$(fm_backend_resolve_selector "$RAW_TARGET" "$STATE") N=${2:-40} +REMOTE_META=$(fm_backend_meta_for_selector "$RAW_TARGET" "$STATE" 2>/dev/null || true) +if [ -n "$REMOTE_META" ] && [ -n "$(fm_meta_get "$REMOTE_META" remote_host)" ]; then + REMOTE_ID=${REMOTE_META##*/} + REMOTE_ID=${REMOTE_ID%.meta} + REMOTE_HOST=$(fm_meta_get "$REMOTE_META" remote_host) + case "$N" in ''|*[!0-9]*|0) N=40 ;; esac + [ "$N" -le 100 ] || N=100 + if ! FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-on.sh" "$REMOTE_ID" \ + fm-remote-secondmate-control.sh capture "$REMOTE_ID" "$N" < /dev/null; then + echo "error: could not read the remote pane of $REMOTE_ID on $REMOTE_HOST (host unreachable or endpoint unreadable; the mate is not thereby dead)" >&2 + exit 1 + fi + exit 0 +fi + +T=$(fm_backend_resolve_selector "$RAW_TARGET" "$STATE") + BACKEND=$(fm_backend_of_selector "$RAW_TARGET" "$T" "$STATE") EXPECTED_LABEL=$(fm_backend_expected_label_of_selector "$RAW_TARGET" "$STATE") diff --git a/bin/fm-pending-reply-lib.sh b/bin/fm-pending-reply-lib.sh index a06cba5f8c..5453585d0e 100755 --- a/bin/fm-pending-reply-lib.sh +++ b/bin/fm-pending-reply-lib.sh @@ -19,6 +19,9 @@ # # Record location (parent FM_HOME): # state/pending-replies/ +# One more durable input, owned by bin/fm-procevent-remote-reply.sh and read +# here: state/remote-replies/.caught-up, the remote reply mirror's +# watermark (see the remote reply-channel freshness section below). # Each record is a key=value file owned by this library. Schema: # schema=fm-pending-reply.v1 # corr_id= privacy-safe correlation token @@ -698,6 +701,74 @@ fm_pending_reply_mark_turn_completed() { # [which: reques return 0 } +# --- remote reply-channel freshness ----------------------------------------- +# +# A LOCAL secondmate appends its report straight into the parent's +# state/.status, so an absent correlated line there is immediate evidence +# that no report was written. A REMOTE mate's reports reach that same file only +# through the asynchronous mirror in bin/fm-procevent-remote-reply.sh, so the +# same absence proves nothing until that mirror has actually been read past the +# turn that should have produced the report. Without this distinction the guard +# nags a REPOST REQUIRED for a reply the mate did write and the parent simply +# had not received yet - the common case, because the mirror's poll window is +# comparable to the recovery grace. +# +# The mirror therefore publishes one watermark: the epoch at which it last knew +# it had read the remote log through its end. Only that adapter writes it (it +# owns the channel), and only this library reads it. A channel that is behind, +# unarmed, or broken simply never advances the watermark, so the request stays +# durably open and un-nagged; the mirror escalates its own continuity failures. +fm_pending_reply_remote_channel_watermark_path() { # + printf '%s/remote-replies/%s.caught-up' "$1" "$2" +} + +# Record that the mirrored remote reply log for was read through its +# end at (default now). Called only by the remote reply adapter. +fm_pending_reply_note_remote_channel_caught_up() { # [epoch] + local state=$1 task_id=$2 epoch=${3-} path dir tmp + [ -n "$state" ] && [ -n "$task_id" ] || return 2 + case "$epoch" in ''|*[!0-9]*) epoch=$(fm_pending_reply_now) ;; esac + path=$(fm_pending_reply_remote_channel_watermark_path "$state" "$task_id") + dir=$(dirname "$path") + mkdir -p "$dir" || return 1 + chmod 700 "$dir" 2>/dev/null || true + [ ! -L "$path" ] || return 1 + tmp="$dir/.caught-up.$task_id.$$" + printf 'caught_up_epoch=%s\n' "$epoch" > "$tmp" || { rm -f -- "$tmp"; return 1; } + chmod 600 "$tmp" 2>/dev/null || true + mv -f -- "$tmp" "$path" +} + +# Print the watermark epoch, or nothing when the channel never reported itself +# caught up. Never invents a value. +fm_pending_reply_remote_channel_epoch() { # + local path epoch + path=$(fm_pending_reply_remote_channel_watermark_path "$1" "$2") + [ -f "$path" ] && [ ! -L "$path" ] || return 0 + epoch=$(sed -n 's/^caught_up_epoch=//p' "$path" 2>/dev/null | head -1) + case "$epoch" in ''|*[!0-9]*) return 0 ;; esac + printf '%s' "$epoch" +} + +# 0 when is a secondmate whose reports cross a machine boundary. +fm_pending_reply_target_is_remote() { # + local meta="$1/$2.meta" + [ -f "$meta" ] || return 1 + [ -n "$(fm_meta_get "$meta" remote_host)" ] +} + +# 0 when "no correlated report in the parent status log" is admissible evidence +# that the mate never reported: always for a local target, and for a remote one +# only once the mirror has been read through its end at or after . +fm_pending_reply_missing_report_is_evidence() { # + local state=$1 task_id=$2 since=$3 caught + fm_pending_reply_target_is_remote "$state" "$task_id" || return 0 + case "$since" in ''|*[!0-9]*) return 1 ;; esac + caught=$(fm_pending_reply_remote_channel_epoch "$state" "$task_id") + [ -n "$caught" ] || return 1 + [ "$caught" -ge "$since" ] +} + # Build the one automatic recovery message for a pending record. fm_pending_reply_recovery_message() { # local rec=$1 corr summary token msg @@ -736,6 +807,8 @@ fm_pending_reply_send_recovery() { # age=$((now - delivered)) [ "$age" -ge "$grace" ] || return 1 task_id=$(fm_pending_reply_get "$rec" task_id) + # A remote mate's report may exist and simply not have been mirrored yet. + fm_pending_reply_missing_report_is_evidence "$state" "$task_id" "$completed" || return 1 parent_home=$(fm_pending_reply_get "$rec" parent_home) msg=$(fm_pending_reply_recovery_message "$rec") sender_pid=${BASHPID:-$$} @@ -984,6 +1057,10 @@ _fm_pending_reply_maybe_escalate_locked() { # recovery_sent) completed=$(fm_pending_reply_get "$rec" recovery_turn_completed_epoch) [ -n "$completed" ] || return 1 + # Same reply-channel evidence rule the recovery repost obeys: a missing + # correlated report is not a missed report until the mirror caught up. + fm_pending_reply_missing_report_is_evidence "$state" \ + "$(fm_pending_reply_get "$rec" task_id)" "$completed" || return 1 ;; delivery_unknown|recovery_failed|recovery_unknown) ;; *) return 1 ;; diff --git a/bin/fm-procevent-remote-reply.sh b/bin/fm-procevent-remote-reply.sh index ca816541df..abba201a6d 100755 --- a/bin/fm-procevent-remote-reply.sh +++ b/bin/fm-procevent-remote-reply.sh @@ -56,6 +56,10 @@ # - at-most-once append, because a captured generation can be replayed # - control-byte normalization, so content-bearing bytes from another machine # cannot make the parent's status file unsafe to read +# - the caught-up watermark this channel publishes for +# bin/fm-pending-reply-lib.sh, because a report that exists remotely but has +# not been mirrored yet must not be mistaken for a report the mate never +# wrote (see WINDOW_CLOSED_EMPTY below) # Line framing and size bounding belong to bin/fm-remote-delta-read.sh, which # delivers only whole lines and breaks continuity on an over-long one. set -u @@ -235,12 +239,26 @@ cmd_arm() { ) } +# The reader's exit when its wait window closed with no complete new line. That +# is the one moment this channel can prove it is not behind: the window opened +# with the remote log matching the committed cursor exactly (any pending bytes +# would have returned a delta at once), so the parent had read that log through +# its end at window START. The window start, not its close, is therefore the +# honest watermark, and bin/fm-pending-reply-lib.sh consumes it so a missing +# correlated report is judged only against a channel known to have caught up. +WINDOW_CLOSED_EMPTY=75 + cmd_source() { - local id=${1:-} + local id=${1:-} started rc=0 validate_id "$id" read_cursor "$id" - exec "$SCRIPT_DIR/fm-on.sh" "$id" fm-remote-delta-read.sh \ - "$REMOTE_LOG" "$CURSOR_OFFSET" "$CURSOR_HASH" "$WAIT_SECONDS" < /dev/null + started=$(fm_pending_reply_now) + "$SCRIPT_DIR/fm-on.sh" "$id" fm-remote-delta-read.sh \ + "$REMOTE_LOG" "$CURSOR_OFFSET" "$CURSOR_HASH" "$WAIT_SECONDS" < /dev/null || rc=$? + if [ "$rc" -eq "$WINDOW_CLOSED_EMPTY" ]; then + fm_pending_reply_note_remote_channel_caught_up "$STATE" "$id" "$started" || true + fi + return "$rc" } safe_doc_path() { @@ -512,6 +530,7 @@ cmd_retire_finalize_locked() { fi rm -f -- "$(cursor_path "$id")" rm -f -- "$CURSOR_DIR/$id".*.ingested + rm -f -- "$(fm_pending_reply_remote_channel_watermark_path "$STATE" "$id")" } cmd_retire() { diff --git a/bin/fm-quota-axi-lib.sh b/bin/fm-quota-axi-lib.sh index 7be4c99614..1f59be6792 100644 --- a/bin/fm-quota-axi-lib.sh +++ b/bin/fm-quota-axi-lib.sh @@ -9,7 +9,7 @@ # turns a failing check into the operator-facing MISSING diagnostic, which is # what keeps an older build from reaching a dispatch intake at all. -FM_QUOTA_AXI_MIN=0.1.25 +FM_QUOTA_AXI_MIN=0.1.29 fm_quota_axi_compatible() { local timeout=${1:-} output parts major minor patch extra diff --git a/bin/fm-remote-delta-read.sh b/bin/fm-remote-delta-read.sh index 73e90bb795..d4c26bd669 100755 --- a/bin/fm-remote-delta-read.sh +++ b/bin/fm-remote-delta-read.sh @@ -12,9 +12,9 @@ # # Exit 75 means the wait window closed with no complete line. SIGTERM exits the # same way after cleanup. The remote job worker preempts this read-only poll to -# unblock any queued command other than another reply long-poll. The -# bin/fm-remote-job-lib.sh header owns that contract, and a preempted read is -# indistinguishable from an empty window. +# unblock any queued command other than another reply long-poll, then publishes +# that preemption as distinct exit 76. The bin/fm-remote-job-lib.sh header owns +# that contract. set -eu FM_HOME=${FM_HOME:?FM_HOME is required} diff --git a/bin/fm-remote-job-lib.sh b/bin/fm-remote-job-lib.sh index 0af1f5aea8..73bffa54c7 100755 --- a/bin/fm-remote-job-lib.sh +++ b/bin/fm-remote-job-lib.sh @@ -20,10 +20,10 @@ # fm_remote_job_command_preemptible names the read-only long-poll class # (fm-remote-delta-read.sh, the reply-log delta read). The worker preempts a # running preemptible job as soon as a non-preemptible job is queued and -# publishes exit 75 with emptied stdout and stderr, identical to the poll's own -# elapsed-window-with-no-data result. The delta read is non-destructive and -# cursor-anchored, so the caller's normal re-arm re-reads the same data and a -# preempted poll loses nothing. +# publishes exit 76 with emptied stdout and stderr, distinct from the poll's +# exit 75 elapsed-window-with-no-data result. The delta read is non-destructive +# and cursor-anchored, so the caller's normal re-arm re-reads the same data and +# a preempted poll loses nothing. # # The worker accepts only a tracked, non-symlink executable named fm-*.sh below # its configured FM_ROOT/bin. Every child receives env -i with the composed @@ -57,6 +57,8 @@ FM_REMOTE_JOB_TIMEOUT=${FM_REMOTE_JOB_TIMEOUT:-360} FM_REMOTE_JOB_WAIT_GRACE=${FM_REMOTE_JOB_WAIT_GRACE:-30} FM_REMOTE_JOB_POLL_SECONDS=${FM_REMOTE_JOB_POLL_SECONDS:-0.05} FM_REMOTE_JOB_REAP_SECONDS=${FM_REMOTE_JOB_REAP_SECONDS:-3600} +# shellcheck disable=SC2034 # Shared protocol constant consumed by the worker and sourcing callers. +FM_REMOTE_JOB_PREEMPTED_EXIT=76 FM_REMOTE_JOB_OPERATOR_PATH= FM_REMOTE_JOB_CHILD_PATH= FM_REMOTE_JOB_STATE= diff --git a/bin/fm-remote-job-worker.sh b/bin/fm-remote-job-worker.sh index 6046fdda36..2a49dd6694 100755 --- a/bin/fm-remote-job-worker.sh +++ b/bin/fm-remote-job-worker.sh @@ -485,7 +485,7 @@ worker_run_with_timeout() { # [args...] WORKER_ACTIVE_JOB= [ "$timed_out" -eq 0 ] || return 124 [ "$heartbeat_failed" -eq 0 ] || return 125 - [ "$WORKER_PREEMPTED" -eq 0 ] || return 75 + [ "$WORKER_PREEMPTED" -eq 0 ] || return "$FM_REMOTE_JOB_PREEMPTED_EXIT" return "$rc" } diff --git a/bin/fm-remote-secondmate-control.sh b/bin/fm-remote-secondmate-control.sh index f2edb32a7b..aa17c95286 100755 --- a/bin/fm-remote-secondmate-control.sh +++ b/bin/fm-remote-secondmate-control.sh @@ -188,6 +188,12 @@ cmd_send() { validate_id "$id" validate_home "$id" remote_endpoint_require "$id" + # fm-send's exit status is the delivery verdict the parent home acts on + # (0 = confirmed, 3 = delivered with the submit read-back unconfirmed, other + # nonzero = failed; see bin/fm-send.sh's header). The job worker, entrypoint, + # and ssh all preserve it, so no mapping may happen here: flattening exit 3 + # into a generic failure is exactly the false-negative the parent's remote + # send path exists to avoid. FM_HOME="$TARGET_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$TARGET_HOME/state" \ "$SCRIPT_DIR/fm-send.sh" "$REMOTE_ENDPOINT_TARGET" "$message" } diff --git a/bin/fm-send.sh b/bin/fm-send.sh index c46c55a340..cc199c9b01 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -15,6 +15,13 @@ # submit or reports an inconclusive send. If a swallowed Enter is positively # confirmed, fm-send exits NON-ZERO so the caller knows the steer did not land # instead of silently leaving an unsubmitted instruction. +# Exit status contract: 0 = submit confirmed (or, for a remote secondmate +# target, delivered with confirmation pending - see the remote paragraph); +# 3 = the text was typed into the live endpoint and Enter was sent, but the +# submit read-back stayed unconfirmed (verify the pane before any resend, and +# never re-type blindly; a marked request's pending-reply expectation stays +# armed because this outcome is not a proven failure); any other nonzero = the +# send failed and nothing may be assumed delivered. # Submission dispatches through the target's recorded backend; the tmux adapter # shares its composer/submit core with the away-mode daemon via bin/fm-tmux-lib.sh. # Tune with FM_SEND_RETRIES (default 3) / FM_SEND_SLEEP (0.4). @@ -33,9 +40,25 @@ # also receives a privacy-safe correlation id and a durable parent record under # state/pending-replies/ before delivery (bin/fm-pending-reply-lib.sh). Delivery # success and reply success are separate facts: a successful submit never -# resolves the expectation. Set FM_PENDING_REPLY_EXISTING_CORR= when -# re-sending a recovery request for an already-open expectation so a second -# record is not created. Direct unmarked captain input never creates one. +# resolves the expectation, and an unconfirmed submit (exit 3) keeps it armed +# rather than dropping it; only a proven send failure discards it. Set +# FM_PENDING_REPLY_EXISTING_CORR= when re-sending a recovery request for an +# already-open expectation so a second record is not created. Direct unmarked +# captain input never creates one. +# +# Remote secondmate delivery: the send crosses fm-on.sh to a host-local leg +# (bin/fm-remote-secondmate-control.sh cmd_send) that runs this same verified +# submit against the recorded remote Herdr pane and relays its exit status +# unchanged. A leg that delivered the text into the live verified pane but +# could not synchronously confirm the submit (exit 3 - typically a busy mate +# whose harness queues the steer and keeps rendering it) is reported here as +# DELIVERED with confirmation pending: fm-send prints a non-error notice, +# exits 0, marks the pending-reply expectation delivered, and closes any +# --resolve-key decisions. Empirically that pattern is a delivered steer, a +# resend duplicates the instruction, and the parent's pending-reply +# recovery/escalation still surfaces the rare genuinely lost request. Transport +# loss (ssh exit 255, completion unknown) and every real remote failure keep +# failing loudly with the remote leg's own stderr attached. # # Decision closure (answerer-closes): pass --resolve-key (repeatable, # before the message) when this send answers an open keyed needs-decision: or @@ -63,7 +86,9 @@ # in this home's status log per status_open_decisions (bin/fm-classify-lib.sh), or # an active captain hold for the target task. A key in neither is refused before # sending, so a mistyped key cannot deliver an answer while silently orphaning the -# decision. A failed or unconfirmed send never closes a key; a +# decision. A failed or unconfirmed send never closes a key (a remote +# delivered-with-pending-confirmation outcome counts as delivered - see the +# remote paragraph above); a # delivered answer whose closing append fails exits nonzero with the exact # manual close command, leaving the decision open to re-surface (the safe # direction). A send without the flag never closes anything: a routine steer, @@ -537,12 +562,27 @@ else # Type once, submit, verify. Only exact empty confirms delivery; every other # verdict preserves the loud refusal boundary. send_rc=0 + REMOTE_DELIVERY_NOTICE=0 if [ "$TARGET_BACKEND" = remote ]; then - if "$SCRIPT_DIR/fm-on.sh" "$TARGET_REMOTE_ID" fm-remote-secondmate-control.sh send "$TARGET_REMOTE_ID" "$MESSAGE" < /dev/null >/dev/null; then + # The remote leg is this same script running host-locally against the + # recorded Herdr pane (cmd_send in fm-remote-secondmate-control.sh), so its + # submit verification IS the local one, and fm-on/the remote worker relay + # its exit status unchanged. Exit 3 is the delivered-unconfirmed contract + # (see this script's header) crossing the ssh boundary: the text reached + # the live verified pane and Enter was sent; only the synchronous read-back + # stayed unconfirmed. The remote stderr is held back and replayed only for + # a real failure, so a delivered outcome does not surface the inner leg's + # diagnostics as alarm. + remote_err=$("$SCRIPT_DIR/fm-on.sh" "$TARGET_REMOTE_ID" fm-remote-secondmate-control.sh send "$TARGET_REMOTE_ID" "$MESSAGE" < /dev/null 2>&1 >/dev/null) || send_rc=$? + if [ "$send_rc" -eq 0 ]; then + verdict=empty + elif [ "$send_rc" -eq 3 ]; then verdict=empty + send_rc=0 + REMOTE_DELIVERY_NOTICE=1 else - send_rc=$? verdict=send-failed + [ -z "$remote_err" ] || printf '%s\n' "$remote_err" >&2 fi elif verdict=$(fm_backend_send_text_submit "$TARGET_BACKEND" "$T" "$MESSAGE" "$retries" "$sleep_s" "$settle" "$EXPECTED_LABEL"); then : @@ -571,6 +611,23 @@ else echo "error: text not sent to $T ($TARGET_BACKEND send failed; tried $RESOLUTION_TRIED)" >&2 exit 1 ;; + pending) + # The text was typed into the live target and Enter was sent; only the + # submit read-back stayed unconfirmed (e.g. a busy harness queues the + # steer and keeps rendering it). That is not a proven failure, so never + # re-type the message: verify the pane instead. Exit 3 is the documented + # delivered-unconfirmed status, and the remote send leg above depends on + # it crossing the ssh boundary intact. + # The pending-reply expectation is deliberately NOT discarded here: this + # is the same not-a-failure outcome the remote leg reports as delivered, + # so dropping it would silently stop tracking a marked request that very + # likely landed. It stays armed on its unconfirmed-delivery marker, so a + # correlated report still resolves it and an unanswered one still + # surfaces through the library's own reconciliation + # (bin/fm-pending-reply-lib.sh). + echo "fm-send: text delivered to $T but submission is unconfirmed (verdict=pending; tried $RESOLUTION_TRIED); do not retype or blindly resend - verify with fm-peek.sh, then re-send '--key Enter' only if the composer still holds the text" >&2 + exit 3 + ;; *) if [ "$PENDING_REPLY_CREATED" = 1 ] && [ -n "$PENDING_REPLY_CORR" ]; then fm_pending_reply_discard_undelivered "$STATE" "$PENDING_REPLY_CORR" || true @@ -600,6 +657,12 @@ else fm_send_close_resolved_keys "$RESOLVE_ANSWER_TEXT" || exit 1 fm_send_feed_resolved_holds "$RESOLVE_ANSWER_TEXT" || exit 1 fi + # Remote delivered-with-pending-confirmation: the outcome above is treated as + # delivered (expectation marked, keys closed), and this one non-error notice + # carries the remaining nuance so nobody re-sends the steer. + if [ "$REMOTE_DELIVERY_NOTICE" = 1 ]; then + echo "fm-send: delivered to remote secondmate $TARGET_REMOTE_ID; the remote pane accepted the text and Enter, and only the synchronous submit confirmation is still pending. This is not a failure - do not resend; the pending-reply expectation stays armed." >&2 + fi # Submit landed with exact empty. Confirmation only proves the text was # accepted; the harness still needs a beat to spin up the # turn before its busy footer shows. Pause so an immediate peek catches the diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 4ca26c865e..24ced99088 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -140,6 +140,7 @@ family_for_basename() { fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ fm-kimi-harness.test.sh|fm-muse-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\ + fm-lint-workflows.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ fm-subagent-pretool-check.test.sh|\ @@ -960,7 +961,8 @@ families_for_changed_path() { # lane's contract coverage re-runs. printf '%s\n' real-herdr-gated ;; - bin/fm-lint.sh|bin/fm-install-shellcheck.sh|\ + bin/fm-lint.sh|bin/fm-lint-workflows.sh|bin/fm-install-shellcheck.sh|\ + bin/fm-install-actionlint.sh|\ bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 856264bb76..a61ade4675 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -37,15 +37,17 @@ # (window_is_busy true) is exempt from the above, but # only up to BUSY_TURN_MAX_SECS with no completed turn # (state/.turn-ended, or the spawn record before any -# turn completes); past that bound busy_turn_over_age -# routes it through the same wedge timer, so it surfaces -# with the identical "stale: ..." reason, escalation -# count, and demand-deep-inspection marker, for human -# inspection only - never an automatic interrupt, -# signal, or restart of the worker or its tool process. -# That bound is exempt from the working deferral above: -# the busy signal a re-verification would read is the -# very one BUSY_TURN_MAX_SECS stopped trusting. +# turn completes). Past that bound, a declared external +# wait or verified captain-held transfer uses the long +# pause recheck cadence; every other pane goes through +# the same wedge timer and surfaces with the identical +# "stale: ..." reason, escalation count, and +# demand-deep-inspection marker, for human inspection +# only - never an automatic interrupt, signal, or restart +# of the worker or its tool process. That wedge timer is +# exempt from the working deferral above: the busy signal +# a re-verification would read is the very one +# BUSY_TURN_MAX_SECS stopped trusting. # check: