@clipboard-health/groundcrew 3.1.2 → 3.1.3
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/README.md +31 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +12 -0
- package/dist/commands/cleaner.d.ts.map +1 -1
- package/dist/commands/cleaner.js +2 -0
- package/dist/commands/cleanupWorkspace.d.ts.map +1 -1
- package/dist/commands/cleanupWorkspace.js +2 -0
- package/dist/commands/interruptWorkspace.d.ts +8 -0
- package/dist/commands/interruptWorkspace.d.ts.map +1 -0
- package/dist/commands/interruptWorkspace.js +108 -0
- package/dist/commands/resumeWorkspace.d.ts +7 -0
- package/dist/commands/resumeWorkspace.d.ts.map +1 -0
- package/dist/commands/resumeWorkspace.js +163 -0
- package/dist/commands/setupWorkspace.d.ts.map +1 -1
- package/dist/commands/setupWorkspace.js +77 -79
- package/dist/commands/ticketDoctor.d.ts +18 -3
- package/dist/commands/ticketDoctor.d.ts.map +1 -1
- package/dist/commands/ticketDoctor.js +77 -8
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/lib/agentLaunch.d.ts +29 -0
- package/dist/lib/agentLaunch.d.ts.map +1 -0
- package/dist/lib/agentLaunch.js +53 -0
- package/dist/lib/runState.d.ts +46 -0
- package/dist/lib/runState.d.ts.map +1 -0
- package/dist/lib/runState.js +137 -0
- package/dist/lib/runStateCleanup.d.ts +4 -0
- package/dist/lib/runStateCleanup.d.ts.map +1 -0
- package/dist/lib/runStateCleanup.js +12 -0
- package/dist/lib/stagedLaunch.d.ts +32 -0
- package/dist/lib/stagedLaunch.d.ts.map +1 -0
- package/dist/lib/stagedLaunch.js +58 -0
- package/dist/lib/workspaces.d.ts +19 -1
- package/dist/lib/workspaces.d.ts.map +1 -1
- package/dist/lib/workspaces.js +29 -9
- package/dist/lib/worktrees.d.ts.map +1 -1
- package/dist/lib/worktrees.js +12 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -316,14 +316,16 @@ crew run # one-shot dispatch
|
|
|
316
316
|
crew run --watch # poll forever
|
|
317
317
|
crew run --ticket <TICKET> # provision one ticket and exit
|
|
318
318
|
crew setup repos [--dry-run] [<repo>...]
|
|
319
|
+
crew interrupt <TICKET> [--reason <text>] # stop the live workspace, keep the worktree
|
|
320
|
+
crew resume <TICKET> # reopen an existing ticket worktree
|
|
319
321
|
crew cleanup <TICKET> # tear down every worktree carrying this ticket
|
|
320
322
|
```
|
|
321
323
|
|
|
322
|
-
`crew doctor --ticket <TICKET>` covers the full per-ticket lifecycle: pre-dispatch eligibility (Todo status, `agent-*` label, model resolution, repository mention, local clone, blockers, model session usage, in-progress capacity) **and** post-dispatch local-state recovery (host worktree, workspace pane, local branch, remote branch, open PR). Verdict precedence
|
|
324
|
+
`crew doctor --ticket <TICKET>` covers the full per-ticket lifecycle: pre-dispatch eligibility (Todo status, `agent-*` label, model resolution, repository mention, local clone, blockers, model session usage, in-progress capacity) **and** post-dispatch local-state recovery (recorded run state, host worktree, workspace pane, local branch, remote branch, open PR). Verdict precedence starts with PR outcomes (`pr-open` > `pr-merged`). Recorded failed launches report before ordinary local recovery, interrupted runs report concrete recoverable git work first when it exists and otherwise report `interrupted`, and ordinary post-dispatch cases report `in-flight` before `recoverable`. If none of those apply, doctor falls through to `unresolvable` > `ineligible` > `would-dispatch` > `lost`. Exits 0 on `would-dispatch`, `pr-open`, or `pr-merged`; any other verdict exits 1. `--watch` and `--ticket` are mutually exclusive. To inspect codexbar session windows directly, run `codexbar usage`.
|
|
323
325
|
|
|
324
326
|
### `crew doctor --ticket <ticket>`
|
|
325
327
|
|
|
326
|
-
Diagnose where a ticket is in its lifecycle and what to do next. Runs the same resolution and eligibility chain as the dispatcher, plus probes
|
|
328
|
+
Diagnose where a ticket is in its lifecycle and what to do next. Runs the same resolution and eligibility chain as the dispatcher, plus probes recorded run state, host worktree, workspace pane, local branch, remote branch, and PR; prints a single verdict with a copy-pasteable recovery step when one applies.
|
|
327
329
|
|
|
328
330
|
Flags:
|
|
329
331
|
|
|
@@ -344,6 +346,13 @@ Resolution
|
|
|
344
346
|
Eligibility
|
|
345
347
|
(skipped — post-dispatch — pre-dispatch checks are irrelevant)
|
|
346
348
|
|
|
349
|
+
Run state
|
|
350
|
+
[ok] Local run state (running)
|
|
351
|
+
[ok] Recorded model (claude)
|
|
352
|
+
[ok] Recorded worktree (/Users/paul/dev/groundcrew-workspaces/herds-social/herds-hrd-442)
|
|
353
|
+
[ok] Recorded branch (paul-hrd-442)
|
|
354
|
+
[ok] Resume count (0)
|
|
355
|
+
|
|
347
356
|
Worktree
|
|
348
357
|
[ok] Host worktree exists (/Users/paul/dev/groundcrew-workspaces/herds-social/herds-hrd-442)
|
|
349
358
|
[--] Working tree clean (0 modified, 1 untracked)
|
|
@@ -374,11 +383,31 @@ The verdict on the last line maps to a recovery action:
|
|
|
374
383
|
| `pr-merged` | Done. |
|
|
375
384
|
| `in-flight` | The ticket is still being worked on; the verdict line names the workspace pane to attach to. |
|
|
376
385
|
| `recoverable` | Run the printed `nextStep` exactly. |
|
|
386
|
+
| `interrupted` | Resume the preserved worktree with `crew resume <ticket>` or inspect it by hand. |
|
|
387
|
+
| `failed-launch` | Fix the launch failure, then run `crew resume <ticket>` or `crew cleanup <ticket>`. |
|
|
377
388
|
| `would-dispatch` | Pre-dispatch checks pass; the orchestrator will pick the ticket up on its next tick. |
|
|
378
389
|
| `ineligible` | A resolution or eligibility check failed; the reason after the colon names the failing check. |
|
|
379
390
|
| `unresolvable` | The Linear ticket couldn't be fetched; the reason after the colon names the error. |
|
|
380
391
|
| `lost` | No trace exists. Re-dispatch via `crew run --ticket <ticket>`. |
|
|
381
392
|
|
|
393
|
+
### `crew interrupt <ticket>`
|
|
394
|
+
|
|
395
|
+
Stop a live workspace pane while preserving the ticket worktree and branch. This is the manual pause button for cases where you need terminal capacity back, want to stop an agent that is going in the wrong direction, or need to inspect the diff before letting another agent continue.
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
crew interrupt HRD-442 --reason "wrong implementation direction"
|
|
399
|
+
crew doctor --ticket HRD-442
|
|
400
|
+
crew resume HRD-442
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
The command closes the cmux/tmux workspace when it exists, records local run state under the groundcrew state directory, and never tears down the worktree. If the workspace was already gone but the worktree is still present, interrupt records that fact so doctor can point at the preserved branch instead of reporting a mystery ticket.
|
|
404
|
+
|
|
405
|
+
### `crew resume <ticket>`
|
|
406
|
+
|
|
407
|
+
Reopen an existing ticket worktree with a continuation prompt. Resume never creates a new worktree; if none exists, it fails and leaves re-dispatch to `crew run --ticket <ticket>`.
|
|
408
|
+
|
|
409
|
+
The resume prompt tells the agent to inspect current git status and diff before editing, includes the previous interrupt reason when recorded, and reuses the recorded model, repository, branch, runner, sandbox, and workspace backend. When no run-state file exists but a worktree does, resume falls back to Linear resolution for the model and ticket context.
|
|
410
|
+
|
|
382
411
|
## Troubleshooting
|
|
383
412
|
|
|
384
413
|
First stop for "labeled but not on the board": `crew doctor --ticket <ticket>` lists every check the dispatcher runs and flags the failing one.
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AA6JA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvD"}
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { cleanupWorkspaceCli } from "./commands/cleanupWorkspace.js";
|
|
3
3
|
import { doctor } from "./commands/doctor.js";
|
|
4
|
+
import { interruptWorkspaceCli } from "./commands/interruptWorkspace.js";
|
|
4
5
|
import { orchestrate } from "./commands/orchestrator.js";
|
|
6
|
+
import { resumeWorkspaceCli } from "./commands/resumeWorkspace.js";
|
|
5
7
|
import { setupReposCli } from "./commands/setupRepos.js";
|
|
6
8
|
import { setupWorkspaceCli } from "./commands/setupWorkspace.js";
|
|
7
9
|
import { errorMessage, readTicketArgument, writeError, writeOutput } from "./lib/util.js";
|
|
@@ -90,6 +92,16 @@ const SUBCOMMANDS = {
|
|
|
90
92
|
usage: "[--force] <ticket>",
|
|
91
93
|
invoke: cleanupWorkspaceCli,
|
|
92
94
|
},
|
|
95
|
+
interrupt: {
|
|
96
|
+
summary: "Stop a live ticket workspace while preserving its worktree",
|
|
97
|
+
usage: "<ticket> [--reason <text>]",
|
|
98
|
+
invoke: interruptWorkspaceCli,
|
|
99
|
+
},
|
|
100
|
+
resume: {
|
|
101
|
+
summary: "Reopen an existing ticket worktree with a continuation prompt",
|
|
102
|
+
usage: "<ticket>",
|
|
103
|
+
invoke: resumeWorkspaceCli,
|
|
104
|
+
},
|
|
93
105
|
setup: {
|
|
94
106
|
summary: "Project-level setup commands (currently: repos)",
|
|
95
107
|
usage: "repos [--dry-run] [<repo>...]",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cleaner.d.ts","sourceRoot":"","sources":["../../src/commands/cleaner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,uBAAuB,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"cleaner.d.ts","sourceRoot":"","sources":["../../src/commands/cleaner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,uBAAuB,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGvD,OAAO,EAAE,KAAK,aAAa,EAAa,MAAM,qBAAqB,CAAC;AAGpE,UAAU,WAAW;IACnB,MAAM,EAAE,cAAc,CAAC;CACxB;AAED,MAAM,WAAW,OAAO;IACtB,OAAO,CAAC,UAAU,EAAE;QAClB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;KACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAsDxD"}
|
package/dist/commands/cleaner.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* invocation; stateless across iterations. Mirrors `Dispatcher`.
|
|
5
5
|
*/
|
|
6
6
|
import { isTerminalStatus } from "../lib/boardSource.js";
|
|
7
|
+
import { recordCleanedUpRuns } from "../lib/runStateCleanup.js";
|
|
7
8
|
import { log, logEvent } from "../lib/util.js";
|
|
8
9
|
import { worktrees } from "../lib/worktrees.js";
|
|
9
10
|
import { logTeardown, recordTeardownEvents } from "./teardownReporter.js";
|
|
@@ -41,6 +42,7 @@ export function createCleaner(deps) {
|
|
|
41
42
|
const result = signal === undefined
|
|
42
43
|
? await worktrees.teardown(config, stale)
|
|
43
44
|
: await worktrees.teardown(config, stale, { signal });
|
|
45
|
+
recordCleanedUpRuns(config, result.removed);
|
|
44
46
|
logTeardown(result);
|
|
45
47
|
recordTeardownEvents(result);
|
|
46
48
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cleanupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/cleanupWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"cleanupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/cleanupWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAMnE,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAwBD,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAef;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAIvE"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from "../lib/config.js";
|
|
2
|
+
import { recordCleanedUpRuns } from "../lib/runStateCleanup.js";
|
|
2
3
|
import { log } from "../lib/util.js";
|
|
3
4
|
import { worktrees } from "../lib/worktrees.js";
|
|
4
5
|
import { logTeardown } from "./teardownReporter.js";
|
|
@@ -29,6 +30,7 @@ export async function cleanupWorkspace(config, options) {
|
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
31
32
|
const result = await worktrees.teardown(config, entries, { force });
|
|
33
|
+
recordCleanedUpRuns(config, result.removed);
|
|
32
34
|
logTeardown(result);
|
|
33
35
|
if (result.failures.length > 0) {
|
|
34
36
|
throw result.failures[0]?.error;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ResolvedConfig } from "../lib/config.ts";
|
|
2
|
+
export interface InterruptWorkspaceOptions {
|
|
3
|
+
ticket: string;
|
|
4
|
+
reason?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function interruptWorkspace(config: ResolvedConfig, options: InterruptWorkspaceOptions): Promise<void>;
|
|
7
|
+
export declare function interruptWorkspaceCli(argv: string[]): Promise<void>;
|
|
8
|
+
//# sourceMappingURL=interruptWorkspace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interruptWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/interruptWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAMnE,MAAM,WAAW,yBAAyB;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAuGD,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,yBAAyB,GACjC,OAAO,CAAC,IAAI,CAAC,CAyBf;AAED,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGzE"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { loadConfig } from "../lib/config.js";
|
|
2
|
+
import { readRunState, recordRunState } from "../lib/runState.js";
|
|
3
|
+
import { errorMessage, log } from "../lib/util.js";
|
|
4
|
+
import { workspaces } from "../lib/workspaces.js";
|
|
5
|
+
import { worktrees } from "../lib/worktrees.js";
|
|
6
|
+
function parseArguments(argv) {
|
|
7
|
+
let reason;
|
|
8
|
+
const positionals = [];
|
|
9
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
10
|
+
const argument = argv[index];
|
|
11
|
+
/* v8 ignore next @preserve -- loop bounds ensure argv[index] exists; guard satisfies noUncheckedIndexedAccess */
|
|
12
|
+
if (argument === undefined) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (argument === "--reason") {
|
|
16
|
+
const value = argv[index + 1];
|
|
17
|
+
if (value === undefined || value.length === 0 || value.startsWith("-")) {
|
|
18
|
+
throw new Error("crew interrupt --reason: reason text is required");
|
|
19
|
+
}
|
|
20
|
+
reason = value;
|
|
21
|
+
index += 1;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (argument.startsWith("-")) {
|
|
25
|
+
throw new Error(`Unknown option: ${argument}\nUsage: crew interrupt <ticket> [--reason <text>]`);
|
|
26
|
+
}
|
|
27
|
+
positionals.push(argument);
|
|
28
|
+
}
|
|
29
|
+
const [ticket, ...extras] = positionals;
|
|
30
|
+
if (ticket === undefined || ticket.length === 0 || extras.length > 0) {
|
|
31
|
+
throw new Error("Usage: crew interrupt <ticket> [--reason <text>]");
|
|
32
|
+
}
|
|
33
|
+
return { ticket: ticket.toLowerCase(), ...(reason === undefined ? {} : { reason }) };
|
|
34
|
+
}
|
|
35
|
+
function sourceFromState(state) {
|
|
36
|
+
return {
|
|
37
|
+
ticket: state.ticket,
|
|
38
|
+
repository: state.repository,
|
|
39
|
+
model: state.model,
|
|
40
|
+
worktreeDir: state.worktreeDir,
|
|
41
|
+
branchName: state.branchName,
|
|
42
|
+
workspaceName: state.workspaceName,
|
|
43
|
+
resumeCount: state.resumeCount,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function sourceFromWorktree(config, ticket, entry) {
|
|
47
|
+
return {
|
|
48
|
+
ticket,
|
|
49
|
+
repository: entry.repository,
|
|
50
|
+
model: config.models.default,
|
|
51
|
+
worktreeDir: entry.dir,
|
|
52
|
+
branchName: entry.branchName,
|
|
53
|
+
workspaceName: ticket,
|
|
54
|
+
resumeCount: 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function resolveInterruptSource(arguments_) {
|
|
58
|
+
if (arguments_.state !== undefined) {
|
|
59
|
+
return sourceFromState(arguments_.state);
|
|
60
|
+
}
|
|
61
|
+
if (arguments_.entry !== undefined) {
|
|
62
|
+
return sourceFromWorktree(arguments_.config, arguments_.ticket, arguments_.entry);
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`No run state or worktree found for ${arguments_.ticket}; nothing to interrupt.`);
|
|
65
|
+
}
|
|
66
|
+
function interruptDetail(result) {
|
|
67
|
+
if (result.kind === "missing") {
|
|
68
|
+
return "workspace missing";
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
function failOnUnavailable(result) {
|
|
73
|
+
if (result.kind !== "unavailable") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const detail = result.error === undefined ? "workspace adapter unavailable" : errorMessage(result.error);
|
|
77
|
+
throw new Error(`Could not interrupt workspace: ${detail}`);
|
|
78
|
+
}
|
|
79
|
+
export async function interruptWorkspace(config, options) {
|
|
80
|
+
const ticket = options.ticket.toLowerCase();
|
|
81
|
+
const state = readRunState(config, ticket);
|
|
82
|
+
const [entry] = worktrees.findByTicket(config, ticket);
|
|
83
|
+
const source = resolveInterruptSource({ config, ticket, state, entry });
|
|
84
|
+
const result = await workspaces.interrupt(config, source.workspaceName);
|
|
85
|
+
failOnUnavailable(result);
|
|
86
|
+
const detail = interruptDetail(result);
|
|
87
|
+
recordRunState({
|
|
88
|
+
config,
|
|
89
|
+
state: {
|
|
90
|
+
ticket,
|
|
91
|
+
repository: source.repository,
|
|
92
|
+
model: source.model,
|
|
93
|
+
worktreeDir: source.worktreeDir,
|
|
94
|
+
branchName: source.branchName,
|
|
95
|
+
workspaceName: source.workspaceName,
|
|
96
|
+
state: "interrupted",
|
|
97
|
+
resumeCount: source.resumeCount,
|
|
98
|
+
...(options.reason === undefined ? {} : { reason: options.reason }),
|
|
99
|
+
...(detail === undefined ? {} : { detail }),
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
log(`Interrupted ${ticket}; worktree preserved at ${source.worktreeDir}`);
|
|
103
|
+
log(`Next: crew doctor --ticket ${ticket}`);
|
|
104
|
+
}
|
|
105
|
+
export async function interruptWorkspaceCli(argv) {
|
|
106
|
+
const config = await loadConfig();
|
|
107
|
+
await interruptWorkspace(config, parseArguments(argv));
|
|
108
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ResolvedConfig } from "../lib/config.ts";
|
|
2
|
+
export interface ResumeWorkspaceOptions {
|
|
3
|
+
ticket: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function resumeWorkspace(config: ResolvedConfig, options: ResumeWorkspaceOptions): Promise<void>;
|
|
6
|
+
export declare function resumeWorkspaceCli(argv: string[]): Promise<void>;
|
|
7
|
+
//# sourceMappingURL=resumeWorkspace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resumeWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/resumeWorkspace.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAcnE,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;CAChB;AA6HD,wBAAsB,eAAe,CACnC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,IAAI,CAAC,CA6Df;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGtE"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { fetchResolvedIssue } from "../lib/boardSource.js";
|
|
2
|
+
import { loadConfig } from "../lib/config.js";
|
|
3
|
+
import { ensureAgentSandbox, openAgentWorkspace, prepareAgentLaunch } from "../lib/agentLaunch.js";
|
|
4
|
+
import { buildLaunchCommand } from "../lib/launchCommand.js";
|
|
5
|
+
import { readRunState, recordRunState } from "../lib/runState.js";
|
|
6
|
+
import { removeStagedPrompt, stageBuildSecrets, stagePromptText, stageWorkspaceLaunchCommand, } from "../lib/stagedLaunch.js";
|
|
7
|
+
import { errorMessage, getLinearClient, log } from "../lib/util.js";
|
|
8
|
+
import { workspaces } from "../lib/workspaces.js";
|
|
9
|
+
import { worktrees } from "../lib/worktrees.js";
|
|
10
|
+
function parseArguments(argv) {
|
|
11
|
+
const [ticket, ...extras] = argv;
|
|
12
|
+
if (ticket === undefined || ticket.length === 0 || extras.length > 0 || ticket.startsWith("-")) {
|
|
13
|
+
throw new Error("Usage: crew resume <ticket>");
|
|
14
|
+
}
|
|
15
|
+
return { ticket: ticket.toLowerCase() };
|
|
16
|
+
}
|
|
17
|
+
async function fetchTicketDetails(ticket) {
|
|
18
|
+
try {
|
|
19
|
+
const issue = await getLinearClient().issue(ticket.toUpperCase());
|
|
20
|
+
return {
|
|
21
|
+
title: issue.title,
|
|
22
|
+
description: issue.description ?? "",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
log(`Resume Linear detail lookup failed for ${ticket}: ${errorMessage(error)}`);
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function contextFromLinear(config, ticket, worktree) {
|
|
31
|
+
const resolved = await fetchResolvedIssue({ client: getLinearClient(), config, ticket });
|
|
32
|
+
return {
|
|
33
|
+
ticket,
|
|
34
|
+
repository: resolved.repository,
|
|
35
|
+
model: resolved.model,
|
|
36
|
+
worktree,
|
|
37
|
+
title: resolved.title,
|
|
38
|
+
description: resolved.description,
|
|
39
|
+
resumeCount: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function contextFromState(ticket, state, worktree) {
|
|
43
|
+
const details = await fetchTicketDetails(ticket);
|
|
44
|
+
return {
|
|
45
|
+
ticket,
|
|
46
|
+
repository: state.repository,
|
|
47
|
+
model: state.model,
|
|
48
|
+
worktree,
|
|
49
|
+
title: details?.title ?? ticket.toUpperCase(),
|
|
50
|
+
description: details?.description ?? "",
|
|
51
|
+
...(state.reason === undefined ? {} : { reason: state.reason }),
|
|
52
|
+
resumeCount: state.resumeCount,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function buildResumeContext(config, ticket) {
|
|
56
|
+
const state = readRunState(config, ticket);
|
|
57
|
+
const entries = worktrees.findByTicket(config, ticket);
|
|
58
|
+
const worktree = state === undefined
|
|
59
|
+
? entries[0]
|
|
60
|
+
: (entries.find((entry) => entry.repository === state.repository) ?? entries[0]);
|
|
61
|
+
if (worktree === undefined) {
|
|
62
|
+
throw new Error(`No worktree found for ${ticket}; cannot resume.`);
|
|
63
|
+
}
|
|
64
|
+
if (state !== undefined) {
|
|
65
|
+
return await contextFromState(ticket, state, worktree);
|
|
66
|
+
}
|
|
67
|
+
return await contextFromLinear(config, ticket, worktree);
|
|
68
|
+
}
|
|
69
|
+
function renderResumePrompt(context) {
|
|
70
|
+
return [
|
|
71
|
+
`You are resuming Groundcrew ticket ${context.ticket} (${context.title}) in an existing worktree.`,
|
|
72
|
+
"",
|
|
73
|
+
"Ticket description:",
|
|
74
|
+
"",
|
|
75
|
+
context.description,
|
|
76
|
+
"",
|
|
77
|
+
"## Continuation context",
|
|
78
|
+
"",
|
|
79
|
+
`- Worktree: ${context.worktree.dir}`,
|
|
80
|
+
`- Branch: ${context.worktree.branchName}`,
|
|
81
|
+
context.reason === undefined
|
|
82
|
+
? "- Previous interrupt reason: none recorded"
|
|
83
|
+
: `- Previous interrupt reason: ${context.reason}`,
|
|
84
|
+
"",
|
|
85
|
+
"Before editing, inspect the current git status and diff. Continue from the work already present in this worktree; do not restart from scratch unless the diff proves that is necessary.",
|
|
86
|
+
"",
|
|
87
|
+
"Run the repository's documented verification before stopping, then leave the branch ready or open a PR when possible.",
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
90
|
+
async function failIfWorkspaceAlreadyLive(config, ticket) {
|
|
91
|
+
const probe = await workspaces.probe(config);
|
|
92
|
+
if (probe.kind === "unavailable") {
|
|
93
|
+
const detail = probe.error === undefined ? "" : `: ${errorMessage(probe.error)}`;
|
|
94
|
+
throw new Error(`Could not verify whether workspace for ${ticket} is already live${detail}. Retry or inspect the workspace backend manually before resuming.`);
|
|
95
|
+
}
|
|
96
|
+
if (probe.names.has(ticket)) {
|
|
97
|
+
throw new Error(`Workspace for ${ticket} is already live; attach to it instead of resuming.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function resumeWorkspace(config, options) {
|
|
101
|
+
const ticket = options.ticket.toLowerCase();
|
|
102
|
+
await failIfWorkspaceAlreadyLive(config, ticket);
|
|
103
|
+
const context = await buildResumeContext(config, ticket);
|
|
104
|
+
const definition = config.models.definitions[context.model];
|
|
105
|
+
if (definition === undefined) {
|
|
106
|
+
throw new Error(`Unknown model: ${context.model}`);
|
|
107
|
+
}
|
|
108
|
+
const { runner, sandboxName } = await prepareAgentLaunch({
|
|
109
|
+
config,
|
|
110
|
+
model: context.model,
|
|
111
|
+
definition,
|
|
112
|
+
purpose: "resumes",
|
|
113
|
+
});
|
|
114
|
+
const stagedPrompt = stagePromptText({
|
|
115
|
+
prefix: "groundcrew-resume",
|
|
116
|
+
ticket,
|
|
117
|
+
text: renderResumePrompt(context),
|
|
118
|
+
});
|
|
119
|
+
const secretsFile = stageBuildSecrets(stagedPrompt.directory);
|
|
120
|
+
await ensureAgentSandbox({ config, definition, sandboxName });
|
|
121
|
+
const launchCommand = buildLaunchCommand({
|
|
122
|
+
definition,
|
|
123
|
+
promptFile: stagedPrompt.file,
|
|
124
|
+
worktreeDir: context.worktree.dir,
|
|
125
|
+
secretsFile,
|
|
126
|
+
runner,
|
|
127
|
+
sandboxName,
|
|
128
|
+
});
|
|
129
|
+
const launchCmd = stageWorkspaceLaunchCommand(stagedPrompt.directory, launchCommand);
|
|
130
|
+
try {
|
|
131
|
+
await openAgentWorkspace({
|
|
132
|
+
config,
|
|
133
|
+
name: ticket,
|
|
134
|
+
cwd: context.worktree.dir,
|
|
135
|
+
command: launchCmd,
|
|
136
|
+
model: context.model,
|
|
137
|
+
color: definition.color,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
removeStagedPrompt(stagedPrompt.directory);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
recordRunState({
|
|
145
|
+
config,
|
|
146
|
+
state: {
|
|
147
|
+
ticket,
|
|
148
|
+
repository: context.repository,
|
|
149
|
+
model: context.model,
|
|
150
|
+
worktreeDir: context.worktree.dir,
|
|
151
|
+
branchName: context.worktree.branchName,
|
|
152
|
+
workspaceName: ticket,
|
|
153
|
+
state: "resumed",
|
|
154
|
+
resumeCount: context.resumeCount + 1,
|
|
155
|
+
...(context.reason === undefined ? {} : { reason: context.reason }),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
log(`Resumed ${ticket} in ${context.worktree.dir} (${context.model})`);
|
|
159
|
+
}
|
|
160
|
+
export async function resumeWorkspaceCli(argv) {
|
|
161
|
+
const config = await loadConfig();
|
|
162
|
+
await resumeWorkspace(config, parseArguments(argv));
|
|
163
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAenE,UAAU,aAAa;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAWD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAqBD,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CA6Gf;AAwHD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAoBf"}
|
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
4
|
-
import { ensureClearance } from "@clipboard-health/clearance";
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
5
2
|
import { fetchResolvedIssue } from "../lib/boardSource.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { buildLaunchCommand, shellSingleQuote } from "../lib/launchCommand.js";
|
|
3
|
+
import { loadConfig } from "../lib/config.js";
|
|
4
|
+
import { ensureAgentSandbox, openAgentWorkspace, prepareAgentLaunch } from "../lib/agentLaunch.js";
|
|
5
|
+
import { buildLaunchCommand } from "../lib/launchCommand.js";
|
|
10
6
|
import { createLinearIssueStatusUpdater } from "../lib/linearIssueStatus.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
7
|
+
import { recordRunState } from "../lib/runState.js";
|
|
8
|
+
import { stageBuildSecrets, stagePromptFromTemplate, stageWorkspaceLaunchCommand, } from "../lib/stagedLaunch.js";
|
|
9
|
+
import { errorMessage, getLinearClient, log } from "../lib/util.js";
|
|
13
10
|
import { workspaces } from "../lib/workspaces.js";
|
|
14
11
|
import { isWorktreeAlreadyExistsError, worktrees } from "../lib/worktrees.js";
|
|
15
12
|
async function fetchTicket(ticket) {
|
|
@@ -20,54 +17,18 @@ async function fetchTicket(ticket) {
|
|
|
20
17
|
description: issue.description ?? "",
|
|
21
18
|
};
|
|
22
19
|
}
|
|
23
|
-
function renderPrompt(template, variables) {
|
|
24
|
-
return template
|
|
25
|
-
.replaceAll("{{ticket}}", variables.ticket)
|
|
26
|
-
.replaceAll("{{worktree}}", variables.worktree)
|
|
27
|
-
.replaceAll("{{title}}", variables.title)
|
|
28
|
-
.replaceAll("{{description}}", variables.description);
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Stage a `KEY='value'` env file for any populated build-time secret so
|
|
32
|
-
* the launch command can source it. Returns `undefined` when groundcrew
|
|
33
|
-
* has nothing to forward, leaving the launch command unchanged. The temp
|
|
34
|
-
* dir is `rm -rf`'d by the launch command (and rollback path), so cleanup
|
|
35
|
-
* is already handled.
|
|
36
|
-
*/
|
|
37
|
-
function stageBuildSecrets(promptDir) {
|
|
38
|
-
const lines = [];
|
|
39
|
-
for (const name of BUILD_SECRET_NAMES) {
|
|
40
|
-
const value = readEnvironmentVariable(name);
|
|
41
|
-
if (value === undefined || value.length === 0) {
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
lines.push(`${name}=${shellSingleQuote(value)}`);
|
|
45
|
-
}
|
|
46
|
-
if (lines.length === 0) {
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
const secretsFile = join(promptDir, "secrets.env");
|
|
50
|
-
writeFileSync(secretsFile, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
51
|
-
return secretsFile;
|
|
52
|
-
}
|
|
53
|
-
function stageLaunchScript(promptDir, command) {
|
|
54
|
-
const launcherFile = join(promptDir, "launch.sh");
|
|
55
|
-
writeFileSync(launcherFile, `#!/usr/bin/env bash\n${command}\n`, { mode: 0o700 });
|
|
56
|
-
return launcherFile;
|
|
57
|
-
}
|
|
58
|
-
function stageWorkspaceLaunchCommand(promptDir, command) {
|
|
59
|
-
return `bash ${shellSingleQuote(stageLaunchScript(promptDir, command))}`;
|
|
60
|
-
}
|
|
61
20
|
function stagePrompt(input) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
21
|
+
return stagePromptFromTemplate({
|
|
22
|
+
config: input.config,
|
|
23
|
+
prefix: "groundcrew",
|
|
65
24
|
ticket: input.ticket,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
25
|
+
variables: {
|
|
26
|
+
ticket: input.ticket,
|
|
27
|
+
worktree: input.worktreeName,
|
|
28
|
+
title: input.ticketDetails.title,
|
|
29
|
+
description: input.ticketDetails.description,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
71
32
|
}
|
|
72
33
|
export async function setupWorkspace(config, options, runOptions = {}) {
|
|
73
34
|
const { ticket, repository, model } = options;
|
|
@@ -76,16 +37,13 @@ export async function setupWorkspace(config, options, runOptions = {}) {
|
|
|
76
37
|
if (!definition) {
|
|
77
38
|
throw new Error(`Unknown model: ${model}`);
|
|
78
39
|
}
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
throw new Error(`Local groundcrew runs with the sdx runner require a sandbox config on model '${model}'. ` +
|
|
87
|
-
"Add `sandbox: { agent: '<sbx-agent-name>' }` to the model in your config.ts.");
|
|
88
|
-
}
|
|
40
|
+
const { runner, sandboxName } = await prepareAgentLaunch({
|
|
41
|
+
config,
|
|
42
|
+
model,
|
|
43
|
+
definition,
|
|
44
|
+
purpose: "runs",
|
|
45
|
+
...(signal === undefined ? {} : { signal }),
|
|
46
|
+
});
|
|
89
47
|
const spec = { repository, ticket };
|
|
90
48
|
let created;
|
|
91
49
|
try {
|
|
@@ -122,16 +80,12 @@ export async function setupWorkspace(config, options, runOptions = {}) {
|
|
|
122
80
|
const stagedPrompt = stagePrompt({ config, ticket, ticketDetails, worktreeName });
|
|
123
81
|
promptDir = stagedPrompt.directory;
|
|
124
82
|
const secretsFile = stageBuildSecrets(promptDir);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
sandbox: definition.sandbox,
|
|
132
|
-
mountPath: resolve(config.workspace.projectDir),
|
|
133
|
-
}, signal);
|
|
134
|
-
}
|
|
83
|
+
await ensureAgentSandbox({
|
|
84
|
+
config,
|
|
85
|
+
definition,
|
|
86
|
+
sandboxName,
|
|
87
|
+
...(signal === undefined ? {} : { signal }),
|
|
88
|
+
});
|
|
135
89
|
const launchCommand = buildLaunchCommand({
|
|
136
90
|
definition,
|
|
137
91
|
promptFile: stagedPrompt.file,
|
|
@@ -142,12 +96,25 @@ export async function setupWorkspace(config, options, runOptions = {}) {
|
|
|
142
96
|
});
|
|
143
97
|
const launchCmd = stageWorkspaceLaunchCommand(promptDir, launchCommand);
|
|
144
98
|
log("Opening workspace...");
|
|
145
|
-
await
|
|
99
|
+
await openAgentWorkspace({
|
|
100
|
+
config,
|
|
146
101
|
name: ticket,
|
|
147
102
|
cwd: launchDir,
|
|
148
103
|
command: launchCmd,
|
|
149
|
-
|
|
150
|
-
|
|
104
|
+
model,
|
|
105
|
+
color: definition.color,
|
|
106
|
+
...(signal === undefined ? {} : { signal }),
|
|
107
|
+
});
|
|
108
|
+
recordRunStateBestEffort({
|
|
109
|
+
config,
|
|
110
|
+
ticket,
|
|
111
|
+
repository,
|
|
112
|
+
model,
|
|
113
|
+
worktreeDir: launchDir,
|
|
114
|
+
branchName,
|
|
115
|
+
workspaceName: ticket,
|
|
116
|
+
state: "running",
|
|
117
|
+
});
|
|
151
118
|
log(`Workspace "${ticket}" launched (${model})`);
|
|
152
119
|
log(` Worktree: ${launchDir}`);
|
|
153
120
|
log(` Branch: ${branchName}`);
|
|
@@ -155,6 +122,17 @@ export async function setupWorkspace(config, options, runOptions = {}) {
|
|
|
155
122
|
}
|
|
156
123
|
catch (error) {
|
|
157
124
|
await rollbackWorktree({ config, entry: created, promptDir });
|
|
125
|
+
recordRunStateBestEffort({
|
|
126
|
+
config,
|
|
127
|
+
ticket,
|
|
128
|
+
repository,
|
|
129
|
+
model,
|
|
130
|
+
worktreeDir: launchDir,
|
|
131
|
+
branchName,
|
|
132
|
+
workspaceName: ticket,
|
|
133
|
+
state: "failed-to-launch",
|
|
134
|
+
detail: errorMessage(error),
|
|
135
|
+
});
|
|
158
136
|
throw error;
|
|
159
137
|
}
|
|
160
138
|
}
|
|
@@ -188,6 +166,26 @@ async function logWorkspaceAccessHint(arguments_) {
|
|
|
188
166
|
function logAccessHint(accessHint) {
|
|
189
167
|
log(` Attach: ${accessHint.command}`);
|
|
190
168
|
}
|
|
169
|
+
function recordRunStateBestEffort(arguments_) {
|
|
170
|
+
try {
|
|
171
|
+
recordRunState({
|
|
172
|
+
config: arguments_.config,
|
|
173
|
+
state: {
|
|
174
|
+
ticket: arguments_.ticket,
|
|
175
|
+
repository: arguments_.repository,
|
|
176
|
+
model: arguments_.model,
|
|
177
|
+
worktreeDir: arguments_.worktreeDir,
|
|
178
|
+
branchName: arguments_.branchName,
|
|
179
|
+
workspaceName: arguments_.workspaceName,
|
|
180
|
+
state: arguments_.state,
|
|
181
|
+
...(arguments_.detail === undefined ? {} : { detail: arguments_.detail }),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
log(`Run state update failed for ${arguments_.ticket}: ${errorMessage(error)}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
191
189
|
async function rollbackWorktree(arguments_) {
|
|
192
190
|
log(`Setup failed; rolling back worktree ${arguments_.entry.repository}-${arguments_.entry.ticket}...`);
|
|
193
191
|
let result;
|