@clipboard-health/groundcrew 4.45.4 → 4.45.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.
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { ResolvedConfig } from "../lib/config.ts";
7
7
  import { type BoardState } from "../lib/taskSource.ts";
8
- import { type WorktreeEntry } from "../lib/worktrees.ts";
8
+ import type { WorktreeEntry } from "../lib/worktrees.ts";
9
9
  interface CleanerDeps {
10
10
  config: ResolvedConfig;
11
11
  }
@@ -1 +1 @@
1
- {"version":3,"file":"cleaner.d.ts","sourceRoot":"","sources":["../../src/commands/cleaner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,EAA0B,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAE/E,OAAO,EAAE,KAAK,aAAa,EAAa,MAAM,qBAAqB,CAAC;AAGpE,UAAU,WAAW;IACnB,MAAM,EAAE,cAAc,CAAC;CACxB;AAED,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,CAAC,UAAU,EAAE;QACpB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;KACtB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAoDxD"}
1
+ {"version":3,"file":"cleaner.d.ts","sourceRoot":"","sources":["../../src/commands/cleaner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,EAA0B,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAE/E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGzD,UAAU,WAAW;IACnB,MAAM,EAAE,cAAc,CAAC;CACxB;AAED,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,CAAC,UAAU,EAAE;QACpB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;KACtB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CA8CxD"}
@@ -3,11 +3,9 @@
3
3
  * tasks that have reached a terminal status. One per `orchestrate()`
4
4
  * invocation; stateless across iterations. Mirrors `Dispatcher`.
5
5
  */
6
- import { recordCleanedUpRuns } from "../lib/runStateCleanup.js";
7
6
  import { naturalIdFromCanonical } from "../lib/taskSource.js";
8
7
  import { log, logEvent } from "../lib/util.js";
9
- import { worktrees } from "../lib/worktrees.js";
10
- import { logTeardown, recordTeardownEvents } from "./teardownReporter.js";
8
+ import { reapWorktrees } from "./teardownReporter.js";
11
9
  export function createCleaner(deps) {
12
10
  const { config } = deps;
13
11
  async function runOnce(arguments_) {
@@ -37,12 +35,7 @@ export function createCleaner(deps) {
37
35
  return;
38
36
  }
39
37
  log(`Cleaning up ${stale.length} terminal worktree(s)`);
40
- const result = signal === undefined
41
- ? await worktrees.teardown(config, stale)
42
- : await worktrees.teardown(config, stale, { signal });
43
- recordCleanedUpRuns(config, result.removed);
44
- logTeardown(result);
45
- recordTeardownEvents(result);
38
+ await reapWorktrees(config, stale, signal);
46
39
  }
47
40
  return { runOnce };
48
41
  }
@@ -7,8 +7,11 @@
7
7
  * dispatch slot (slot math counts only in-progress) while leaving the
8
8
  * worktree intact for review, since the cleaner only tears down `done`.
9
9
  * - A **merged** PR (on an in-progress or in-review task) → `markDone`:
10
- * the work has landed, so the task is terminal and the cleaner tears the
11
- * worktree down on a later tick. `merged` never routes to `in-review`.
10
+ * the work has landed, so the task is terminal. On a successful `markDone`
11
+ * the reviewer tears the worktree down immediately (same step). The cleaner
12
+ * remains the level-triggered backstop for sources where `markDone` is
13
+ * unsupported or that keep reporting `done`. `merged` never routes to
14
+ * `in-review`.
12
15
  *
13
16
  * Sources that don't implement `markDone` (e.g. Linear) return `unsupported`;
14
17
  * the reviewer logs the skip and does nothing — there is no in-review
@@ -1 +1 @@
1
- {"version":3,"file":"reviewer.d.ts","sourceRoot":"","sources":["../../src/commands/reviewer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,KAAK,UAAU,EAIhB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGzD;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,UAAU,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,KAAK,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAAC;AAE7C,UAAU,YAAY;IACpB,KAAK,EAAE,KAAK,CAAC;IACb,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,MAAM,CAAC,EAAE,cAAc,CAAC;CACzB;AAED,sEAAsE;AACtE,UAAU,eAAe;IACvB,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,CAAC,UAAU,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AA+CD,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG,QAAQ,CA+H3D"}
1
+ {"version":3,"file":"reviewer.d.ts","sourceRoot":"","sources":["../../src/commands/reviewer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,KAAK,UAAU,EAIhB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIzD;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,UAAU,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,KAAK,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAAC;AAE7C,UAAU,YAAY;IACpB,KAAK,EAAE,KAAK,CAAC;IACb,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,MAAM,CAAC,EAAE,cAAc,CAAC;CACzB;AAED,sEAAsE;AACtE,UAAU,eAAe;IACvB,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,CAAC,UAAU,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AA+CD,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG,QAAQ,CAiJ3D"}
@@ -7,8 +7,11 @@
7
7
  * dispatch slot (slot math counts only in-progress) while leaving the
8
8
  * worktree intact for review, since the cleaner only tears down `done`.
9
9
  * - A **merged** PR (on an in-progress or in-review task) → `markDone`:
10
- * the work has landed, so the task is terminal and the cleaner tears the
11
- * worktree down on a later tick. `merged` never routes to `in-review`.
10
+ * the work has landed, so the task is terminal. On a successful `markDone`
11
+ * the reviewer tears the worktree down immediately (same step). The cleaner
12
+ * remains the level-triggered backstop for sources where `markDone` is
13
+ * unsupported or that keep reporting `done`. `merged` never routes to
14
+ * `in-review`.
12
15
  *
13
16
  * Sources that don't implement `markDone` (e.g. Linear) return `unsupported`;
14
17
  * the reviewer logs the skip and does nothing — there is no in-review
@@ -24,6 +27,7 @@
24
27
  import { naturalIdFromCanonical, } from "../lib/taskSource.js";
25
28
  import { debug, errorMessage, log, logEvent } from "../lib/util.js";
26
29
  import { effectiveBranchName } from "../lib/worktreeRunState.js";
30
+ import { reapWorktrees } from "./teardownReporter.js";
27
31
  // Maps a worktree's PRs to the transition its task should make. A merged PR
28
32
  // means the work landed → done; an open PR on an in-progress task means it's
29
33
  // up for review. `merged` wins over `open`. An open PR on an already in-review
@@ -118,7 +122,15 @@ export function createReviewer(deps) {
118
122
  return;
119
123
  }
120
124
  // oxlint-disable-next-line no-await-in-loop -- single write-back then return; never iterates past the first actionable worktree.
121
- await advance({ issue, task, pullRequest, transition });
125
+ await advance({
126
+ issue,
127
+ task,
128
+ pullRequest,
129
+ transition,
130
+ entry,
131
+ reviewerConfig,
132
+ signal,
133
+ });
122
134
  return;
123
135
  }
124
136
  }
@@ -126,7 +138,7 @@ export function createReviewer(deps) {
126
138
  // task keeps its status and is retried next tick, exactly like a failed
127
139
  // lookup. We never let one task's writeback abort the others' reviews.
128
140
  async function advance(arguments_) {
129
- const { issue, task, pullRequest, transition } = arguments_;
141
+ const { issue, task, pullRequest, transition, entry, reviewerConfig, signal } = arguments_;
130
142
  try {
131
143
  const result = transition === "done" ? await board.markDone(issue) : await board.markInReview(issue);
132
144
  if (result.outcome === "unsupported") {
@@ -147,6 +159,14 @@ export function createReviewer(deps) {
147
159
  state: pullRequest.state,
148
160
  to: transition,
149
161
  });
162
+ if (transition === "done" && reviewerConfig !== undefined) {
163
+ try {
164
+ await reapWorktrees(reviewerConfig, [entry], signal);
165
+ }
166
+ catch (teardownError) {
167
+ log(`Teardown failed for ${task}: ${errorMessage(teardownError)}`);
168
+ }
169
+ }
150
170
  }
151
171
  catch (error) {
152
172
  log(`Failed to advance ${task} to ${transition}: ${errorMessage(error)}`);
@@ -1,4 +1,11 @@
1
- import type { TeardownResult } from "../lib/worktrees.ts";
1
+ import type { ResolvedConfig } from "../lib/config.ts";
2
+ import { type TeardownResult, type WorktreeEntry } from "../lib/worktrees.ts";
2
3
  export declare function logTeardown(result: TeardownResult): void;
4
+ /**
5
+ * Shared helper: runs `worktrees.teardown` then records run-state cleanup,
6
+ * logs the result, and emits telemetry events. Used by both the cleaner and
7
+ * the reviewer fast-path so the sequence is never duplicated.
8
+ */
9
+ export declare function reapWorktrees(config: ResolvedConfig, entries: readonly WorktreeEntry[], signal?: AbortSignal): Promise<TeardownResult>;
3
10
  export declare function recordTeardownEvents(result: TeardownResult): void;
4
11
  //# sourceMappingURL=teardownReporter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"teardownReporter.d.ts","sourceRoot":"","sources":["../../src/commands/teardownReporter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE1D,wBAAgB,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAmBxD;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAwCjE"}
1
+ {"version":3,"file":"teardownReporter.d.ts","sourceRoot":"","sources":["../../src/commands/teardownReporter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGvD,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAa,MAAM,qBAAqB,CAAC;AAEzF,wBAAgB,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAmBxD;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,SAAS,aAAa,EAAE,EACjC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CASzB;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAwCjE"}
@@ -1,4 +1,6 @@
1
+ import { recordCleanedUpRuns } from "../lib/runStateCleanup.js";
1
2
  import { debug, errorMessage, log, logEvent, okMark } from "../lib/util.js";
3
+ import { worktrees } from "../lib/worktrees.js";
2
4
  export function logTeardown(result) {
3
5
  if (result.workspaceProbe.kind === "unavailable" && result.workspaceProbe.error !== undefined) {
4
6
  log(`workspace list failed: ${errorMessage(result.workspaceProbe.error)}`);
@@ -20,6 +22,20 @@ export function logTeardown(result) {
20
22
  }
21
23
  }
22
24
  }
25
+ /**
26
+ * Shared helper: runs `worktrees.teardown` then records run-state cleanup,
27
+ * logs the result, and emits telemetry events. Used by both the cleaner and
28
+ * the reviewer fast-path so the sequence is never duplicated.
29
+ */
30
+ export async function reapWorktrees(config, entries, signal) {
31
+ const result = signal === undefined
32
+ ? await worktrees.teardown(config, entries)
33
+ : await worktrees.teardown(config, entries, { signal });
34
+ recordCleanedUpRuns(config, result.removed);
35
+ logTeardown(result);
36
+ recordTeardownEvents(result);
37
+ return result;
38
+ }
23
39
  export function recordTeardownEvents(result) {
24
40
  if (result.workspaceProbe.kind === "unavailable") {
25
41
  logEvent("cleanup", {
@@ -1 +1 @@
1
- {"version":3,"file":"cmuxAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/cmuxAdapter.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,KAAK,OAAO,EAIb,MAAM,uBAAuB,CAAC;AAG/B,eAAO,MAAM,WAAW,EAAE,OA+EzB,CAAC"}
1
+ {"version":3,"file":"cmuxAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/cmuxAdapter.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,KAAK,OAAO,EAIb,MAAM,uBAAuB,CAAC;AAG/B,eAAO,MAAM,WAAW,EAAE,OAqFzB,CAAC"}
@@ -7,18 +7,25 @@ import { isSignalAborted, runWorkspaceCommand, } from "./workspaceAdapter.js";
7
7
  import { debug, errorMessage, log } from "./util.js";
8
8
  export const cmuxAdapter = {
9
9
  async open(spec, signal) {
10
- const output = await runWorkspaceCommand("cmux", [
11
- "--json",
12
- "new-workspace",
13
- "--name",
14
- spec.name,
15
- "--cwd",
16
- spec.cwd,
17
- "--command",
18
- spec.command,
19
- "--description",
20
- cmuxDescriptionFor(spec.name),
21
- ], signal);
10
+ let output;
11
+ try {
12
+ output = await runWorkspaceCommand("cmux", [
13
+ "--json",
14
+ "new-workspace",
15
+ "--name",
16
+ spec.name,
17
+ "--cwd",
18
+ spec.cwd,
19
+ "--command",
20
+ spec.command,
21
+ "--description",
22
+ cmuxDescriptionFor(spec.name),
23
+ ], signal);
24
+ }
25
+ catch (error) {
26
+ await closeWorkspaceLeakedByFailedOpen(error, spec.name, signal);
27
+ throw error;
28
+ }
22
29
  const workspaceId = extractCmuxOpenId(output);
23
30
  if (workspaceId === undefined) {
24
31
  log(`cmux new-workspace returned unrecognized output for ${spec.name}; if a workspace was created, run \`cmux close-workspace\` manually.`);
@@ -180,6 +187,48 @@ async function applyCmuxStatus(workspaceId, status, signal) {
180
187
  async function closeCmuxWorkspace(workspaceId, signal) {
181
188
  await runWorkspaceCommand("cmux", ["close-workspace", "--workspace", workspaceId], signal);
182
189
  }
190
+ /**
191
+ * cmux occasionally exits non-zero from `new-workspace` while still having
192
+ * created the workspace (a known flake where it also lands in the wrong `--cwd`).
193
+ * The id rides along in the failed command's captured output, so recover it and
194
+ * close that exact workspace by id — a failed launch must not strand an orphan
195
+ * tagged with the task's `groundcrew:<taskId>` marker. Closing by the recovered
196
+ * id needs no `list-workspaces`, so it survives a concurrent list failure that
197
+ * would defeat re-enumeration. Re-enumeration close is unsafe here anyway; we
198
+ * hold the precise id cmux returned, so there is no same-named-sibling risk.
199
+ */
200
+ async function closeWorkspaceLeakedByFailedOpen(error, name, signal) {
201
+ if (isSignalAborted(signal)) {
202
+ return;
203
+ }
204
+ const workspaceId = extractCmuxOpenIdFromFailure(error);
205
+ if (workspaceId === undefined) {
206
+ return;
207
+ }
208
+ try {
209
+ await closeCmuxWorkspace(workspaceId, signal);
210
+ debug(`cmux new-workspace for ${name} exited non-zero but had created ${workspaceId}; closed the leaked workspace.`);
211
+ }
212
+ catch (closeError) {
213
+ log(`cmux new-workspace for ${name} exited non-zero and left workspace ${workspaceId}; automatic close failed (${errorMessage(closeError)}). Run \`cmux close-workspace --workspace ${workspaceId}\` by hand.`);
214
+ }
215
+ }
216
+ /**
217
+ * Recover the created workspace id from a failed `new-workspace`. Parse only
218
+ * the captured stdout slice of the command error's message (see
219
+ * `normalizeCommandError`), never the whole message — matching against stderr
220
+ * or the echoed command line risks grabbing an unrelated `workspace:N` and
221
+ * closing the wrong workspace. The slice is the same shape the success path
222
+ * sees, so `extractCmuxOpenId` handles both the `--json` id object and a bare
223
+ * `workspace:N` ref.
224
+ */
225
+ function extractCmuxOpenIdFromFailure(error) {
226
+ const stdout = cmuxStdoutFromFailureMessage(errorMessage(error));
227
+ return stdout === undefined ? undefined : extractCmuxOpenId(stdout);
228
+ }
229
+ function cmuxStdoutFromFailureMessage(message) {
230
+ return /\nStdout:\n([\s\S]*?)(?:\nCause: |$)/.exec(message)?.[1];
231
+ }
183
232
  function isCmuxSetStatusUnsupported(error) {
184
233
  return errorMessage(error).includes('unknown command "set-status"');
185
234
  }
@@ -1 +1 @@
1
- {"version":3,"file":"usage.d.ts","sourceRoot":"","sources":["../../src/lib/usage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAmB,cAAc,EAAE,MAAM,aAAa,CAAC;AA+BnE,UAAU,eAAe;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;AAE3D;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,EAAE,eAK7B,CAAC;AA2GF,wBAAgB,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,EAAE,CAI5D;AAED,wBAAsB,eAAe,CACnC,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,YAAY,CAAC,CA+BvB"}
1
+ {"version":3,"file":"usage.d.ts","sourceRoot":"","sources":["../../src/lib/usage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAmB,cAAc,EAAE,MAAM,aAAa,CAAC;AA+BnE,UAAU,eAAe;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;AAE3D;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,EAAE,eAK7B,CAAC;AA8JF,wBAAgB,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,EAAE,CAI5D;AAED,wBAAsB,eAAe,CACnC,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,YAAY,CAAC,CA+BvB"}
package/dist/lib/usage.js CHANGED
@@ -52,12 +52,30 @@ async function codexbarUsage(definition, signal) {
52
52
  "--format",
53
53
  "json",
54
54
  ];
55
- const out = await runCommandAsync("codexbar", arguments_, signal === undefined
55
+ const options = signal === undefined
56
56
  ? { timeoutMs: CODEXBAR_TIMEOUT_MS }
57
- : {
58
- signal,
59
- timeoutMs: CODEXBAR_TIMEOUT_MS,
60
- });
57
+ : { signal, timeoutMs: CODEXBAR_TIMEOUT_MS };
58
+ let out;
59
+ try {
60
+ out = await runCommandAsync("codexbar", arguments_, options);
61
+ }
62
+ catch (error) {
63
+ if (signal?.aborted === true) {
64
+ throw error;
65
+ }
66
+ // codexbar exits non-zero (e.g. status 3 for "no rate limit events yet")
67
+ // for handled provider conditions while still writing its JSON payload to
68
+ // stdout. runCommandAsync rejects on the exit code, so recover that payload
69
+ // and parse it like a success — its per-entry `error` field, not the exit
70
+ // code, distinguishes "available" from a genuine failure below. A missing
71
+ // or unparseable payload is a real failure: rethrow so the caller fails
72
+ // closed.
73
+ const recovered = recoverStdout(error);
74
+ if (recovered === undefined) {
75
+ throw error;
76
+ }
77
+ out = recovered;
78
+ }
61
79
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse returns any; codexbar's --format json output matches CodexbarEntry[]
62
80
  const parsed = JSON.parse(out);
63
81
  // codexbar can return multiple entries when a provider has several
@@ -77,8 +95,16 @@ async function codexbarUsage(definition, signal) {
77
95
  throw new Error(`codexbar returned no matching entry for provider=${provider}, source=${source}`);
78
96
  }
79
97
  if (!match.usage) {
80
- // codexbar can return `{error: ...}` instead of `{usage: ...}` when
81
- // the underlying provider failed (e.g. codex app-server crashed). The
98
+ // codexbar reports a valid session that simply has no rate-limit events
99
+ // recorded yet a fresh, low-traffic, or unlimited-quota account that has
100
+ // consumed nothing the windows can measure. That is the *least* exhausted
101
+ // state, not an unreadable one: return empty windows so it normalizes to
102
+ // `session: null` (available) instead of fail-closing to exhausted.
103
+ if (isNoRateLimitEvents(match.error)) {
104
+ return {};
105
+ }
106
+ // codexbar can otherwise return `{error: ...}` instead of `{usage: ...}`
107
+ // when the underlying provider failed (e.g. codex app-server crashed). The
82
108
  // outer catch in getUsageByAgent turns this into a fail-closed
83
109
  // exhausted entry; surface codexbar's error message so the operator
84
110
  // can fix the underlying CLI.
@@ -87,6 +113,33 @@ async function codexbarUsage(definition, signal) {
87
113
  }
88
114
  return match.usage;
89
115
  }
116
+ /**
117
+ * codexbar signals an authenticated account with zero recorded rate-limit
118
+ * events via this provider error (e.g. "Found sessions, but no rate limit
119
+ * events yet."). It means no quota consumed — available — not a probe failure.
120
+ */
121
+ function isNoRateLimitEvents(error) {
122
+ return /no rate limit events/i.test(error?.message ?? "");
123
+ }
124
+ /**
125
+ * runCommandAsync rejects on a non-zero exit, attaching the captured stdout
126
+ * Buffer to the thrown error's `cause`. codexbar uses non-zero exits for handled
127
+ * provider conditions while still emitting its JSON payload, so pull that stdout
128
+ * back out to parse like a success. Returns undefined when no stdout was
129
+ * captured (a real failure with nothing to parse).
130
+ */
131
+ function recoverStdout(error) {
132
+ /* v8 ignore next 3 @preserve -- runCommandAsync always rejects with an Error */
133
+ if (!(error instanceof Error)) {
134
+ return undefined;
135
+ }
136
+ // A plain command failure with no captured output carries no cause/stdout.
137
+ if (!(error.cause instanceof Error) || !("stdout" in error.cause)) {
138
+ return undefined;
139
+ }
140
+ const { stdout } = error.cause;
141
+ return Buffer.isBuffer(stdout) ? stdout.toString("utf8") : undefined;
142
+ }
90
143
  function toFraction(value) {
91
144
  if (value === undefined || value === null) {
92
145
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "4.45.4",
3
+ "version": "4.45.6",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",
@@ -83,7 +83,7 @@
83
83
  "@tsconfig/node24": "24.0.4",
84
84
  "@tsconfig/strictest": "2.0.8",
85
85
  "@types/node": "25.9.4",
86
- "@typescript/native-preview": "7.0.0-dev.20260618.1",
86
+ "@typescript/native-preview": "7.0.0-dev.20260621.1",
87
87
  "@vitest/coverage-v8": "4.1.9",
88
88
  "cspell": "10.0.1",
89
89
  "dependency-cruiser": "17.4.3",