@feigi/fleet-ctl 3.82.4 → 3.82.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.82.4",
3
+ "version": "3.82.6",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -21,8 +21,10 @@
21
21
  # directory git-env.mjs's resolveWorkspace() names for the repository: the
22
22
  # common git dir's parent when the common dir is named `.git` or that parent's
23
23
  # own `.git` resolves to it, otherwise the common git dir itself (a bare
24
- # repository, a submodule). That is the same place the
25
- # ledger and the heartbeat live — so a claimed worktree and a
24
+ # repository, a submodule). Only an absent `<parent>/.git` is "not this
25
+ # parent"; one that exists and that git refuses fails the probe, so a linked
26
+ # worktree never re-keys the cache to the common dir. The ledger and the
27
+ # heartbeat live in that same place — so a claimed worktree and a
26
28
  # review worktree read the one cache the main checkout holds rather than a
27
29
  # private copy each. A single JSON object:
28
30
  #
@@ -179,9 +181,18 @@ common=$(git -C "$repo" rev-parse --path-format=absolute --git-common-dir 2>&1)
179
181
  # shell rather than asked of node: the interpreter probe below must stay the
180
182
  # first node call, since its refusals are pinned in that order. A common dir
181
183
  # named `.git` belongs to its parent checkout. Otherwise the parent's `.git`,
182
- # a symlink or a gitfile, that resolves to the common dir makes the parent the
183
- # workspace. Otherwise the common dir is the workspace itself (a bare
184
- # repository, a submodule's git dir).
184
+ # a symlink, a gitfile or a directory, that git resolves to the common dir
185
+ # makes the parent the workspace. Only an absent `<parent>/.git` is "not this
186
+ # parent": one that exists and that git refuses (a dangling or invalid gitfile,
187
+ # an unreadable one) fails the probe, never re-keys the cache to the common dir.
188
+ # Otherwise the common dir is the workspace itself (a bare repository, a
189
+ # submodule's git dir). git prints the realpath for a gitfile but the path as
190
+ # spelled for a `.git` directory, so its answer is canonicalised before the
191
+ # compare; `$target` stays absolute, since a relative operand is answered
192
+ # relative to the cwd. Only git's stdout is the answer: anything it writes to
193
+ # stderr on a success would otherwise land inside the path and fail the
194
+ # compare, re-keying the cache in silence. A refusal asks once more for the
195
+ # reason, its stderr alone.
185
196
  real=$(cd -P "$common" 2>/dev/null && pwd -P) || real=$common
186
197
  parent=${common%/*}
187
198
  case ${real##*/} in
@@ -189,13 +200,16 @@ case ${real##*/} in
189
200
  *)
190
201
  ws=$common
191
202
  target=${real%/*}/.git
192
- if [ -f "$target" ]; then
193
- IFS= read -r gl < "$target" || :
194
- gl=${gl#gitdir: }
195
- case $gl in /*) target=$gl ;; *) target=${real%/*}/$gl ;; esac
203
+ if [ -e "$target" ] || [ -L "$target" ]; then
204
+ named=$(git rev-parse --resolve-git-dir "$target" 2>/dev/null) || {
205
+ gitrc=$?
206
+ why=$(git rev-parse --resolve-git-dir "$target" 2>&1 >/dev/null) || :
207
+ case $gitrc in 128) die "cannot resolve the workspace of $repo: $target — $why" ;; esac
208
+ unrunnable "git did not resolve $target (exit $gitrc), so the Recipe cache was not read${why:+ — $why}"
209
+ }
210
+ named=$(cd -P "$named" 2>/dev/null && pwd -P) || named=
211
+ if [ "$named" = "$real" ]; then ws=$parent; fi
196
212
  fi
197
- named=$(cd -P "$target" 2>/dev/null && pwd -P) || named=
198
- if [ "$named" = "$real" ]; then ws=$parent; fi
199
213
  ;;
200
214
  esac
201
215
  cache="$ws/.fleet/recipe.json"
@@ -10,13 +10,16 @@
10
10
  // every worktree of one repository shares, and git-env.mjs's resolveWorkspace()
11
11
  // turns that answer into the workspace it names: the common git dir's parent
12
12
  // when the common dir is named `.git` or that parent's own `.git` resolves to
13
- // it, otherwise the common git dir itself. This module is the spawn around
14
- // that function — the scrubbed env, the bound, the join onto `.fleet/` — so a
15
- // new `.fleet/`-rooted file is one `fleetFile("name")` call rather than
16
- // another hand-spelled probe. The bound is per git child, not per call: when
17
- // the common git dir is not named `.git`, resolveWorkspace() runs a second
18
- // `rev-parse` after this module's own, with a bound of its own, so one call's
19
- // worst case approaches twice `timeoutMs`.
13
+ // it, otherwise the common git dir itself. Only an absent `<parent>/.git` is
14
+ // "not this parent"; one that exists and that git refuses fails the probe, so
15
+ // a linked worktree never re-keys to the common dir. This module is the spawn
16
+ // around that function — the scrubbed env, the bound, the join onto `.fleet/`
17
+ // — so a new `.fleet/`-rooted file is one `fleetFile("name")` call rather
18
+ // than another hand-spelled probe. The bound is per git child, not per call:
19
+ // when the common git dir is not named `.git` and the parent's `.git`
20
+ // exists, resolveWorkspace() runs a second `rev-parse` after this module's
21
+ // own, with a bound of its own, so one call's worst case approaches twice
22
+ // `timeoutMs`.
20
23
  //
21
24
  // One error policy: anything that leaves no workspace THROWS
22
25
  // FleetDirUnresolvable, never a null or an empty path. What a caller does
@@ -115,15 +118,16 @@ function workspaceOf(cwd, timeoutMs, canonicalise) {
115
118
  * that spelling without changing which file it names.
116
119
  *
117
120
  * `timeoutMs` (default git-env.mjs's DEFAULT_GIT_TIMEOUT_MS) bounds each git
118
- * child, not the call. When the common git dir is not named `.git` (the
119
- * `.bare` layout, a bare repository, a submodule, a `--separate-git-dir`
120
- * clone), resolveWorkspace() runs a second probe after `--git-common-dir`,
121
- * with its own full `timeoutMs`, so one call's worst case approaches twice
122
- * `timeoutMs`.
121
+ * child, not the call. When the common git dir is not named `.git` and the
122
+ * `.git` in its parent exists (the `.bare` layout, or any other git dir that
123
+ * shares a parent with a `.git`), resolveWorkspace()
124
+ * runs a second probe after `--git-common-dir`, with its own full `timeoutMs`,
125
+ * so one call's worst case approaches twice `timeoutMs`.
123
126
  *
124
127
  * Throws FleetDirUnresolvable for every probe that leaves no workspace — no
125
- * repository, an unreadable `.git`, git missing, a probe killed at
126
- * `timeoutMs` — with git's own reason in the message.
128
+ * repository, an unreadable `.git`, a parent's `.git` that git refuses, git
129
+ * missing, a probe killed at `timeoutMs` — with git's own reason in the
130
+ * message.
127
131
  */
128
132
  export function fleetFile(name, { cwd = process.cwd(), timeoutMs, canonicalise = false } = {}) {
129
133
  const { dir } = workspaceOf(cwd, timeoutMs, canonicalise);
@@ -146,9 +150,9 @@ export function fleetFile(name, { cwd = process.cwd(), timeoutMs, canonicalise =
146
150
  *
147
151
  * `home` exists for tests; it defaults to the user's home directory.
148
152
  * `timeoutMs` bounds each git child exactly as in fleetFile(): two may run in
149
- * sequence when the common git dir is not named `.git`, so one call's worst
150
- * case approaches twice `timeoutMs`. Throws FleetDirUnresolvable exactly as
151
- * fleetFile() does.
153
+ * sequence when the common git dir is not named `.git` and its parent's `.git`
154
+ * exists, so one call's worst case approaches twice `timeoutMs`. Throws
155
+ * FleetDirUnresolvable exactly as fleetFile() does.
152
156
  */
153
157
  export function scratchRoot({ cwd = process.cwd(), timeoutMs, home = homedir() } = {}) {
154
158
  const { dir, gitDir } = workspaceOf(cwd, timeoutMs, true);
@@ -63,8 +63,10 @@ export function gitEnv(overrides = {}, base = process.env) {
63
63
  // `.git` or when that parent's own `.git` resolves to it (the `.bare`
64
64
  // layout's gitfile); otherwise it is the common dir itself — a bare
65
65
  // repository's, a submodule's or a `--separate-git-dir` clone's git dir — so
66
- // two such dirs sharing a parent do not collapse onto one workspace. The
67
- // rule's full statement sits beside `resolveWorkspace()` below. The main
66
+ // two such dirs sharing a parent do not collapse onto one workspace. Only an
67
+ // absent `<parent>/.git` is "not this parent"; one that git refuses fails the
68
+ // probe, so a linked worktree never re-keys to the common dir. The rule's
69
+ // full statement sits beside `resolveWorkspace()` below. The main
68
70
  // checkout's work tree is a separate question with its own answer,
69
71
  // `mainCheckoutDirFromGitCommonDir()`: a `.git` symlinked to a git dir
70
72
  // elsewhere moves the workspace out of the checkout, never the work tree.
@@ -79,7 +81,7 @@ export function gitEnv(overrides = {}, base = process.env) {
79
81
  // composes the rest around it.
80
82
 
81
83
  import { spawnSync } from "node:child_process";
82
- import { realpathSync } from "node:fs";
84
+ import { lstatSync, realpathSync } from "node:fs";
83
85
  import { basename, dirname, join, resolve } from "node:path";
84
86
 
85
87
  // A `rev-parse` answers in milliseconds; this bound exists so a git that never
@@ -87,8 +89,10 @@ import { basename, dirname, join, resolve } from "node:path";
87
89
  export const DEFAULT_GIT_TIMEOUT_MS = 10_000;
88
90
 
89
91
  // Thrown when the one probe the rule needs beyond `--git-common-dir` could not
90
- // run to an answer: git missing, or killed at its bound. Not thrown for a git
91
- // that ran and refused — that is an answer, and the rule reads it as "no".
92
+ // run to an answer: git missing, killed at its bound, or a `<parent>/.git`
93
+ // that exists and that git refuses. Only an absent `<parent>/.git` is "no";
94
+ // one git refuses fails the probe, so a linked worktree never re-keys to the
95
+ // common dir.
92
96
  export class WorkspaceProbeFailed extends Error {
93
97
  constructor(message) {
94
98
  super(message);
@@ -107,25 +111,32 @@ function canonicalPath(path) {
107
111
  }
108
112
 
109
113
  // Why a probe that did not answer did not: the spawn error, the signal that
110
- // killed it, or its stderr. Its caller reaches it only for a spawn error or a
111
- // probe with no exit status, so no exit code is ever left to name.
114
+ // killed it, its stderr, or its exit status when stderr says nothing.
112
115
  function probeWhy(r) {
113
116
  if (r.error && r.error.message) return r.error.message;
114
117
  if (r.signal) return `killed by ${r.signal}`;
115
- return String(r.stderr ?? "").trim() || "no answer";
118
+ return String(r.stderr ?? "").trim() || `git exited ${r.status}`;
116
119
  }
117
120
 
118
121
  // Does `<parent>/.git` name `gitDir` (a realpath)? `--resolve-git-dir` follows
119
- // a gitfile, so the `.bare` layout's `.git` file answers with `.bare`.
122
+ // a gitfile, so the `.bare` layout's `.git` file answers with `.bare`. Only an
123
+ // absent `<parent>/.git` is no, without a spawn; one that exists and that git
124
+ // refuses (a dangling or invalid gitfile, an unreadable one) fails the probe.
125
+ // Git's stderr is only quoted, never read: its text depends on the locale.
120
126
  function parentGitDirNames(parent, gitDir, cwd, timeoutMs) {
121
127
  const candidate = join(parent, ".git");
128
+ try {
129
+ lstatSync(candidate);
130
+ } catch (e) {
131
+ if (e.code === "ENOENT") return false;
132
+ throw new WorkspaceProbeFailed(`could not resolve ${candidate}: ${e.message}`);
133
+ }
122
134
  const r = spawnSync("git", ["rev-parse", "--resolve-git-dir", candidate], {
123
135
  cwd, encoding: "utf8", timeout: timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS, env: gitEnv(),
124
136
  });
125
- if (r.error || r.status === null) {
137
+ if (r.error || r.status !== 0) {
126
138
  throw new WorkspaceProbeFailed(`could not resolve ${candidate}: ${probeWhy(r)}`);
127
139
  }
128
- if (r.status !== 0) return false;
129
140
  return realpathOr(resolve(parent, r.stdout.trim())) === gitDir;
130
141
  }
131
142
 
@@ -138,8 +149,9 @@ function parentGitDirNames(parent, gitDir, cwd, timeoutMs) {
138
149
  * The rule, on the common dir `C` (the realpath of the answer):
139
150
  * 1. `C` is named `.git`: the workspace is its parent. No spawn.
140
151
  * 2. Otherwise `<parent of C>/.git` is probed with `--resolve-git-dir`. If it
141
- * names `C`, the workspace is that parent (the `.bare` layout). A git that
142
- * refuses, or names something else, is "no".
152
+ * names `C`, the workspace is that parent (the `.bare` layout). A
153
+ * `.git` that is absent, or that git names as something else, is "no".
154
+ * A `.git` that exists and that git refuses fails the probe.
143
155
  * 3. Otherwise the workspace is `C` itself, with `gitDir: true`.
144
156
  *
145
157
  * `gitDir` is what the scratch root's key reads: a git dir's `.git` suffix
@@ -160,12 +172,13 @@ function parentGitDirNames(parent, gitDir, cwd, timeoutMs) {
160
172
  * uncanonicalised path.
161
173
  *
162
174
  * `timeoutMs` (default DEFAULT_GIT_TIMEOUT_MS) bounds this function's own
163
- * step-2 probe only, the one spawn it makes. The caller's `--git-common-dir`
175
+ * step-2 probe only, the one spawn it can make. The caller's `--git-common-dir`
164
176
  * probe that produced `gitCommonDir` is the caller's to bound, so a caller
165
177
  * passing the same `timeoutMs` to both can wait close to twice that in all.
166
178
  *
167
- * Throws WorkspaceProbeFailed only when the step-2 probe could not run to an
168
- * answer.
179
+ * Throws WorkspaceProbeFailed when the step-2 probe could not run to an
180
+ * answer: git missing or killed at its bound, or a `<parent of C>/.git` that
181
+ * exists and that git refuses.
169
182
  */
170
183
  export function resolveWorkspace(gitCommonDir, cwd = process.cwd(), { canonicalise = false, timeoutMs } = {}) {
171
184
  const common = String(gitCommonDir ?? "").trim();
@@ -126,20 +126,23 @@ function cause(...candidates) {
126
126
  // though "dominant" overstates that relationship: a normal invocation
127
127
  // without `--file` can pay this same budget TWICE in sequence, once here and
128
128
  // once more in repoCheck() below, before ever reaching `gh` at all — and
129
- // THREE times in layouts whose common git dir is not named `.git` (the
130
- // `.bare` layout, a bare repository, a submodule, a `--separate-git-dir`
131
- // clone), where fleetFile() runs a second probe with its own full budget:
129
+ // THREE times where the common git dir is not named `.git` and its parent
130
+ // holds a `.git` (the `.bare` layout, or any other git dir sharing a parent
131
+ // with a `.git`), where fleetFile() runs a second probe with its own full budget:
132
132
  // fleetFile()'s bound is per git child, not per call. At default settings
133
133
  // two stalled probes TIE `gh`'s own 20 s bound and three exceed it, and even
134
134
  // one stall can EXCEED it — up to GIT_TIMEOUT_MS + 20 s — when `gh` still
135
135
  // runs (measured). `check`'s worst case is the sum of whichever of its
136
136
  // children actually stall, not a single fixed term. That is three children
137
- // normally; in those layouts it is three git children and `gh` is a fourth.
137
+ // normally; in the `.bare` layout it is three git children and `gh` is a fourth.
138
+ // A bare repository, a submodule or a `--separate-git-dir` clone has no `.git`
139
+ // beside its git dir, so fleetFile() runs no second probe there.
138
140
  // Where the ledger's repository has no work tree (the `.bare` layout, a bare
139
141
  // repository, a `--separate-git-dir` clone; not a submodule) repoCheck()'s
140
142
  // `--show-toplevel` is refused and it asks the same directory for
141
- // `--git-common-dir` to bind `gh` to, so there it is four git children and
142
- // `gh` a fifth. `gh` runs in every one of those layouts. With `--file` the
143
+ // `--git-common-dir` to bind `gh` to: four git children and `gh` a fifth in
144
+ // the `.bare` layout, three and `gh` a fourth in the other two. `gh` runs in
145
+ // every one of those layouts. With `--file` the
143
146
  // fleetFile() children drop out: two children normally, three where the
144
147
  // repository has no work tree.
145
148
  //
@@ -599,7 +599,7 @@ worktree re-created for an already-open PR is in references/isolation.md.
599
599
  | 1 `LEFT #N: taken …` | in-flight hit, an in-flight check that could not answer, or `claim-ticket.sh` exit 2 | next entry |
600
600
  | 1 `LEFT #N: relabelled elsewhere` | no longer labelled `ready-for-agent` | next entry |
601
601
  | 1 `LEFT #N: closed: fixed by …` | `close` answered `yes` | next entry |
602
- | 1 `LEFT #N: relabelled <label>: …` | `decided` answered with a cause | next entry |
602
+ | 1 `LEFT #N: relabelled <label>: …` | `decided` answered with a cause, or a stranded ruling | next entry |
603
603
  | 1 `LEFT #N: excluded …` | `collision` answered `behind-pr:#M` or `behind-issue:#M`, or its ledger exclusion stands | next entry |
604
604
  | 1 `EMPTY` | the shortlist holds no entry to pull | run `shortlist.mjs` once and Pull again; still empty, hold the slot idle until the next refresh trigger |
605
605
  | 2 | could not look, named on stderr | as the entry paragraph says |
@@ -1771,7 +1771,7 @@ above and every row of the table below already applied; every wake
1771
1771
  ends in it. The deficit is then *computed, not remembered*, which is
1772
1772
  the whole point: a table you must remember to consult is one you will not
1773
1773
  consult under a merge-side event storm, and that is how implementers reached
1774
- 0/target with one approved ticket waiting and ~57 `ready-for-agent` in supply while nobody
1774
+ 0/target with ~57 `ready-for-agent` in supply while nobody
1775
1775
  noticed (#3). What follows stays here as the explanation of what the script decides —
1776
1776
  never as a second, hand-run copy of it. The table's shortlist counts no head
1777
1777
  whose Pull ended this run. A refresh at unclaimed < B leaves `unclaimed=` at
@@ -242,6 +242,7 @@ does:
242
242
  |---|---|
243
243
  | in-flight hit, or an in-flight check that could not answer | drop, next entry |
244
244
  | no longer labelled `ready-for-agent` | drop, next entry |
245
+ | a stranded ruling: a `## Ruling` or `## Triage` comment newer than the last `## Agent Brief`, or on a ticket with none | relabel `ready-for-human`, next entry |
245
246
  | `staleness.mjs` exit 1 — provably fixed | close citing the commit, next entry |
246
247
  | brief names no *what* | relabel `needs-triage`, next entry |
247
248
  | genuine fork — options named, none ruled | relabel `ready-for-human`, next entry |
@@ -266,8 +267,11 @@ that trusted a list built before the relabel landed would hand a ticket the
266
267
  fleet has already judged unimplementable straight to the next member.
267
268
 
268
269
  **Relabel by cause.** An unclaimed ticket is yours to reclassify at the Pull —
269
- three causes, two labels: `ready-for-human` for hands or a fork;
270
- `needs-triage` for a brief that names no *what*.
270
+ `ready-for-human` for hands or a fork; `needs-triage` for a brief that names no
271
+ *what*. A stranded ruling relabels to `ready-for-human` as well, but it is never
272
+ yours to judge: `pull.mjs` finds it at step 3 and posts its own comment naming
273
+ the stranded comment's heading line and URL, which is not one of the
274
+ `briefs/relabel.md` comments.
271
275
  `gh issue edit <N> --remove-label ready-for-agent --add-label <label>` (no
272
276
  `in-progress` to drop — it was never claimed), preceded by exactly one comment,
273
277
  the one `~/.fleet/bin/fleet-run dispatch-prompt.mjs render relabel --kind <kind> …`
@@ -280,8 +284,9 @@ live in `briefs/relabel.md`; `pull.mjs` posts its own one-line copy of the same
280
284
  words when a Pull answer relabels, so a change to either is a change to both.
281
285
 
282
286
  Neither label invokes anything: `/triage` is maintainer-run, and both labels
283
- only stop the ticket costing a read per scan. These comments are the only
284
- writes the fleet makes to a ticket it did not claim.
287
+ only stop the ticket costing a read per scan. These comments, the
288
+ stranded-ruling comment and the comment closing a provably fixed ticket are the
289
+ only writes the fleet makes to a ticket it did not claim.
285
290
 
286
291
  **Exclusion is a ticket row with a premise.** `ledger.mjs row <N> "excluded ·
287
292
  behind-pr:#M"` or `ledger.mjs row <N> "excluded · behind-issue:#M"` — `#M` is