@feigi/fleet-ctl 3.76.8 → 3.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/fleet-fix-applier.agent.md +2 -1
- package/commands/review-and-fix.md +1 -1
- package/package.json +1 -1
- package/scripts/fleet-event.mjs +119 -12
- package/scripts/fleet-tick.mjs +10 -5
- package/scripts/push-sync.mjs +231 -0
- package/skills/next-ticket/SKILL.md +9 -3
- package/skills/run-team/SKILL.md +5 -10
- package/skills/run-team/briefs/fix-applier.md +2 -2
- package/skills/run-team/references/reviewers.md +4 -4
- package/skills/run-team/references/tick-rows.md +3 -1
|
@@ -333,7 +333,8 @@ whose `ledger.mjs filed` failed twice, which the controller's settle records —
|
|
|
333
333
|
send the one line your brief gives, `<your name>: <outcome> <path>`, and exit.
|
|
334
334
|
**Deferring everything is a normal
|
|
335
335
|
outcome, not a stall:** nothing is then staged, `git commit` refuses an empty
|
|
336
|
-
index,
|
|
336
|
+
index, `~/.fleet/bin/fleet-run push-sync.mjs --branch <branch>` prints git's
|
|
337
|
+
`Everything up-to-date`, and your
|
|
337
338
|
report's `outcome` is `no-op`, its `head` the unchanged SHA. Say it
|
|
338
339
|
explicitly — silence there is indistinguishable from a member that died. Never
|
|
339
340
|
manufacture a commit to make CI fire.
|
|
@@ -12,7 +12,7 @@ argument-hint: [pr-number]
|
|
|
12
12
|
See **Specialists** — those constraints are not optional.
|
|
13
13
|
2. Plan the actions. Split explicitly into **apply now** / **defer**. A finding is deferred, never dropped. **Every `suggestion` — the review's `severity`, the plugin's *Suggestions* bucket — arrives unchecked: the review budgets that band 0 refuters, so nothing has looked at it yet.** Checking it is now your job, not nobody's. So split it by SCOPE first, and check it in either scope. Out of the PR's ticket scope → defer and file, but **the claim bar runs first:** a suggestion whose claim is about shape — step 5's worth-a-claim bar, recorded `claimKind: shape` — gets no refuter and goes to the closed `PR #<pr> review: the suggestion band, checked` record, and every other out-of-scope suggestion gets the same refuter an in-scope one does, dispatched the same way, before it is filed. In scope → dispatch one refuter against it, biased to refuse, and hand it this instruction verbatim with one substitution: resolve `<scratch>/pr<N>/<finding>/` in it to an absolute path under your own run root — `mktemp -d` once per round for `<scratch>/pr<N>/fix-XXXXXXXX/`, never bare `pr<N>`, since finding ids restart at 1 each round too — and write that absolute path into the refuter's prompt in its place; a refuter never derives its own path, and tell it your step-3 `testCmd`, which its rules call `<testCmd>`: *Try to REFUTE this finding. Default to refuted=true if uncertain. Verify by RUNNING something — compile it, run the test, apply the mutation. Do not reason your way to agreement. Observe that run synchronously — run the command, wait for it, read its exit code. Never poll a log file for a completion marker: prefer ONE blocking run to a poll loop, and treat its return as permission to look, never as the answer. Reading a log the run has already finished writing is fine; waiting on one is not. If you match a test reporter's own output, accepting both `ℹ` and `#` is necessary but NOT sufficient — strip SGR escapes first as well. node's prefix moves with the node version and with whether stdout is a TTY, and color wraps the whole line so it begins with ESC and no prefix anchor matches at all, which returns empty at exit 0 — indistinguishable from a hung run and from a run of zero tests. For an uncolored baseline use `env -u FORCE_COLOR`; `FORCE_COLOR=` empty still enables color, so it is not a control. State your search scope AND what your pattern would have missed. A grep over one ref does not support a claim about history; a pattern built from the token a diff removed does not support a claim that the category is empty. When your check scores a mutant killed or a test red, run only in your own copy of the tree, and these rules decide it and nothing else: Valid red: a run that completed — the command returned on its own, with no signal and no timeout, deadline or kill having fired — in which the test the claim names is reported failing. An exit ≠ 0 with no failing test is not red, and a different test failing is not red for this claim. A red in the full suite only triggers a narrowed run; it is never evidence on its own. Narrowed run: the Test entrypoint with arguments appended, `<testCmd> <args>`. That appended arguments reach the Test entrypoint is all that is promised about them, so a narrowed run counts only if it reports `tests` > 0 and its output names the claimed test. If no narrowing can be proven, run the full `<testCmd>` and read the named test's own result from its output. Kill: the mutant produced a valid red in a narrowed run, and the same narrowed command, in the same tree with the mutant reverted, ran a valid green with the named test passing — completed, exit 0, `tests` > 0, `cancelled` 0. A completed exit ≠ 0 with the named test not failing is not a kill, and no kill stands without that green run on the unmutated tree. The result is inconclusive, never a kill, when the red does not reproduce in the narrowed run, when the unmutated tree is red too, or when no usable run exists. Evidence line: every kill and every valid red you claim carries one line in the claim's own free-text field: `red: <cmd> → exit N, fail K incl <test>; baseline: <cmd> → exit 0, <test> pass`. A failing-test finding filed off the review's shared run carries the `red:` half alone. This refuter has no abstain: a run that could not decide — neither a valid red nor a valid green — is no kill and no refutation, so name the runs in `reason`, each with its evidence line, and leave `refuted` true, so the suggestion defers on the record instead of applying on a check that decided nothing. A failure injection with no positive control has produced NO result, never a negative one. Before you read an injected fault — an env var, an argv word, a mutant — as having had no effect, prove the injection reached the child: one run whose output differs with it present versus absent, or the child echoing the injected value back. Uncontrolled, the cell is unrun — say so in your verdict instead of reporting a no-effect result. Build such an invocation as an array expanded braced and quoted — `cfg=(SETB=1 BADJ=1); env "${cfg[@]}" sh ./probe.sh` — or inline the assignments literally — `env SETB=1 BADJ=1 sh ./probe.sh`; NEVER from an unquoted scalar — `cfg="SETB=1 BADJ=1"; env $cfg sh ./probe.sh` — which under zsh passes ONE argument, sets a variable literally named `SETB` to `1 BADJ=1`, never sets `BADJ` at all, and still exits 0. `env $cfg[@]` is not the portable spelling either: measured, bash word-splits it into `SETB=1` and `BADJ=1[@]`, so the injection variable is set to a corrupted value, while zsh behaves exactly as with the bare `$cfg` — `BADJ` never set, exit 0. Everything you write — mutants, fixtures, scratch repos — goes under `<scratch>/pr<N>/<finding>/` and nowhere else; the checkout and any worktree are never write targets, though `git show`/`git archive` at a pinned ref read fine anywhere. Chain the directory change into the command, `cd "$D" && git …`, never `cd "$D"; git …`, so a failed `cd` cannot leave a `git` command running in the checkout — and bracket a fixture's own git with `git rev-parse --show-toplevel`: before `git init` it must NOT resolve to the host repository, and `fatal: not a git repository` (exit 128) is the usual pass, not a failure — an outer repository whose ignore rules cover your scratch dir is a pass too; before any `git commit` it must resolve to your scratch path, which catches a forgotten `git init` under any enclosing repository — compare resolved forms (`realpath`), since `--show-toplevel` can report a symlinked parent such as `/tmp` or a symlinked `$HOME` under its resolved name.* That last clause is the whole mechanism — a refuter left to reason its way to agreement rubber-stamps, and "apply only what survives" silently degrades into "apply everything in scope". Apply only what survives; a refuted one defers with the refutation in its body. **Out of scope the refuter decides only where the suggestion is filed — it defers either way, never applied.** Refuted → the closed `PR #<pr> review: the suggestion band, checked` record, never an open issue. Survived, or its refuter crashed or abstained → its own open issue labelled `needs-triage`, citing the verdict, or the run that crashed, in the body. There is no cap on these refuters; your final verdict (step 4) says how many you dispatched against out-of-scope suggestions. "In scope" means the ticket's scope, not merely the same files — and applying one still never licenses editing a file the ticket has no business in. **A finding in `unverified` whose refuters ran and crashed or abstained always defers, whatever its severity** — and which it is, you read off the finding's **`refutersDispatched`** and **`refutersInconclusive`**, never off severity and never off an empty vote list: `refutersDispatched` above zero with nothing decided means every refuter dispatched against it died or abstained, and `refutersInconclusive` counts the ones that abstained. Only a `survived` finding has been checked; severity says how much a finding would matter if true, never whether anything looked. **Deferring a crash is your move, and re-running it is not** — the review already re-dispatched every crashed refuter pair once, in-run, so a crash that reaches you crashed twice, and the result's `resume` field names the one move left, which is not yours: nothing replays a review that ran in a runner's kernel — it is reported, not acted on. So say *in the deferral* that its refuters crashed rather than that it went unchecked, and re-run nothing for it. **An abstention is not a crash** — a refuter that abstained ran and could not decide: defer and file the finding, citing the inconclusive runs each abstaining refuter named in its `reason`, and never describe its refuters as crashed. **A `suggestion` is also in `unverified`, for a different reason — `refutersDispatched` of zero, the 0-refuter budget the workflow gives that band by policy, so nothing looked *there* — and the scope split above, not this rule, covers it.**
|
|
14
14
|
**Write every ruling to `<scratch>/dispositions-<your name>.json`, `<your name>` the `fix-pr-<pr>[-x]` you were dispatched as (standalone, `fix-pr-<pr>`) — the disposition record `dispositions-check.mjs` judges against the review result file before any finisher is dispatched.** It is `{"head": "<the review file's head>", "run": "<the run root's name in the review path>", "entries": [ … ]}` — `run` the `<run>` of the review path your prompt names, which the check holds against the one the PR's latest `reviewed=` names, so a record written against an earlier review of the same PR, a same-head one included, is a mismatch (standalone, with no ledger naming a run, leave it out): one entry per `survived` finding and per `unverified` finding, plus one per `refuted` finding you reverse. Each entry carries `bucket` (`survived|unverified|refuted`), `index` (its position in that bucket of the review file — stable, since a round's file never changes), `scope` (`in|out`), `claimKind` (`behavior|shape`) and `disposition` (`apply|defer`), and where they apply `reason`, `issue` (the number you filed it to or commented on), `verdictPath` (a suggestion's refuter verdict file, in scope and, above the claim bar, out of scope) and `remedyFiles` (the files its remedy names). A `refuted` entry carries `scope`, `claimKind` and `disposition` like every other entry, and a non-empty `reason` holding the evidence that reverses it; a missing or blank `reason` is a mismatch. The check reads scope off the diff before it reads your entry: a finding with no `line`, or on a line `git diff` touches from the PR's merge-base with `origin/main` to the review's `head`, is in scope whatever `scope` says, and only otherwise does the declared one stand. An in-scope `survived` finding deferred passes only with `reason` `false-rationale`, `mutual-exclusion`, `remedy-worse` or `remedy-outside-diff`. `remedy-outside-diff` — the remedy would edit a file the PR's diff does not touch — also needs `remedyFiles` to name at least one file absent from `git diff` from that merge-base to the review's `head`: none named, or every one in that diff, is a mismatch. A `critical` or `important` finding deferred for it goes to a human instead of passing: the script exits 1 with verdict `escalate`, `ledger.mjs dispatch` refuses the PR's finisher, and nothing retries it; a `suggestion` deferred for it passes. Any other reason, none, or a `survived` or `unverified` finding with no entry is a mismatch: the script exits 1 naming each violating entry's bucket, index and rule, and `ledger.mjs dispatch` refuses the PR's finisher. `disposition` has no `dismiss`: an in-scope `survived` finding verified correct, needing no change, is deferred with `reason` `false-rationale` and filed on the closed suggestion-band record — step 5's `PR #<pr> review: the suggestion band, checked`.
|
|
15
|
-
3. **Run `testCmd` before you commit**, from the worktree, copied verbatim — not a runner you picked. `testCmd` is the command your dispatcher handed you; **standalone, with no dispatcher to hand you one, it is the repo's own test command**, the Test entrypoint in the repo's Recipe cache (`derive-testcmd.sh <repo> test` prints it). Read it off the repo, never invent one: "not a runner you picked" is what this step forbids, and on the standalone path nothing else defines the referent. With no usable Recipe cache (`derive-testcmd.sh` refuses), run the Recipe derivation step first — dispatch a `task` member, agent `fleet-recipe-deriver`, its prompt naming the main checkout's absolute path as `<repo>` and a scratch directory of its own; it proves both commands in a throwaway worktree and writes the cache only on proof, and its `RECIPE NOT PROVEN` is a stop, reported with its reason. Never fall back to a runner of your own choosing. `tests 0` is a FAILED run, not a pass: a glob matching nothing still exits 0. **Zero is not the only no-work count.** A run reporting **0 passes with no failures** skipped everything it collected, and a count **materially below the whole suite** means you ran a partial tree — this suite reported `tests 937` when this line was written, so compare what you get against a full run rather than against zero. The number drifts; that is the safe direction, because a stale one produces a loud false red while its absence produces a silent false green. Red, zero-test, or either no-work count → fix it or move the finding to defer; never commit over it. Then commit and push —
|
|
15
|
+
3. **Run `testCmd` before you commit**, from the worktree, copied verbatim — not a runner you picked. `testCmd` is the command your dispatcher handed you; **standalone, with no dispatcher to hand you one, it is the repo's own test command**, the Test entrypoint in the repo's Recipe cache (`derive-testcmd.sh <repo> test` prints it). Read it off the repo, never invent one: "not a runner you picked" is what this step forbids, and on the standalone path nothing else defines the referent. With no usable Recipe cache (`derive-testcmd.sh` refuses), run the Recipe derivation step first — dispatch a `task` member, agent `fleet-recipe-deriver`, its prompt naming the main checkout's absolute path as `<repo>` and a scratch directory of its own; it proves both commands in a throwaway worktree and writes the cache only on proof, and its `RECIPE NOT PROVEN` is a stop, reported with its reason. Never fall back to a runner of your own choosing. `tests 0` is a FAILED run, not a pass: a glob matching nothing still exits 0. **Zero is not the only no-work count.** A run reporting **0 passes with no failures** skipped everything it collected, and a count **materially below the whole suite** means you ran a partial tree — this suite reported `tests 937` when this line was written, so compare what you get against a full run rather than against zero. The number drifts; that is the safe direction, because a stale one produces a loud false red while its absence produces a silent false green. Red, zero-test, or either no-work count → fix it or move the finding to defer; never commit over it. Then commit and push — `~/.fleet/bin/fleet-run push-sync.mjs --branch <branch>`, which pushes the explicit refspec `HEAD:refs/heads/<branch>` and then moves the worktree holding `<branch>` to the pushed SHA, or names why it did not and exits 3 with the push landed: this step also runs from a `--detach` worktree (`run-team/SKILL.md`'s worktree-recreation form), where a bare `git push` exits 128 (`fatal: You are not currently on a branch`), any push whose destination is `HEAD` rather than a full or branch refname (`-u` or not, forced or not) exits 1 (`error: The destination you provided is not a full refname`), and a refspec push alone leaves the claim worktree's `refs/heads/<branch>` at its old tip — and report the test result alongside the SHA. **Then reconcile the PR body with what you actually did.** It merges into the permanent record and nothing downstream re-derives it: your commit messages are yours to fix and usually get fixed, the body is neither. Removing a change, or reversing one, leaves the body asserting work the diff no longer contains. Measured twice in one run — one body kept asserting a calibration its own fix had just deleted as false, another kept claiming a hunk the fix had removed as "this diff owns it" — both caught by a finisher's reading rather than by any rule, and both would otherwise have merged intact. `gh pr edit <pr> --body-file <path>`, and re-read the body against the final diff rather than against your memory of it. **Never `--no-verify`.** The fleet runs in repos it knows nothing about: it must not assume a pre-commit hook exists, and must never bypass one that does — a hook that fails is a finding to report, not an obstacle. Nothing re-reviews what you commit here, so this run is the only gate between your edit and a merge.
|
|
16
16
|
4. **Under the fleet, do not hold this wait.** The controller owns a persistent CI Monitor and dispatches a finisher once your report has arrived and CI is green — so push, then report to the controller a **final verdict** (every specialist you dispatched + its outcome + your apply/defer adjudication + how many refuters you dispatched against out-of-scope suggestions + the pushed SHA) and stop. A specialist that has not reported to you is not a verdict line — `hub send` it by name for its report, then ask the controller, before ruling; see **Specialists**. The controller keys "review complete" on that verdict, **never on you merely going idle** — a slow specialist can still be running when your turn ends, and finishing off idle-alone ships a premature label (observed: a PR labelled while its contract-test specialist was still running and about to surface a vacuous pin). A turn-based reviewer re-reading `gh pr checks` each idle cycle rebuilds its whole context for nothing the Monitor lacks. **Standalone (no controller): watch** the run until the diff-validating jobs are green (bind via `ci-state.mjs` below); fix a **genuine** failure and repeat from 3. **Staleness is not a failure:** a `rebase-check` red or heavy jobs `skipped` **solely from a non-zero behind-count** never clears by waiting and must not be rebased away here — see **`skipped` is not `passed`** and **Do NOT rebase to label** below for what it looks like and why. Stop watching and go to 6. A turn-based reviewer that "waits for green" on a behind PR stalls forever and ends its turn without labelling. **`ci-state.mjs` returns `verdict: "no-ci"`** → there is no run to watch, so don't: go straight to 6 and let that step's `--declare-no-ci` branch decide.
|
|
17
17
|
5. File each deferred finding: `gh issue create --label needs-triage` with the finding, `file:line`, why deferred, `Deferred from PR #<pr> review`, and **the finding's `dimension` in the issue body** — the review returns it on every finding, and a backlog that does not carry it cannot be attributed to the specialist that generated it. **The body also carries the evidence triage needs: the refuter's verdict — the `verdictPath` reason, or the review's votes — and the candidate remedy.** **Every issue you create is labelled `needs-triage`; only triage sets `ready-for-agent`** — and the label is never omitted; an unlabelled issue is invisible to both `candidates.mjs --require-label` and the cockpit's pool query, reachable only by a human. **A confirmed defect does not settle its remedy**, so confirming one earns it no other label: a confirmed defect with the remedy still open files `needs-triage`, and so does a finding you are unsure is broken at all. **A finding a refuter CHECKED and did not survive is neither** — it is not a confirmed defect, and you are not unsure whether anything is broken: you measured that nothing is. Refutations go as **one issue per review, titled `PR #<pr> review: the suggestion band, checked`, labelled `wontfix`, and CLOSED** — a run that files them open instead has to be corrected afterwards, as one was on 2026-08-27. Left open they read as queued agent work and rot into filing artifacts; a measured triage pass found a quarter of its `needs-triage` backlog was refuted records, bulk-closable by title shape. Closed is still a durable home — `check` reads the tracker at `--state all`, so a future run re-deriving the finding lands on the evidence rather than re-litigating it. Split the band only where an entry is **not** a refutation: a residual the refuter surfaced, or an out-of-scope finding its refuter let survive or never decided, is real deferred work — and real deferred work earns its own open issue only past a second bar. **Worth a claim: a finding whose own claim is that correct code could be shaped better — style, naming, layout, redundancy, a micro-simplification — never gets its own open issue.** **Neither does a gap in a prose guard — a test holding prose to its wording — unless something acts on the guarded text: an agent follows it as an instruction (a skill, a command, an agent definition, a script's header or usage text, `AGENTS.md`), or a program other than its own guard reads it.** Whether anything does is your judgement, as `claimKind` is, and a gap below this bar is recorded `claimKind: shape`. A gap in a guard's own code — a slow pattern, a dead branch, an unchecked spawn result — takes the stakes of the prose it guards, and a helper several guards share takes the highest of theirs. A missed wording already in the tree is no guard gap — that prose is wrong — and neither is an input already in the tree that trips a guard's code; each files on its own merits. Once the prose is corrected, the observed wording may join the guard's literal ban, never a reader built to catch wordings nobody has written. A below-bar finding's only open question is "is this worth doing?", which answers itself under the repriced triage bar: measured 2026-08-15→31, 133 issues were triage-closed `wontfix` in sixteen days, 121 of them review deferrals, mostly this shape. Append it to the same closed record issue under a `Below the claim bar` heading — `file:line`, dimension, the claim, and a re-open trigger per entry. Closed is a durable home for these too: `check` reads `--state all`, so a run re-deriving the finding lands on the record instead of re-filing — and a later review returning the same finding as `survived`, or a maintainer hitting it, is the promotion signal: file it open then, citing the record. **An in-scope rediscovery step 2 applied is that same promotion signal, and it files nothing** — so the record carries no mark unless you write one: run `check` over each finding step 2 applied as well, and where its tracker rows put one on a `Below the claim bar` entry, comment on that record issue, opening `Promoted — applied, not filed`, and name the entry, that its refuter let it through, and the commit carrying the fix. That comment is the promotion mark this path leaves, and it is **not** a cross-reference — measured on this tracker, a comment mints `cross-referenced` on the issue its own text *names*, never on the issue it sits under (a comment citing a second issue landed on that issue's timeline and nowhere else), so the fixed opening is what carries the signal instead. **The bar reads the finding's claim, never its review band** — a finding that alleges wrong behavior, whatever band it sat in, `unverified`-with-crashed-refuters included, is above this bar and files open, `needs-triage`, which is why a band cannot carry this decision. Do **not** reach for phase 0's **decided?** test here. That is a *consumption* gate, and whether a filed issue is decided enough to reach it is triage's call, never the filer's. **The cost of this rule is watched:** open `needs-triage` issues citing `Deferred from PR` above 40 on 3 consecutive run dates send the filing rule back to the maintainer — batch triage, a fleet triage role, or a different bar. **Dedupe through `~/.fleet/bin/fleet-run ledger.mjs check "<subject>"`, never a bare `gh issue list --search`** — the duplicate class that guard exists to catch was filed from *here*: the ledger row for the finding rediscovered five times in one measured run carries a reviewer's own source tag, `(review-pr-108)`. `check` reads this run's filed list *and* the tracker at `--state all`, so a closed duplicate still counts. Exit **1** is already filed in this run; exit **3** is a clean ledger with matching tracker rows that score against the subject — read the ones it prints and comment on the existing follow-up rather than filing a second issue. **Exit 0 is not automatically "safe to file"**: it covers `clean`, `soft-hit` and `unverified` alike, and an unreachable `gh` degrades to a ledger-only answer that says `TRACKER NOT CHECKED`, so read the stdout `verdict` to tell a tracker that was searched and clean from one nobody read — under `unverified`, search it yourself before filing — or, for the applied-promotion comment above, before treating a tracker nobody could read as a reason to skip it. **`soft-hit` is the answer that costs most to misread**: it means related rows were found and none of them was established as a duplicate — a near-miss above the floor, or tracker rows the scorer rates 0.00 — so read the rows it printed and decide, and never treat that exit 0 as permission. Exit **2** is a usage error, meaning nothing was checked at all: fix the invocation and re-run, never read it as a duplicate. Full exit-code and `verdict` semantics live in `$(~/.fleet/bin/fleet-run --root)/skills/run-team/references/ledger.md`, which `SKILL.md`'s **Run ledger** section points at — read them there rather than restating them here. **The guard dedupes, it never drops:** a duplicate becomes a comment on the issue that already covers it, never a dropped finding. **Record every issue you create, at once:** immediately after each `gh issue create`, run `~/.fleet/bin/fleet-run ledger.mjs filed <N> "<subject>"` — `<N>` the number `gh issue create` printed, and **the same subject string you passed to `check`**, so the next `check` for that finding meets it in the filed list instead of scoring a rewording. Without it the guard's ledger half never holds a row, and its exit **1** can never fire. If `filed` fails, retry it once; if it fails again, leave the issue alone — it exists and is correct — and report it as `unrecorded: #N <subject>`, so the controller runs `filed` for it. Report `filed: #N <subject>` for every issue you created — under the fleet, in your report to the controller. **The one window the guard cannot see:** `check` → `gh issue create` → `filed` is three commands, and the ledger's lock covers each alone, so two filers can both read `clean` in the seconds before either records. That gap is accepted, never a reason to skip `filed`. Post the numbers as one PR comment.
|
|
18
18
|
**A duplicate that should hold an open deferral is a comment, never a relabel.** Where the guard lands a finding whose home is an open issue on an existing one that is open, cite the refuters' verdict — or the review's votes — in the comment you leave there, and change no label. Never relabel `ready-for-agent` down to `needs-triage`: a `ready-for-agent` issue already holds a `needs-triage` finding, because triage already promoted it, and a later run's crashed refuters are no reason to send it back. A closed one cannot hold it: file a new open issue citing it, as the promotion rule above does for the closed record.
|
package/package.json
CHANGED
package/scripts/fleet-event.mjs
CHANGED
|
@@ -134,6 +134,27 @@
|
|
|
134
134
|
// `closeout.mjs reopen --reason "<reason>"`, its output printed as it
|
|
135
135
|
// stands, then the tick. Refused once any `finish` step is ok, and when
|
|
136
136
|
// no drain marker is set; its exit 2 or 3 is passed through.
|
|
137
|
+
// member-died <member>
|
|
138
|
+
// Settles a member the controller has confirmed dead, then ticks. The
|
|
139
|
+
// outcome comes from the session directory's listing, never from an
|
|
140
|
+
// argument or a file's contents. The first marker that matches decides:
|
|
141
|
+
// <m>.jsonl.tombstone killed
|
|
142
|
+
// <m>.md, no tombstone, fix-pr or finisher-pr failed
|
|
143
|
+
// <m>.md, no tombstone, merge-bot killed
|
|
144
|
+
// <m>.md, no tombstone, impl refused: settle it by hand
|
|
145
|
+
// <m>.jsonl, no marker refused: running or parked
|
|
146
|
+
// no transcript, no marker killed once the tick's
|
|
147
|
+
// first-seen stamp in
|
|
148
|
+
// heartbeat.json is over
|
|
149
|
+
// five minutes old; refused
|
|
150
|
+
// when younger or unstamped
|
|
151
|
+
// Ahead of the markers: a review runner refuses, its death being
|
|
152
|
+
// `review-failed`; a member the ledger already holds settled is noted,
|
|
153
|
+
// and the tick runs; a report on disk refuses, naming the kind that
|
|
154
|
+
// settles it, or its violation when that kind could not settle from
|
|
155
|
+
// it. The session directory is this run's phase-0 receipt's
|
|
156
|
+
// `session`, else `--session <dir>`: the tick's lookup, so another
|
|
157
|
+
// run's receipt names none.
|
|
137
158
|
//
|
|
138
159
|
// A write this script cannot make, and does not refuse, is printed as a
|
|
139
160
|
// `NEED <what> …` line, at the left margin, for the controller to make by
|
|
@@ -141,7 +162,10 @@
|
|
|
141
162
|
//
|
|
142
163
|
// Exit: 0 every write made and the tick exited 0. 2 a usage error, refused
|
|
143
164
|
// before anything is written or ticked: an unknown kind or flag, a `--reason`
|
|
144
|
-
// that is empty or beside any kind but `drain` and `undrain`, a
|
|
165
|
+
// that is empty or beside any kind but `drain` and `undrain`, a `--session`
|
|
166
|
+
// beside any kind but `member-died`, any `member-died` refusal (the table's,
|
|
167
|
+
// a name that is no ledger member, no session directory, a session directory
|
|
168
|
+
// that cannot be listed, a heartbeat file that cannot be used), a member of
|
|
145
169
|
// the wrong family, a review path outside the PR's partition or a file that
|
|
146
170
|
// is no review result, a ledger or phase-0 receipt that cannot be read. A
|
|
147
171
|
// ledger write's own refusal — `settle`'s 3 (the report's claim and the world
|
|
@@ -155,17 +179,18 @@
|
|
|
155
179
|
// Zero deps: node builtins and sibling scripts only.
|
|
156
180
|
|
|
157
181
|
import { spawnSync } from "node:child_process";
|
|
158
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
182
|
+
import { readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
159
183
|
import { basename, dirname, join, normalize, resolve } from "node:path";
|
|
160
184
|
import { fileURLToPath } from "node:url";
|
|
161
185
|
import { makeDie, defineFlags, isDigits, writeAll } from "./arg.mjs";
|
|
162
186
|
import { FleetDirUnresolvable, fleetFile } from "./fleet-dir.mjs";
|
|
163
187
|
import { gitEnv } from "./git-env.mjs";
|
|
164
188
|
import { isCLI } from "./is-cli.mjs";
|
|
165
|
-
import { BRIEFS, REVIEW, REVIEWED, memberTokens, nextReviewerName, parseDispatchedEntry, parseMember, parseToken, readRetired, rowNums } from "./ledger-grammar.mjs";
|
|
166
|
-
import { dispositionsToken, foldLedger } from "./fleet-tick.mjs";
|
|
167
|
-
import { readRunReceipt, tickCaps } from "./phase0-receipt.mjs";
|
|
189
|
+
import { BRIEFS, REVIEW, REVIEWED, REVIEWER, memberTokens, nextReviewerName, parseDispatchedEntry, parseMember, parseToken, readRetired, rowNums } from "./ledger-grammar.mjs";
|
|
190
|
+
import { NO_TRANSCRIPT_AFTER_DEFAULT, controllerState, dispositionsToken, foldLedger } from "./fleet-tick.mjs";
|
|
191
|
+
import { phase0Gate, readRunReceipt, tickCaps } from "./phase0-receipt.mjs";
|
|
168
192
|
import { defaultScratch, readReportIn, reportsDir } from "./report-files.mjs";
|
|
193
|
+
import { readState, statePath } from "./fleet-state.mjs";
|
|
169
194
|
|
|
170
195
|
const NAME = "fleet-event";
|
|
171
196
|
const die = makeDie(NAME);
|
|
@@ -188,8 +213,9 @@ const KINDS = {
|
|
|
188
213
|
heartbeat: { args: [] },
|
|
189
214
|
drain: { args: [], flag: "--reason \"<reason>\"" },
|
|
190
215
|
undrain: { args: [], flag: "--reason \"<reason>\"" },
|
|
216
|
+
"member-died": { args: ["<member>"] },
|
|
191
217
|
};
|
|
192
|
-
const FLAGS = { "in-flight": "value", reason: "value" };
|
|
218
|
+
const FLAGS = { "in-flight": "value", reason: "value", session: "value" };
|
|
193
219
|
const USAGE = `usage: ${NAME}.mjs <kind> <args> [--in-flight <n>] — kinds: `
|
|
194
220
|
+ Object.entries(KINDS).map(([k, { args, flag }]) => [k, ...args, ...(flag ? [flag] : [])].join(" ")).join(", ");
|
|
195
221
|
|
|
@@ -299,15 +325,18 @@ class Event {
|
|
|
299
325
|
return readReportIn(reportsDir(this.scratch), member);
|
|
300
326
|
}
|
|
301
327
|
|
|
302
|
-
// `settle <member> --from-report` unless the ledger holds it settled
|
|
303
|
-
//
|
|
304
|
-
settle
|
|
328
|
+
// `settle <member> --from-report` unless the ledger holds it settled, or
|
|
329
|
+
// `settle <member> <decided>` when a marker decided the outcome — never one
|
|
330
|
+
// a file's contents named. Returns the outcome, or null when the settle
|
|
331
|
+
// refused.
|
|
332
|
+
settle(member, decided = null) {
|
|
305
333
|
const before = settledOutcome(this.ledger, member);
|
|
306
334
|
if (before !== null) {
|
|
307
335
|
this.note(`${member}=${before} already settled`);
|
|
308
336
|
return before;
|
|
309
337
|
}
|
|
310
|
-
const
|
|
338
|
+
const tail = decided === null ? ["--from-report", "--reports", reportsDir(this.scratch)] : [decided];
|
|
339
|
+
const r = node("ledger.mjs", ["settle", member, ...tail]);
|
|
311
340
|
if (r.status !== 0) {
|
|
312
341
|
this.refuse(`settle ${member}`, r);
|
|
313
342
|
return null;
|
|
@@ -727,6 +756,81 @@ function undrain(ev, reason) {
|
|
|
727
756
|
}
|
|
728
757
|
}
|
|
729
758
|
|
|
759
|
+
// ── member-died ─────────────────────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
// fleet-tick's default `--no-transcript-after`: a member with no transcript
|
|
762
|
+
// whose first-seen stamp is older than this never started.
|
|
763
|
+
const NO_TRANSCRIPT_MS = NO_TRANSCRIPT_AFTER_DEFAULT * 60_000;
|
|
764
|
+
|
|
765
|
+
// The kind whose report settles a family — the KINDS entry naming that family
|
|
766
|
+
// — named in the refusal that finds one on disk.
|
|
767
|
+
export const reportKind = (family) => Object.keys(KINDS).find((k) => KINDS[k].family === family);
|
|
768
|
+
|
|
769
|
+
// This run's phase-0 receipt `session`, or null: the receipt phase0Gate()
|
|
770
|
+
// judges this run's against the controller record in heartbeat.json and that
|
|
771
|
+
// record's process, as fleet-tick judges it.
|
|
772
|
+
function runSession({ read, path }) {
|
|
773
|
+
const { controller } = readState(statePath(NAME), NAME);
|
|
774
|
+
return phase0Gate(read, { path, controllerAt: controller?.at, controller: controllerState(controller, NAME) }).session;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// The names in the session directory. A missing one reads as empty when the
|
|
778
|
+
// receipt named it, as the tick reads it, and refuses otherwise.
|
|
779
|
+
function sessionListing(session, receiptNamed) {
|
|
780
|
+
try {
|
|
781
|
+
return new Set(readdirSync(session));
|
|
782
|
+
} catch (e) {
|
|
783
|
+
if (e.code === "ENOENT" && receiptNamed) return new Set();
|
|
784
|
+
return die(`member-died: cannot list the session directory ${session} (${e.code ?? e.message}) — nothing written`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Settles a member the controller has confirmed dead, by the first marker
|
|
789
|
+
// that matches; see the header. The settle is a ledger write, so its
|
|
790
|
+
// refusal passes through to the exit as every other kind's does. Every other
|
|
791
|
+
// refusal is a die() before anything is written or ticked.
|
|
792
|
+
function memberDied(ev, member, receipt, flagSession) {
|
|
793
|
+
const review = REVIEWER.exec(member);
|
|
794
|
+
if (review !== null) die(`member-died ${member}: a review runner settles nothing; its death is review-failed ${review[1]} member:${member} — nothing written`);
|
|
795
|
+
const m = parseMember(member);
|
|
796
|
+
if (m === null) die(`member-died: '${member}' is not a ledger member — nothing written`);
|
|
797
|
+
const before = settledOutcome(ev.ledger, member);
|
|
798
|
+
if (before !== null) {
|
|
799
|
+
ev.note(`${member}=${before} already settled`);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const report = ev.report(member);
|
|
803
|
+
const kind = reportKind(m.family);
|
|
804
|
+
if (report.state === "invalid") die(`member-died ${member}: its report ${report.path} is on disk and ${kind} cannot settle from it (${report.violation}): fix the report and run ${kind} ${member}, or settle ${member} by hand — nothing written`);
|
|
805
|
+
if (report.state !== "absent") die(`member-died ${member}: its report ${report.path} is on disk; ${kind} ${member} settles it — nothing written`);
|
|
806
|
+
// The session directory the tick reads: this run's receipt's `session`,
|
|
807
|
+
// else `--session`. A `--session` the receipt's overrides is said, not
|
|
808
|
+
// dropped in silence.
|
|
809
|
+
const receiptSession = runSession(receipt);
|
|
810
|
+
if (receiptSession !== null && flagSession !== null && resolve(flagSession) !== resolve(receiptSession)) {
|
|
811
|
+
writeAll(2, `${NAME}: member-died ${member} reads ${receiptSession}, this run's phase-0 receipt's session, not --session ${flagSession}\n`);
|
|
812
|
+
}
|
|
813
|
+
const session = receiptSession ?? flagSession;
|
|
814
|
+
if (session === null) die(`member-died ${member} needs the session directory: this run's phase-0 receipt names none, so pass --session <dir> — nothing written`);
|
|
815
|
+
const listed = sessionListing(session, receiptSession !== null);
|
|
816
|
+
const md = `${member}.md`;
|
|
817
|
+
const jsonl = `${member}.jsonl`;
|
|
818
|
+
if (listed.has(`${jsonl}.tombstone`)) return ev.settle(member, "killed");
|
|
819
|
+
if (listed.has(md)) {
|
|
820
|
+
if (m.family === "impl") die(`member-died ${member}: ${md} is in ${session} with no tombstone, and an implementer's yield is its own outcome: read agent://${member} and settle it by hand — nothing written`);
|
|
821
|
+
return ev.settle(member, m.family === "merge-bot" ? "killed" : "failed");
|
|
822
|
+
}
|
|
823
|
+
if (listed.has(jsonl)) die(`member-died ${member}: ${member} has a transcript and no marker in ${session}, so it is running or parked; census it, kill it, then rerun — nothing written`);
|
|
824
|
+
const heartbeat = statePath("fleet-event");
|
|
825
|
+
const state = readState(heartbeat, "fleet-event");
|
|
826
|
+
if (state.degraded) die(`member-died ${member}: ${heartbeat} ${state.degraded} — nothing written`);
|
|
827
|
+
const stamp = state.seen[member];
|
|
828
|
+
if (typeof stamp !== "number") die(`member-died ${member}: no transcript and no first-seen stamp in ${heartbeat}, so the tick never saw it live — nothing written`);
|
|
829
|
+
const age = Date.now() - stamp;
|
|
830
|
+
if (age <= NO_TRANSCRIPT_MS) die(`member-died ${member}: first seen ${Math.floor(age / 60_000)} min ago, not yet past the ${NO_TRANSCRIPT_MS / 60_000}-minute no-transcript bound — nothing written`);
|
|
831
|
+
return ev.settle(member, "killed");
|
|
832
|
+
}
|
|
833
|
+
|
|
730
834
|
// ── main ────────────────────────────────────────────────────────────────────
|
|
731
835
|
|
|
732
836
|
function boundArg(F, name) {
|
|
@@ -764,6 +868,7 @@ function main() {
|
|
|
764
868
|
} else if (reason !== null) {
|
|
765
869
|
die(`--reason belongs to drain and undrain, not ${kind}`);
|
|
766
870
|
}
|
|
871
|
+
if (kind !== "member-died" && F.arg("session") !== null) die(`--session belongs to member-died, not ${kind}`);
|
|
767
872
|
let pr = null;
|
|
768
873
|
if (spec.family !== undefined) {
|
|
769
874
|
const m = parseMember(args[0]);
|
|
@@ -776,9 +881,11 @@ function main() {
|
|
|
776
881
|
|
|
777
882
|
let fleetDir;
|
|
778
883
|
let scratch;
|
|
884
|
+
let receipt;
|
|
779
885
|
try {
|
|
780
886
|
fleetDir = fleetFile(null);
|
|
781
|
-
|
|
887
|
+
receipt = readRunReceipt();
|
|
888
|
+
const { read } = receipt;
|
|
782
889
|
const named = read.state === "ok" ? read.receipt.scratch : undefined;
|
|
783
890
|
scratch = typeof named === "string" && named !== "" ? named : defaultScratch();
|
|
784
891
|
} catch (e) {
|
|
@@ -788,7 +895,6 @@ function main() {
|
|
|
788
895
|
const caps = tickCaps(fleetDir);
|
|
789
896
|
if (caps.error) die(`${caps.error} — nothing written`);
|
|
790
897
|
const tickArgs = ["--apply", ...(inFlight === null ? caps.args : ["--in-flight", inFlight]), ...(kind === "heartbeat" ? ["--fold-unchanged"] : [])];
|
|
791
|
-
|
|
792
898
|
const ev = new Event({ fleetDir, scratch, ledger: readLedger() });
|
|
793
899
|
if (kind === "impl-report") implReport(ev, args[0]);
|
|
794
900
|
else if (kind === "fix-report") fixReport(ev, args[0]);
|
|
@@ -800,6 +906,7 @@ function main() {
|
|
|
800
906
|
else if (kind === "conflict") conflict(ev, pr);
|
|
801
907
|
else if (kind === "drain") drain(ev, reason);
|
|
802
908
|
else if (kind === "undrain") undrain(ev, reason);
|
|
909
|
+
else if (kind === "member-died") memberDied(ev, args[0], receipt, F.arg("session"));
|
|
803
910
|
|
|
804
911
|
writeAll(1, `${[...ev.lines, `event: ${kind}: ${ev.summary.join("; ") || "nothing to record"}`].join("\n")}\n`);
|
|
805
912
|
const tick = spawnSync(process.execPath, [join(SCRIPTS, "fleet-tick.mjs"), ...tickArgs], { stdio: ["ignore", "inherit", "inherit"], env: gitEnv() });
|
package/scripts/fleet-tick.mjs
CHANGED
|
@@ -1295,6 +1295,10 @@ export function unrecordedLines({ stems, recorded }) {
|
|
|
1295
1295
|
});
|
|
1296
1296
|
}
|
|
1297
1297
|
|
|
1298
|
+
// The `--no-transcript-after` default, in minutes: its one definition, which
|
|
1299
|
+
// fleet-event.mjs's member-died reads as its own no-transcript bound.
|
|
1300
|
+
export const NO_TRANSCRIPT_AFTER_DEFAULT = 5;
|
|
1301
|
+
|
|
1298
1302
|
// The NO-TRANSCRIPT lines: one per live member token in `live` whose `seen`
|
|
1299
1303
|
// stamp is more than `after` minutes old and whose `<member>.jsonl` is not
|
|
1300
1304
|
// among `stems`, as `{kind, name, line, key}` — a ledger token whose `task` call
|
|
@@ -1481,7 +1485,7 @@ const OPTIONS = {
|
|
|
1481
1485
|
session: { type: "string", default: "" },
|
|
1482
1486
|
// Minutes a live member token may go with no transcript in the session
|
|
1483
1487
|
// directory before NO-TRANSCRIPT names it.
|
|
1484
|
-
"no-transcript-after": { type: "string", default:
|
|
1488
|
+
"no-transcript-after": { type: "string", default: String(NO_TRANSCRIPT_AFTER_DEFAULT) },
|
|
1485
1489
|
// Minutes a Pull's question may stand, its `pull.json` unwritten, before the
|
|
1486
1490
|
// PULL row naming it says `(stale)`.
|
|
1487
1491
|
"pull-stale-minutes": { type: "string", default: "120" },
|
|
@@ -1867,15 +1871,16 @@ function claimed() {
|
|
|
1867
1871
|
|
|
1868
1872
|
// What the controller record's process is now, for the phase-0 gate: `none`
|
|
1869
1873
|
// with no record, else judgeRecord()'s answer. A process table that cannot
|
|
1870
|
-
// be read is `unreadable`, said on stderr
|
|
1871
|
-
// is not one whose receipt
|
|
1872
|
-
|
|
1874
|
+
// be read is `unreadable`, said on stderr under the caller's `name`: a
|
|
1875
|
+
// controller nobody could judge is not one whose receipt may be taken as
|
|
1876
|
+
// this run's.
|
|
1877
|
+
export function controllerState(controller, name = NAME) {
|
|
1873
1878
|
if (controller === null) return "none";
|
|
1874
1879
|
try {
|
|
1875
1880
|
return judgeRecord(controller);
|
|
1876
1881
|
} catch (e) {
|
|
1877
1882
|
if (!(e instanceof ProcUnreadable)) throw e;
|
|
1878
|
-
console.error(`${
|
|
1883
|
+
console.error(`${name}: WARNING ${e.message} — the recorded controller pid ${controller.pid} cannot be judged, so the phase-0 receipt is held`);
|
|
1879
1884
|
return "unreadable";
|
|
1880
1885
|
}
|
|
1881
1886
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Push `HEAD` to `refs/heads/<branch>` on origin, then move the worktree that
|
|
3
|
+
// holds `refs/heads/<branch>` to the pushed SHA.
|
|
4
|
+
//
|
|
5
|
+
// push-sync.mjs --branch <branch> [--force-with-lease]
|
|
6
|
+
//
|
|
7
|
+
// WHY. A member in a `--detach` worktree can only push by refspec, and a
|
|
8
|
+
// refspec push moves origin's ref alone: the claim worktree claim-ticket.sh
|
|
9
|
+
// made (`worktree add --no-track -b`) still has `refs/heads/<branch>` at the
|
|
10
|
+
// old tip, and everything that later reads that local ref reads a head the PR
|
|
11
|
+
// no longer has. `git branch -f` refuses a branch a worktree has checked out,
|
|
12
|
+
// and `git update-ref` would move the ref while leaving that worktree's index
|
|
13
|
+
// and files at the old tip, so this uses neither: it runs
|
|
14
|
+
// `git -C <claim worktree> reset --keep <pushed>`, which moves the ref, the
|
|
15
|
+
// index and the working tree together, and covers a fast-forward and a
|
|
16
|
+
// rebased (non-fast-forward) push alike.
|
|
17
|
+
//
|
|
18
|
+
// BEFORE THE PUSH it reads origin's head of the branch (`ls-remote`) and the
|
|
19
|
+
// local `refs/heads/<branch>`. A failed or timed-out `ls-remote` does not stop
|
|
20
|
+
// the push — it only leaves the ref guard's "equals origin's pre-push head"
|
|
21
|
+
// alternative below unmet; the ancestor alternative can still hold.
|
|
22
|
+
//
|
|
23
|
+
// THE PUSH is `git push [--force-with-lease] origin HEAD:refs/heads/<branch>`,
|
|
24
|
+
// git's own output passed through on stderr. A failed push — a denied lease
|
|
25
|
+
// included — exits with the push's own status, is never retried with
|
|
26
|
+
// `--force`, and nothing is synced.
|
|
27
|
+
//
|
|
28
|
+
// THE SYNC, after a push that landed. The holder is found by parsing
|
|
29
|
+
// `git worktree list --porcelain -z` record by record (NUL-delimited, so a
|
|
30
|
+
// path holding a newline cannot split a record). The move happens only when
|
|
31
|
+
// both guards hold:
|
|
32
|
+
// - the holder is clean: `git status --porcelain` prints nothing, untracked
|
|
33
|
+
// files included;
|
|
34
|
+
// - the local ref read before the push equals origin's head read before the
|
|
35
|
+
// push, or is an ancestor of the pushed SHA. A rebase's force-push
|
|
36
|
+
// replaces exactly the tip origin had, so under this guard no commit that
|
|
37
|
+
// was only local is dropped.
|
|
38
|
+
// A guard that fails moves nothing: the refusal names each failed condition
|
|
39
|
+
// and says the push itself landed. An ancestry check git cannot answer —
|
|
40
|
+
// `merge-base --is-ancestor` exiting other than 0 or 1, or not running —
|
|
41
|
+
// refuses as `ancestry-check-failed` with git's own error, never as a
|
|
42
|
+
// divergence. The clean-tree check is the only protection against a member
|
|
43
|
+
// working in the holder at the same moment.
|
|
44
|
+
// A holder already at the pushed SHA — the attached claim worktree running
|
|
45
|
+
// this itself — has nothing to move and is not checked.
|
|
46
|
+
//
|
|
47
|
+
// OUTPUT, on stdout, in this order: one `push:` line, then the `sync:`
|
|
48
|
+
// outcome — one line, or on a refusal one line per failed condition — then
|
|
49
|
+
// one `read-back:` line comparing `refs/heads/<branch>` with the pushed
|
|
50
|
+
// SHA. A failed push prints the `push:` line alone.
|
|
51
|
+
//
|
|
52
|
+
// Exit 0: pushed, and the holder is at the pushed SHA — or no worktree holds
|
|
53
|
+
// the branch (`sync: no worktree holds <branch>`), where the push
|
|
54
|
+
// result stands.
|
|
55
|
+
// Exit 2: bad usage, HEAD names no commit, or git could not run or read HEAD.
|
|
56
|
+
// Nothing was pushed.
|
|
57
|
+
// Exit 3: pushed, NOT synced — a guard refused, a git call in the sync failed,
|
|
58
|
+
// or the read-back differs. Origin has the pushed SHA.
|
|
59
|
+
// Any other non-zero: the push failed, with git's own exit status.
|
|
60
|
+
|
|
61
|
+
import { spawnSync } from "node:child_process";
|
|
62
|
+
import { constants as osConstants } from "node:os";
|
|
63
|
+
import { defineFlags, makeDie, writeAll } from "./arg.mjs";
|
|
64
|
+
import { gitEnv } from "./git-env.mjs";
|
|
65
|
+
import { isCLI } from "./is-cli.mjs";
|
|
66
|
+
|
|
67
|
+
const NAME = "push-sync";
|
|
68
|
+
const die = makeDie(NAME);
|
|
69
|
+
const EXIT_NOT_SYNCED = 3;
|
|
70
|
+
// `ls-remote` moves refs, no objects, so it gets a short bound.
|
|
71
|
+
const LS_REMOTE_TIMEOUT_MS = 30_000;
|
|
72
|
+
const USAGE = "usage: push-sync.mjs --branch <branch> [--force-with-lease]";
|
|
73
|
+
|
|
74
|
+
const say = (line) => writeAll(1, `${line}\n`);
|
|
75
|
+
|
|
76
|
+
function git(args, { timeout, stdio } = {}) {
|
|
77
|
+
return spawnSync("git", args, {
|
|
78
|
+
env: gitEnv(),
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
81
|
+
timeout,
|
|
82
|
+
stdio,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ok = (r) => !r.error && r.status === 0;
|
|
87
|
+
const why = (r) => r.error?.message ?? (r.signal ? `killed by ${r.signal}` : (r.stderr ?? "").trim() || `exit ${r.status}`);
|
|
88
|
+
|
|
89
|
+
/** The commit `ref` names, or null when it names none. */
|
|
90
|
+
function commitOf(ref) {
|
|
91
|
+
const r = git(["rev-parse", "-q", "--verify", `${ref}^{commit}`]);
|
|
92
|
+
return ok(r) ? r.stdout.trim() : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Origin's head of `branch`: `{ok: true, sha}` (`sha` null when origin has no
|
|
97
|
+
* such branch) or `{ok: false, why}`. An exact field match, never ls-remote's
|
|
98
|
+
* pattern, which matches any ref ENDING in it on a `/` boundary.
|
|
99
|
+
*/
|
|
100
|
+
function remoteHead(branch) {
|
|
101
|
+
const ref = `refs/heads/${branch}`;
|
|
102
|
+
const r = git(["ls-remote", "origin", ref], { timeout: LS_REMOTE_TIMEOUT_MS });
|
|
103
|
+
if (!ok(r)) return { ok: false, why: why(r) };
|
|
104
|
+
for (const line of r.stdout.split("\n")) {
|
|
105
|
+
const [sha, name] = line.split("\t");
|
|
106
|
+
if (name === ref) return { ok: true, sha };
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, sha: null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The path of the worktree whose record in `listing` — the output of
|
|
113
|
+
* `git worktree list --porcelain -z` — carries `branch refs/heads/<branch>`,
|
|
114
|
+
* or null. Records end with an empty field; a record's first field is its
|
|
115
|
+
* `worktree <path>`.
|
|
116
|
+
*/
|
|
117
|
+
export function claimHolder(listing, branch) {
|
|
118
|
+
const want = `branch refs/heads/${branch}`;
|
|
119
|
+
let path = null;
|
|
120
|
+
for (const field of listing.split("\0")) {
|
|
121
|
+
if (field === "") path = null;
|
|
122
|
+
else if (path === null && field.startsWith("worktree ")) path = field.slice("worktree ".length);
|
|
123
|
+
else if (field === want && path !== null) return path;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The push's exit status as a shell would report it. */
|
|
129
|
+
function pushStatus(r) {
|
|
130
|
+
if (r.status !== null) return r.status;
|
|
131
|
+
const n = r.signal ? osConstants.signals[r.signal] : undefined;
|
|
132
|
+
return n ? 128 + n : 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readBack(branch, pushed) {
|
|
136
|
+
const now = commitOf(`refs/heads/${branch}`);
|
|
137
|
+
if (now === null) {
|
|
138
|
+
say(`read-back: refs/heads/${branch} is absent; the pushed SHA is ${pushed}`);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
const same = now === pushed;
|
|
142
|
+
say(`read-back: refs/heads/${branch} is ${now} — ${same ? "equals" : "differs from"} the pushed ${pushed}`);
|
|
143
|
+
return same;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function refuse(branch, pushed, reasons) {
|
|
147
|
+
for (const [name, text] of reasons) {
|
|
148
|
+
say(`sync: refused (${name}): ${text}; nothing moved — the push itself landed`);
|
|
149
|
+
}
|
|
150
|
+
readBack(branch, pushed);
|
|
151
|
+
process.exit(EXIT_NOT_SYNCED);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function main() {
|
|
155
|
+
const flags = defineFlags(die, { flags: { branch: "value", "force-with-lease": "bool" } });
|
|
156
|
+
flags.sweep();
|
|
157
|
+
flags.stray();
|
|
158
|
+
const branch = flags.arg("branch");
|
|
159
|
+
if (branch === null) die(`--branch is required\n${USAGE}`);
|
|
160
|
+
const lease = flags.has("force-with-lease");
|
|
161
|
+
const format = git(["check-ref-format", `refs/heads/${branch}`]);
|
|
162
|
+
if (format.error || format.signal) die(`git could not run: ${why(format)}`);
|
|
163
|
+
if (format.status !== 0) die(`'${branch}' is not a valid branch name`);
|
|
164
|
+
|
|
165
|
+
// `rev-parse -q --verify` says nothing when HEAD is unborn; anything it does
|
|
166
|
+
// say is git failing to read the repository at all.
|
|
167
|
+
const head = git(["rev-parse", "-q", "--verify", "HEAD^{commit}"]);
|
|
168
|
+
if (!ok(head)) {
|
|
169
|
+
die(head.error || head.signal || head.stderr.trim() !== "" ? `cannot read HEAD: ${why(head)}` : "HEAD names no commit — nothing to push");
|
|
170
|
+
}
|
|
171
|
+
const pushed = head.stdout.trim();
|
|
172
|
+
const remoteBefore = remoteHead(branch);
|
|
173
|
+
const localBefore = commitOf(`refs/heads/${branch}`);
|
|
174
|
+
|
|
175
|
+
const pushArgs = ["push", ...(lease ? ["--force-with-lease"] : []), "origin", `HEAD:refs/heads/${branch}`];
|
|
176
|
+
writeAll(2, `$ git ${pushArgs.join(" ")}\n`);
|
|
177
|
+
const push = git(pushArgs, { stdio: ["ignore", 2, 2] });
|
|
178
|
+
if (push.error) die(`git push could not run: ${push.error.message}`);
|
|
179
|
+
if (push.status !== 0) {
|
|
180
|
+
const status = pushStatus(push);
|
|
181
|
+
say(`push: failed (exit ${status}); nothing synced, nothing retried`);
|
|
182
|
+
process.exit(status);
|
|
183
|
+
}
|
|
184
|
+
say(`push: landed ${pushed} on origin refs/heads/${branch}`);
|
|
185
|
+
|
|
186
|
+
const list = git(["worktree", "list", "--porcelain", "-z"]);
|
|
187
|
+
if (!ok(list)) refuse(branch, pushed, [["worktree-list-failed", `git worktree list failed: ${why(list)}`]]);
|
|
188
|
+
const holder = claimHolder(list.stdout, branch);
|
|
189
|
+
if (holder === null) {
|
|
190
|
+
say(`sync: no worktree holds ${branch}`);
|
|
191
|
+
readBack(branch, pushed);
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
if (localBefore === pushed) {
|
|
195
|
+
say(`sync: ${holder} already at ${pushed}`);
|
|
196
|
+
process.exit(readBack(branch, pushed) ? 0 : EXIT_NOT_SYNCED);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const reasons = [];
|
|
200
|
+
const st = git(["-C", holder, "status", "--porcelain"]);
|
|
201
|
+
if (!ok(st)) reasons.push(["status-unreadable", `git -C ${holder} status failed: ${why(st)}`]);
|
|
202
|
+
else if (st.stdout !== "") reasons.push(["claim-worktree-dirty", `${holder} has uncommitted or untracked changes`]);
|
|
203
|
+
|
|
204
|
+
const local = localBefore ?? "(absent)";
|
|
205
|
+
const equalsRemote = remoteBefore.ok && remoteBefore.sha !== null && remoteBefore.sha === localBefore;
|
|
206
|
+
if (!equalsRemote) {
|
|
207
|
+
const remote = !remoteBefore.ok ? `unread: ${remoteBefore.why}` : remoteBefore.sha ?? "absent";
|
|
208
|
+
// `--is-ancestor` answers 0 (an ancestor) or 1 (not one); anything else is
|
|
209
|
+
// git failing to answer, which is no evidence of a divergence.
|
|
210
|
+
const ancestry = localBefore === null ? null : git(["merge-base", "--is-ancestor", localBefore, pushed]);
|
|
211
|
+
if (ancestry !== null && !ok(ancestry) && (ancestry.error || ancestry.status !== 1)) {
|
|
212
|
+
reasons.push([
|
|
213
|
+
"ancestry-check-failed",
|
|
214
|
+
`refs/heads/${branch} at ${local} does not equal origin's pre-push head (${remote}), and git merge-base --is-ancestor ${local} ${pushed} failed: ${why(ancestry)}`,
|
|
215
|
+
]);
|
|
216
|
+
} else if (ancestry === null || !ok(ancestry)) {
|
|
217
|
+
reasons.push([
|
|
218
|
+
"claim-ref-diverged",
|
|
219
|
+
`refs/heads/${branch} at ${local} neither equals origin's pre-push head (${remote}) nor is an ancestor of the pushed ${pushed}`,
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (reasons.length > 0) refuse(branch, pushed, reasons);
|
|
224
|
+
|
|
225
|
+
const reset = git(["-C", holder, "reset", "-q", "--keep", pushed]);
|
|
226
|
+
if (!ok(reset)) refuse(branch, pushed, [["reset-failed", `git -C ${holder} reset --keep ${pushed} failed: ${why(reset)}`]]);
|
|
227
|
+
say(`sync: moved ${holder} from ${local} to ${pushed}`);
|
|
228
|
+
process.exit(readBack(branch, pushed) ? 0 : EXIT_NOT_SYNCED);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (isCLI(import.meta.url)) main();
|
|
@@ -105,9 +105,15 @@ and a member sent into one is on `## HEAD (no branch)`, where the push above exi
|
|
|
105
105
|
the cause, so no `-u`/force combination reaches it: the unqualified `HEAD`
|
|
106
106
|
destination is, and plain `git push origin HEAD` fails the same way while a bare
|
|
107
107
|
`git push` exits 128 (`fatal: You are not currently on a branch`). Name the branch
|
|
108
|
-
instead —
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
instead, through the helper — `~/.fleet/bin/fleet-run push-sync.mjs --branch <branch> --force-with-lease`.
|
|
109
|
+
A refspec push alone moves origin's ref and leaves the claim worktree's
|
|
110
|
+
`refs/heads/<branch>` at its old tip; the helper pushes `HEAD:refs/heads/<branch>`,
|
|
111
|
+
then moves the worktree holding `<branch>` to the pushed SHA with `reset --keep`,
|
|
112
|
+
only when that worktree is clean and its branch was origin's head or an ancestor
|
|
113
|
+
of the push. Exit 3 is pushed but not synced, its refusal named; exit 2 refused
|
|
114
|
+
before pushing anything; any other non-zero is the push's own failure, nothing
|
|
115
|
+
synced. A worktree `claim-ticket.sh` claimed is on its branch, where the push
|
|
116
|
+
above is the correct one.
|
|
111
117
|
|
|
112
118
|
`--force-with-lease` matters only on a re-push: step 7 rebases immediately before pushing, so re-entering step 7 after an earlier push needs the force to land the rebased commits. On a re-push the force is load-bearing: stop and report the denial, never retry with a plain `--force`; a denial on a branch that has never been pushed is safe to route around with a plain `git push -u origin HEAD` instead.
|
|
113
119
|
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -203,6 +203,7 @@ Run each as `~/.fleet/bin/fleet-run fleet-event.mjs <kind> <args>`, from the mai
|
|
|
203
203
|
| Heartbeat | `fleet-event.mjs heartbeat` |
|
|
204
204
|
| Drain | `fleet-event.mjs drain --reason "<reason>"` |
|
|
205
205
|
| Undrain | `fleet-event.mjs undrain --reason "<reason>"` |
|
|
206
|
+
| Member confirmed dead or never dispatched | `fleet-event.mjs member-died <member>` |
|
|
206
207
|
|
|
207
208
|
### Nothing to do: arm the beat
|
|
208
209
|
|
|
@@ -280,7 +281,7 @@ depth** guard table applied in code. Act on each line as it reads:
|
|
|
280
281
|
- `PHASE0-INCOMPLETE <step>…` — every dispatching row is held: run the `phase0.mjs` command the line names; advice lines hold nothing.
|
|
281
282
|
- `CHECK-LIVENESS <member> (…)` — advice, never an action: take the census the **Member unaccounted** row of **Failure handling** gives before you touch the token.
|
|
282
283
|
- `UNRECORDED <stem> (…)` — record it with `ledger.mjs dispatch`, or confirm it is a stranger's member.
|
|
283
|
-
- `NO-TRANSCRIPT <member> (…)` — make the `task` call, or
|
|
284
|
+
- `NO-TRANSCRIPT <member> (…)` — make the `task` call, or run the **Member confirmed dead or never dispatched** wake.
|
|
284
285
|
- `SETTLE <member> (report on disk …)` — a live token whose report is on disk: run that member's report wake from the wake table, which runs the `ledger.mjs settle <member> --from-report` the line prints plus the follow-on work its report calls for; `REPORT-MISMATCH`, `REPORT-UNREADABLE` and `REPORT-INVALID` name a report file that cannot be settled from, and the tick settles nothing itself.
|
|
285
286
|
|
|
286
287
|
## Phase 1 — Pull: judge, then claim or relabel
|
|
@@ -1296,11 +1297,7 @@ owes beyond its call:
|
|
|
1296
1297
|
nothing the tick did not name.
|
|
1297
1298
|
- **Finisher reports halted** → the per-cause rule (**Resolving a finisher
|
|
1298
1299
|
halt**, above).
|
|
1299
|
-
- **A finisher failed or was killed** → `
|
|
1300
|
-
finisher that crashed or gave up, `=killed` for one that was killed; no label,
|
|
1301
|
-
and the tick prints no dispatch for it. The cockpit flags the PR
|
|
1302
|
-
`finisher:failed` / `finisher:killed` at severity 4 and you resolve it by hand
|
|
1303
|
-
(**Resolving a finisher that died**, above), then run the tick.
|
|
1300
|
+
- **A finisher failed or was killed** → the **Member confirmed dead or never dispatched** wake (`member-died finisher-pr-<M>`), which settles it `failed` or `killed`; no label, and the tick prints no dispatch for it. The cockpit flags the PR `finisher:failed` / `finisher:killed` at severity 4 and you resolve it by hand (**Resolving a finisher that died**, above), then run the tick.
|
|
1304
1301
|
- **Implementer bails before implementing** → `fleet-event.mjs impl-report impl-<N>`; the causes and the issue action each takes are under **Relabel causes**.
|
|
1305
1302
|
- **A review result lands** → see the result file written (by you or by the
|
|
1306
1303
|
runner — **Reviewers** says which, per harness), then
|
|
@@ -2609,8 +2606,8 @@ Match the failure to its row in the table below and run that response; where the
|
|
|
2609
2606
|
| Merge bot's CI wait reaches its per-PR time bound with the run's `status` still not `completed` | The bot skips that PR and reports `ci-wait-timeout-#<pr>` with the run id and its status. PR stays queued, label untouched |
|
|
2610
2607
|
| Member silent or truncated | Send to ping or resume — same unit of work; see the state machine below |
|
|
2611
2608
|
| Member idle with work outstanding | Read the PR first, *then* ping. Idle ≠ done |
|
|
2612
|
-
| Member **killed** (spend limit, API error, crash) | Confirm it is dead first — a frozen transcript does not establish that; see below — then new member, new name, prompt carries inherited state |
|
|
2613
|
-
| Member **unaccounted** (a live token the tick flags `CHECK-LIVENESS`, or any live token nothing has reported on) | Registry census first: `read history://` once. A row other than `running` → `read agent://<member>` and the artifacts (PR head, worktree, labels), settle the real outcome with the existing vocabulary, and resume a `parked` member with `write agent://<member>` when its work is worth continuing. A `running` row → the mtime discriminator below, two polls: moved means BLOCKED and alive; frozen at both polls **and** no artifact movement → `write proc://<member>/kill`,
|
|
2609
|
+
| Member **killed** (spend limit, API error, crash) | Confirm it is dead first — a frozen transcript does not establish that; see below — then run the **Member confirmed dead or never dispatched** wake, then new member, new name, prompt carries inherited state |
|
|
2610
|
+
| Member **unaccounted** (a live token the tick flags `CHECK-LIVENESS`, or any live token nothing has reported on) | Registry census first: `read history://` once. A row other than `running` → `read agent://<member>` and the artifacts (PR head, worktree, labels), settle the real outcome with the existing vocabulary, and resume a `parked` member with `write agent://<member>` when its work is worth continuing. A `running` row → the mtime discriminator below, two polls: moved means BLOCKED and alive; frozen at both polls **and** no artifact movement → `write proc://<member>/kill`, the **Member confirmed dead or never dispatched** wake, fresh name. Three independent alive-signals — the first poll, the second poll, the artifacts — must all be silent before a kill; the tick settles and kills nothing |
|
|
2614
2611
|
|
|
2615
2612
|
### Recovery
|
|
2616
2613
|
|
|
@@ -2623,8 +2620,6 @@ spellings. Recovery is a fresh member, fresh name (`impl-<N>-b`,
|
|
|
2623
2620
|
`fix-pr-<M>-b`, and for a reviewer the next letter PR M's rows do not carry
|
|
2624
2621
|
yet) whose prompt states what it inherits.
|
|
2625
2622
|
|
|
2626
|
-
`hub cancel` leaves a peer hard-aborted and unmessageable, but a `failed` job's peer can stay `idle` and answer normally. No distinct truncated state exists.
|
|
2627
|
-
|
|
2628
2623
|
Reviewers that went idle on CI recover as a **finisher, not a re-review** once
|
|
2629
2624
|
commits are pushed. See references/member-lifecycle.md.
|
|
2630
2625
|
|
|
@@ -66,11 +66,11 @@ claim, never your patch.
|
|
|
66
66
|
Bring `<branch>` current with `origin/main`, resolving every conflict by the
|
|
67
67
|
**No-undo audit** in `$(~/.fleet/bin/fleet-run --root)/commands/run-merge-bot.md`.
|
|
68
68
|
Run `<testCmd>` from the worktree before you push, then push with
|
|
69
|
-
|
|
69
|
+
`~/.fleet/bin/fleet-run push-sync.mjs --branch <branch>`. Report the new head, or that no conflict was
|
|
70
70
|
left.
|
|
71
71
|
## kind: red-check
|
|
72
72
|
Read the failing job's log and fix the cause. Run `<testCmd>` from the
|
|
73
|
-
worktree before you push, then push with
|
|
73
|
+
worktree before you push, then push with `~/.fleet/bin/fleet-run push-sync.mjs --branch <branch>`.
|
|
74
74
|
Report the new head and **stop**: do not watch, poll or wait for CI — the
|
|
75
75
|
controller owns the next run on the pushed head. A red that is not yours to
|
|
76
76
|
fix — a flake that passes on rerun, or staleness — gets nothing pushed: report
|
|
@@ -92,7 +92,7 @@ reads gates and never injects a fault.
|
|
|
92
92
|
the worktree from. Pass it as `--branch`, because
|
|
93
93
|
the member cannot read it off git: references/isolation.md creates an already-open PR's worktree
|
|
94
94
|
with `--detach`, and there `git branch --show-current` prints nothing and exits 0
|
|
95
|
-
(measured 2026-09-18, git 2.50.1), so
|
|
96
|
-
"
|
|
97
|
-
|
|
98
|
-
all, which is why the renderer requires it.
|
|
95
|
+
(measured 2026-09-18, git 2.50.1), so `~/.fleet/bin/fleet-run push-sync.mjs
|
|
96
|
+
--branch "$(git branch --show-current)"` is handed an empty branch and exits 2
|
|
97
|
+
before pushing anything. A brief rendered without it would leave the push no
|
|
98
|
+
destination at all, which is why the renderer requires it.
|
|
@@ -151,7 +151,9 @@ The full text of each row `fleet-tick.mjs` prints and what it asks of the contro
|
|
|
151
151
|
above the rows — a live ledger token first seen more than
|
|
152
152
|
`--no-transcript-after` minutes ago (default 5) has no transcript in the
|
|
153
153
|
session directory: a dispatch whose `task` call was never made. Make the
|
|
154
|
-
`task` call, or
|
|
154
|
+
`task` call, or run the **Member confirmed dead or never dispatched** wake
|
|
155
|
+
(`fleet-event.mjs member-died <member>`), which settles the token `killed`
|
|
156
|
+
once the first-seen stamp is past five minutes. Advice the same way, folded as
|
|
155
157
|
`…; NO-TRANSCRIPT <member> persists — …`. Both lines read the session
|
|
156
158
|
directory this run's phase-0 receipt names, else `--session <dir>`; with
|
|
157
159
|
neither the tick skips them with one stderr line. A heartbeat state file
|