agent-dealer 0.2.0 → 1.0.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/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
- package/bundle/server/dist/adapters/agent-deck.js +38 -50
- package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
- package/bundle/server/dist/adapters/agent-health.js +230 -33
- package/bundle/server/dist/adapters/agent-health.test.js +237 -0
- package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
- package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
- package/bundle/server/dist/adapters/external.js +0 -1
- package/bundle/server/dist/adapters/git-worktree.js +514 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
- package/bundle/server/dist/adapters/github.js +170 -0
- package/bundle/server/dist/adapters/github.test.js +205 -0
- package/bundle/server/dist/adapters/linear-inbox.js +199 -30
- package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
- package/bundle/server/dist/adapters/linear-sync.js +3 -101
- package/bundle/server/dist/adapters/managed-repo.js +168 -0
- package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
- package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
- package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
- package/bundle/server/dist/adapters/reflect-authority.js +43 -0
- package/bundle/server/dist/cli-env.js +26 -0
- package/bundle/server/dist/cli-env.test.js +14 -1
- package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
- package/bundle/server/dist/coordinator/admission.js +330 -0
- package/bundle/server/dist/coordinator/admission.test.js +338 -0
- package/bundle/server/dist/coordinator/args.js +128 -0
- package/bundle/server/dist/coordinator/args.test.js +132 -0
- package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/auto-merge.js +286 -0
- package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
- package/bundle/server/dist/coordinator/branch-progress.js +81 -0
- package/bundle/server/dist/coordinator/clock-jump.js +98 -0
- package/bundle/server/dist/coordinator/commands.js +1088 -0
- package/bundle/server/dist/coordinator/commands.test.js +554 -0
- package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
- package/bundle/server/dist/coordinator/dependencies.js +242 -0
- package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
- package/bundle/server/dist/coordinator/developer-effect.js +959 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
- package/bundle/server/dist/coordinator/dispatcher.js +58 -0
- package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
- package/bundle/server/dist/coordinator/effect-registry.js +17 -0
- package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
- package/bundle/server/dist/coordinator/failure-reason.js +273 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
- package/bundle/server/dist/coordinator/guidance.js +33 -0
- package/bundle/server/dist/coordinator/guidance.test.js +110 -0
- package/bundle/server/dist/coordinator/human-resolution.js +88 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
- package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
- package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
- package/bundle/server/dist/coordinator/latest-failure.js +100 -0
- package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
- package/bundle/server/dist/coordinator/metrics.js +35 -0
- package/bundle/server/dist/coordinator/metrics.test.js +52 -0
- package/bundle/server/dist/coordinator/permissions.js +127 -0
- package/bundle/server/dist/coordinator/permissions.test.js +184 -0
- package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
- package/bundle/server/dist/coordinator/process-liveness.js +191 -0
- package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
- package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
- package/bundle/server/dist/coordinator/projection.js +147 -0
- package/bundle/server/dist/coordinator/projection.test.js +121 -0
- package/bundle/server/dist/coordinator/prompts.js +164 -0
- package/bundle/server/dist/coordinator/prompts.test.js +177 -0
- package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
- package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
- package/bundle/server/dist/coordinator/recovery.js +466 -0
- package/bundle/server/dist/coordinator/recovery.test.js +164 -0
- package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
- package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
- package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
- package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
- package/bundle/server/dist/coordinator/routing.js +160 -0
- package/bundle/server/dist/coordinator/routing.test.js +336 -0
- package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
- package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
- package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
- package/bundle/server/dist/coordinator/session-progress.js +351 -0
- package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
- package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
- package/bundle/server/dist/coordinator/spawn.js +49 -0
- package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
- package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
- package/bundle/server/dist/coordinator/usage.js +40 -0
- package/bundle/server/dist/coordinator/usage.test.js +69 -0
- package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
- package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
- package/bundle/server/dist/coordinator/worker-loop.js +363 -0
- package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
- package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
- package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
- package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
- package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
- package/bundle/server/dist/db/index.js +359 -7
- package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
- package/bundle/server/dist/db/migrate-to-issues.js +824 -0
- package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
- package/bundle/server/dist/db/schema.sql +311 -1
- package/bundle/server/dist/db/schema.test.js +66 -0
- package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
- package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
- package/bundle/server/dist/index.js +58 -5
- package/bundle/server/dist/paths.js +40 -4
- package/bundle/server/dist/paths.test.js +42 -0
- package/bundle/server/dist/pid-marker-claim-child.js +18 -0
- package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
- package/bundle/server/dist/pid-marker.js +141 -0
- package/bundle/server/dist/pid-marker.test.js +208 -0
- package/bundle/server/dist/queue/approve-deliver.js +128 -28
- package/bundle/server/dist/queue/reflect-schedule.js +25 -0
- package/bundle/server/dist/queue/result-qa.js +1 -1
- package/bundle/server/dist/queue/send-gate.test.js +172 -36
- package/bundle/server/dist/repository/agents.js +55 -18
- package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
- package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
- package/bundle/server/dist/repository/artifacts.js +47 -0
- package/bundle/server/dist/repository/artifacts.test.js +48 -0
- package/bundle/server/dist/repository/authority-attempts.js +189 -0
- package/bundle/server/dist/repository/findings.js +70 -0
- package/bundle/server/dist/repository/findings.test.js +68 -0
- package/bundle/server/dist/repository/human-actions.js +142 -0
- package/bundle/server/dist/repository/human-actions.test.js +123 -0
- package/bundle/server/dist/repository/intake-settings.js +5 -59
- package/bundle/server/dist/repository/issues.js +305 -0
- package/bundle/server/dist/repository/issues.test.js +69 -0
- package/bundle/server/dist/repository/outbound-drafts.js +28 -0
- package/bundle/server/dist/repository/queue-entries.js +162 -0
- package/bundle/server/dist/repository/review-publications.js +153 -0
- package/bundle/server/dist/repository/review-publications.test.js +78 -0
- package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
- package/bundle/server/dist/repository/runs.js +4 -211
- package/bundle/server/dist/repository/runtime-availability.js +54 -0
- package/bundle/server/dist/repository/usage-events.js +60 -0
- package/bundle/server/dist/repository/usage-events.test.js +56 -0
- package/bundle/server/dist/repository/work-items.js +305 -0
- package/bundle/server/dist/repository/work-items.test.js +142 -0
- package/bundle/server/dist/repository/worker-sessions.js +219 -0
- package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
- package/bundle/server/dist/repository/workflow-events.js +193 -0
- package/bundle/server/dist/repository/workflow-events.test.js +89 -0
- package/bundle/server/dist/resolve-tsx-bin.js +19 -0
- package/bundle/server/dist/routes/human-actions.js +68 -0
- package/bundle/server/dist/routes/human-actions.test.js +284 -0
- package/bundle/server/dist/routes/index.js +29 -496
- package/bundle/server/dist/routes/issues.js +240 -0
- package/bundle/server/dist/routes/issues.test.js +451 -0
- package/bundle/server/dist/routes/queue.js +41 -0
- package/bundle/server/dist/routes/queue.test.js +78 -0
- package/bundle/server/dist/runners/claude-args.js +18 -30
- package/bundle/server/dist/runners/claude.js +19 -163
- package/bundle/server/dist/runners/claude.test.js +15 -70
- package/bundle/server/dist/runners/codex-args.js +59 -0
- package/bundle/server/dist/runners/codex-args.test.js +72 -0
- package/bundle/server/dist/runners/codex-jsonl.js +150 -0
- package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
- package/bundle/server/dist/runners/codex-resume.test.js +19 -0
- package/bundle/server/dist/runners/codex.js +46 -0
- package/bundle/server/dist/runners/models-codex.test.js +14 -0
- package/bundle/server/dist/runners/models.js +53 -1
- package/bundle/server/dist/runners/persist.js +4 -1
- package/bundle/server/dist/runners/process-registry.js +11 -0
- package/bundle/server/dist/runners/prompts.js +13 -280
- package/bundle/server/dist/runners/prompts.test.js +22 -153
- package/bundle/server/dist/runners/qa.js +17 -1
- package/bundle/server/dist/runners/reflect.js +4 -16
- package/bundle/server/dist/runners/spawn-cli.js +197 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
- package/bundle/server/dist/runners/stream-json.js +0 -141
- package/bundle/server/dist/runners/usage-cap.js +242 -0
- package/bundle/server/dist/runners/usage-cap.test.js +97 -0
- package/bundle/server/dist/server-liveness.js +37 -0
- package/bundle/server/dist/server-liveness.test.js +82 -0
- package/bundle/server/dist/static-ui.test.js +44 -0
- package/bundle/server/package.json +4 -2
- package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
- package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +262 -133
- package/bundle/shared/dist/agents.js +85 -19
- package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
- package/bundle/shared/dist/cursor-auth-health.js +36 -0
- package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
- package/bundle/shared/dist/findings.d.ts +46 -0
- package/bundle/shared/dist/findings.js +17 -0
- package/bundle/shared/dist/findings.test.d.ts +1 -0
- package/bundle/shared/dist/findings.test.js +20 -0
- package/bundle/shared/dist/github-repo.d.ts +28 -0
- package/bundle/shared/dist/github-repo.js +98 -0
- package/bundle/shared/dist/github-repo.test.d.ts +1 -0
- package/bundle/shared/dist/github-repo.test.js +32 -0
- package/bundle/shared/dist/human-actions.d.ts +66 -0
- package/bundle/shared/dist/human-actions.js +51 -0
- package/bundle/shared/dist/human-actions.test.d.ts +1 -0
- package/bundle/shared/dist/human-actions.test.js +57 -0
- package/bundle/shared/dist/index.d.ts +567 -757
- package/bundle/shared/dist/index.js +22 -36
- package/bundle/shared/dist/issues.d.ts +385 -0
- package/bundle/shared/dist/issues.js +169 -0
- package/bundle/shared/dist/issues.test.d.ts +1 -0
- package/bundle/shared/dist/issues.test.js +72 -0
- package/bundle/shared/dist/outbound-draft.d.ts +6 -0
- package/bundle/shared/dist/outbound-draft.js +4 -0
- package/bundle/shared/dist/plan-triage.d.ts +6 -6
- package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
- package/bundle/shared/dist/profile-snapshot.js +145 -0
- package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
- package/bundle/shared/dist/profile-snapshot.test.js +67 -0
- package/bundle/shared/dist/queue-entries.d.ts +89 -0
- package/bundle/shared/dist/queue-entries.js +19 -0
- package/bundle/shared/dist/result-qa.d.ts +2 -2
- package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
- package/bundle/shared/dist/runtime-auth-health.js +186 -0
- package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
- package/bundle/shared/dist/runtime-availability.d.ts +51 -0
- package/bundle/shared/dist/runtime-availability.js +13 -0
- package/bundle/shared/dist/runtime.d.ts +1 -1
- package/bundle/shared/dist/runtime.js +1 -1
- package/bundle/shared/dist/usage-events.d.ts +36 -0
- package/bundle/shared/dist/usage-events.js +13 -0
- package/bundle/shared/dist/worker-sessions.d.ts +140 -0
- package/bundle/shared/dist/worker-sessions.js +70 -0
- package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
- package/bundle/shared/dist/worker-sessions.test.js +39 -0
- package/bundle/shared/dist/workflow.d.ts +78 -0
- package/bundle/shared/dist/workflow.js +59 -0
- package/bundle/shared/dist/workflow.test.d.ts +1 -0
- package/bundle/shared/dist/workflow.test.js +61 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.d.ts +1 -0
- package/dist/action.js +50 -0
- package/dist/action.test.d.ts +1 -0
- package/dist/action.test.js +70 -0
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +17 -0
- package/dist/agent.test.d.ts +1 -0
- package/dist/agent.test.js +19 -0
- package/dist/cli-check.d.ts +7 -0
- package/dist/cli-check.js +40 -0
- package/dist/doctor.js +59 -1
- package/dist/http.d.ts +5 -0
- package/dist/http.js +19 -0
- package/dist/index.js +28 -0
- package/dist/issue.d.ts +35 -0
- package/dist/issue.js +143 -0
- package/dist/issue.test.d.ts +1 -0
- package/dist/issue.test.js +240 -0
- package/dist/lifecycle.contract.test.d.ts +1 -0
- package/dist/lifecycle.contract.test.js +135 -0
- package/dist/queue.d.ts +11 -0
- package/dist/queue.js +60 -0
- package/dist/queue.test.d.ts +1 -0
- package/dist/queue.test.js +46 -0
- package/dist/test-fetch-stub.d.ts +6 -0
- package/dist/test-fetch-stub.js +31 -0
- package/package.json +2 -1
- package/templates/prod.env.example +1 -1
- package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
- package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// packages/server/src/adapters/git-worktree.ts
|
|
2
|
+
//
|
|
3
|
+
// One worktree manager for the coordinator: developer sessions get a read-write
|
|
4
|
+
// checkout on the issue branch, reviewer sessions get a separate detached-HEAD
|
|
5
|
+
// checkout pinned to the verified head SHA (design §"Worktree lifecycle and
|
|
6
|
+
// concurrency"). Every add/remove/prune for one repo is serialized behind
|
|
7
|
+
// withRepoLock so two sessions on the same repo never race `.git` metadata.
|
|
8
|
+
//
|
|
9
|
+
// Lifted from archive/not-57-full-p0-slice and extended with role-aware creation,
|
|
10
|
+
// safe (non-destructive) removal, and leftover inspection for crash recovery.
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import { withRepoLock } from "../runners/process-registry.js";
|
|
16
|
+
/** Directory name under the issue repo for coordinator-managed role worktrees.
|
|
17
|
+
* Kept inside the repo (not `$AGENT_DEALER_HOME`) so `agent-deck use` grants cover the
|
|
18
|
+
* worker cwd and `bind_workspace` to that path can succeed. */
|
|
19
|
+
export const WORKTREES_DIR_NAME = ".agent-dealer-worktrees";
|
|
20
|
+
const run = promisify(execFile);
|
|
21
|
+
async function git(cwd, args) {
|
|
22
|
+
try {
|
|
23
|
+
return await run("git", args, { cwd });
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
const e = err;
|
|
27
|
+
throw new Error(`git ${args.join(" ")} failed: ${e.stderr?.trim() || e.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function addWorktree(opts) {
|
|
31
|
+
const args = ["worktree", "add"];
|
|
32
|
+
if (opts.detach)
|
|
33
|
+
args.push("--detach");
|
|
34
|
+
if (opts.newBranch)
|
|
35
|
+
args.push("-b", opts.newBranch);
|
|
36
|
+
args.push(opts.path, opts.ref);
|
|
37
|
+
await git(opts.repo, args);
|
|
38
|
+
}
|
|
39
|
+
export async function removeWorktree(opts) {
|
|
40
|
+
const args = ["worktree", "remove"];
|
|
41
|
+
if (opts.force)
|
|
42
|
+
args.push("--force");
|
|
43
|
+
args.push(opts.path);
|
|
44
|
+
await git(opts.repo, args);
|
|
45
|
+
}
|
|
46
|
+
export async function isWorktreeClean(worktreePath) {
|
|
47
|
+
const { stdout } = await git(worktreePath, ["status", "--porcelain"]);
|
|
48
|
+
return stdout.trim().length === 0;
|
|
49
|
+
}
|
|
50
|
+
/** Commit message for a timeout salvage tip (NOT-145). */
|
|
51
|
+
export const SALVAGE_TIMEOUT_MESSAGE = "wip: timeout salvage";
|
|
52
|
+
/** Commit message for a crash/non-zero exit salvage tip (NOT-145). */
|
|
53
|
+
export const SALVAGE_CRASH_MESSAGE = "wip: crash salvage";
|
|
54
|
+
/**
|
|
55
|
+
* NOT-145: stage everything and commit a clearly marked salvage tip so a timeout/crash
|
|
56
|
+
* can remove the worktree for retry without silently discarding uncommitted WIP.
|
|
57
|
+
* Prefer this over leaving dirt behind — the durable checkpoint is the branch tip
|
|
58
|
+
* (parent NOT-143), not a preserved dirty checkout the next attempt cannot adopt.
|
|
59
|
+
*/
|
|
60
|
+
export async function salvageDirtyWorktree(worktreePath, kind) {
|
|
61
|
+
const message = kind === "timeout" ? SALVAGE_TIMEOUT_MESSAGE : SALVAGE_CRASH_MESSAGE;
|
|
62
|
+
try {
|
|
63
|
+
await git(worktreePath, ["add", "-A"]);
|
|
64
|
+
// Explicit identity: coordinator-managed worktrees may lack user.name/email, and a
|
|
65
|
+
// salvage commit must not depend on whatever the agent happened to configure.
|
|
66
|
+
await git(worktreePath, [
|
|
67
|
+
"-c",
|
|
68
|
+
"user.email=agent-dealer@localhost",
|
|
69
|
+
"-c",
|
|
70
|
+
"user.name=Agent Dealer",
|
|
71
|
+
"commit",
|
|
72
|
+
"-q",
|
|
73
|
+
"-m",
|
|
74
|
+
message,
|
|
75
|
+
]);
|
|
76
|
+
// Lens (NOT-145): never report ok while the tree is still dirty — residual dirt would
|
|
77
|
+
// make bestEffortRemove preserve the checkout and the next retry collide on the branch.
|
|
78
|
+
const clean = await isWorktreeClean(worktreePath);
|
|
79
|
+
if (!clean) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: "salvage commit succeeded but the worktree is still dirty",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const { stdout } = await git(worktreePath, ["rev-parse", "HEAD"]);
|
|
86
|
+
return { ok: true, commitSha: stdout.trim(), message };
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return { ok: false, reason: err.message };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Actionable recovery lines for a preserved dirty developer checkout (NOT-137 / NOT-145). */
|
|
93
|
+
export function dirtyWorktreeRecoveryCommands(repo, worktreePath) {
|
|
94
|
+
return [
|
|
95
|
+
`cd ${worktreePath}`,
|
|
96
|
+
"git status",
|
|
97
|
+
"git log --oneline -5",
|
|
98
|
+
"git stash list",
|
|
99
|
+
`git -C ${repo} worktree remove ${worktreePath} # only after the work is saved`,
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
export async function mergeBase(opts) {
|
|
103
|
+
const { stdout } = await git(opts.repo, ["merge-base", opts.base, opts.head]);
|
|
104
|
+
return stdout.trim();
|
|
105
|
+
}
|
|
106
|
+
/** Updates the local `origin/<ref>` remote-tracking branch — call before resolving a base SHA against it. */
|
|
107
|
+
export async function fetchRef(worktreePath, ref) {
|
|
108
|
+
await git(worktreePath, ["fetch", "origin", ref]);
|
|
109
|
+
}
|
|
110
|
+
/** The worktree's actual local HEAD — compared against what `gh` reports to catch a stale/wrong view. */
|
|
111
|
+
export async function revParseHead(worktreePath) {
|
|
112
|
+
const { stdout } = await git(worktreePath, ["rev-parse", "HEAD"]);
|
|
113
|
+
return stdout.trim();
|
|
114
|
+
}
|
|
115
|
+
/** Resolve any ref (e.g. `origin/issue-…`) — used by publish-only retries that have no worktree. */
|
|
116
|
+
export async function revParseRef(repo, ref) {
|
|
117
|
+
const { stdout } = await git(repo, ["rev-parse", ref]);
|
|
118
|
+
return stdout.trim();
|
|
119
|
+
}
|
|
120
|
+
export async function pruneWorktrees(repo) {
|
|
121
|
+
await git(repo, ["worktree", "prune"]);
|
|
122
|
+
}
|
|
123
|
+
/** Whether a local branch already exists — a retry must reuse it, never re-create with -b. */
|
|
124
|
+
export async function branchExists(repo, branch) {
|
|
125
|
+
try {
|
|
126
|
+
await git(repo, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export { withRepoLock };
|
|
134
|
+
/**
|
|
135
|
+
* Legacy layout: worktrees under `<repo>/.agent-dealer-worktrees/` (pre-NOT-149).
|
|
136
|
+
* New managed checkouts use `roleWorktreePathForResolution` from managed-repo.ts;
|
|
137
|
+
* callers that still pass a local repo path keep this helper for recovery.
|
|
138
|
+
*/
|
|
139
|
+
export function worktreesRoot(repo) {
|
|
140
|
+
return path.join(repo, WORKTREES_DIR_NAME);
|
|
141
|
+
}
|
|
142
|
+
export function roleWorktreePath(repo, sessionId, role) {
|
|
143
|
+
return path.join(worktreesRoot(repo), `${sessionId}-${role}`);
|
|
144
|
+
}
|
|
145
|
+
function ensureParentDir(worktreePath) {
|
|
146
|
+
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
/** @deprecated use ensureParentDir — kept so any stray call sites compile during migration. */
|
|
149
|
+
function ensureWorktreesRoot(repo) {
|
|
150
|
+
fs.mkdirSync(worktreesRoot(repo), { recursive: true });
|
|
151
|
+
}
|
|
152
|
+
/** RFC 2606 reserved TLD — guaranteed never to resolve. */
|
|
153
|
+
const BLOCKED_PUSH_URL = "https://push-blocked.invalid/policy-denied.git";
|
|
154
|
+
/**
|
|
155
|
+
* Redirects `remote.origin.pushurl` for one worktree, scoped to that worktree only
|
|
156
|
+
* (requires `extensions.worktreeConfig`). This is HYGIENE, not a security boundary: a
|
|
157
|
+
* review round proved it bypassable by the very process it would restrict — `git push
|
|
158
|
+
* <remote-url>` skips the named remote entirely, and a worker with ordinary write access
|
|
159
|
+
* to its own worktree can simply `git config --worktree --unset-all
|
|
160
|
+
* remote.origin.pushurl` before pushing. Applied unconditionally to reviewer worktrees
|
|
161
|
+
* (which have no write/Bash grant to begin with — see `args.ts` — so this never needs to
|
|
162
|
+
* hold on its own) to make an accidental `git push` from a read-only session fail
|
|
163
|
+
* immediately instead of silently succeeding. There is no `push`-capability parameter
|
|
164
|
+
* here: see `profile-snapshot.ts`'s `PermissionPolicy` doc comment for why `push` is not
|
|
165
|
+
* modeled as a profile-tunable capability at all.
|
|
166
|
+
*/
|
|
167
|
+
async function blockPush(repo, worktreePath) {
|
|
168
|
+
await git(repo, ["config", "extensions.worktreeConfig", "true"]);
|
|
169
|
+
await git(worktreePath, ["config", "--worktree", "remote.origin.pushurl", BLOCKED_PUSH_URL]);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Create the checkout for one worker session. Developer = branch checkout the session
|
|
173
|
+
* can commit/push; reviewer = detached HEAD at the exact SHA it must not mutate (and is
|
|
174
|
+
* additionally push-blocked as hygiene — see `blockPush`). Serialized per repo.
|
|
175
|
+
*
|
|
176
|
+
* `newBranch` creates a fresh branch off `ref` (round 1 of a developer session, where the
|
|
177
|
+
* issue has no branch yet) instead of checking out an existing one (repair rounds 2+).
|
|
178
|
+
*/
|
|
179
|
+
export async function createRoleWorktree(opts) {
|
|
180
|
+
const detached = opts.role === "reviewer";
|
|
181
|
+
const worktreePath = opts.worktreePath ?? roleWorktreePath(opts.repo, opts.sessionId, opts.role);
|
|
182
|
+
await withRepoLock(opts.repo, async () => {
|
|
183
|
+
await pruneWorktrees(opts.repo);
|
|
184
|
+
ensureParentDir(worktreePath);
|
|
185
|
+
await addWorktree({
|
|
186
|
+
repo: opts.repo,
|
|
187
|
+
path: worktreePath,
|
|
188
|
+
ref: opts.ref,
|
|
189
|
+
detach: detached,
|
|
190
|
+
newBranch: opts.newBranch,
|
|
191
|
+
});
|
|
192
|
+
if (detached)
|
|
193
|
+
await blockPush(opts.repo, worktreePath);
|
|
194
|
+
});
|
|
195
|
+
return { path: tryRealpath(worktreePath), role: opts.role, ref: opts.newBranch ?? opts.ref, detached };
|
|
196
|
+
}
|
|
197
|
+
const PUSH_REJECTION_PATTERNS = /rejected|non-fast-forward|fetch first|stale info/i;
|
|
198
|
+
function shortSha(sha) {
|
|
199
|
+
return sha.length > 12 ? sha.slice(0, 12) : sha;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* After a rejected push, fetch the remote tip and classify local vs origin/<branch>.
|
|
203
|
+
* Best-effort: returns null when the remote tip cannot be resolved (facts stay out of
|
|
204
|
+
* the escalation rather than inventing them).
|
|
205
|
+
*/
|
|
206
|
+
async function gatherPushRejectionFacts(opts) {
|
|
207
|
+
try {
|
|
208
|
+
// Refresh origin/<branch> so left/right counts reflect the tip that rejected us, not a
|
|
209
|
+
// stale remote-tracking ref left over from an earlier fetch.
|
|
210
|
+
await fetchRef(opts.cwd, opts.branch).catch(() => { });
|
|
211
|
+
const remoteRef = `origin/${opts.branch}`;
|
|
212
|
+
if (!(await refExists(opts.cwd, remoteRef)))
|
|
213
|
+
return null;
|
|
214
|
+
const localSha = await revParseRef(opts.cwd, opts.localRef);
|
|
215
|
+
const remoteSha = await revParseRef(opts.cwd, remoteRef);
|
|
216
|
+
const { stdout } = await git(opts.cwd, [
|
|
217
|
+
"rev-list",
|
|
218
|
+
"--left-right",
|
|
219
|
+
"--count",
|
|
220
|
+
`${localSha}...${remoteSha}`,
|
|
221
|
+
]);
|
|
222
|
+
const parts = stdout.trim().split(/\s+/);
|
|
223
|
+
const ahead = Number(parts[0]);
|
|
224
|
+
const behind = Number(parts[1]);
|
|
225
|
+
if (!Number.isFinite(ahead) || !Number.isFinite(behind))
|
|
226
|
+
return null;
|
|
227
|
+
// A non-fast-forward rejection always means remote has commits we lack (behind > 0).
|
|
228
|
+
// When we also have unique commits, histories diverged — git's pull hint is wrong.
|
|
229
|
+
const relationship = ahead > 0 && behind > 0 ? "diverged" : "behind";
|
|
230
|
+
const localShort = shortSha(localSha);
|
|
231
|
+
const remoteShort = shortSha(remoteSha);
|
|
232
|
+
let summary;
|
|
233
|
+
let recoveryCommands;
|
|
234
|
+
if (relationship === "diverged") {
|
|
235
|
+
summary =
|
|
236
|
+
`local and origin/${opts.branch} have diverged: local ${localShort} is ${ahead} commit(s) ahead, ` +
|
|
237
|
+
`remote ${remoteShort} is ${behind} commit(s) ahead. ` +
|
|
238
|
+
`Do not git pull — that integrates the wrong history for a rewritten branch.`;
|
|
239
|
+
recoveryCommands = [
|
|
240
|
+
`# Confirm the remote tip is still ${remoteShort}, then publish local with a lease pin:`,
|
|
241
|
+
`git push --force-with-lease=refs/heads/${opts.branch}:${remoteSha} origin ${localSha}:refs/heads/${opts.branch}`,
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
summary =
|
|
246
|
+
`local ${localShort} is behind origin/${opts.branch} at ${remoteShort} ` +
|
|
247
|
+
`(remote is ${behind} commit(s) ahead; local is ${ahead} commit(s) ahead).`;
|
|
248
|
+
recoveryCommands = [
|
|
249
|
+
`git fetch origin ${opts.branch}`,
|
|
250
|
+
`git rebase origin/${opts.branch}`,
|
|
251
|
+
`git push origin HEAD:refs/heads/${opts.branch}`,
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
localSha,
|
|
256
|
+
remoteSha,
|
|
257
|
+
ahead,
|
|
258
|
+
behind,
|
|
259
|
+
relationship,
|
|
260
|
+
summary,
|
|
261
|
+
recoveryCommands,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function pushFailureResult(cwd, branch, localRef, message) {
|
|
269
|
+
const rejected = PUSH_REJECTION_PATTERNS.test(message);
|
|
270
|
+
if (!rejected)
|
|
271
|
+
return { ok: false, reason: message, rejected: false };
|
|
272
|
+
const facts = await gatherPushRejectionFacts({ cwd, branch, localRef });
|
|
273
|
+
if (facts) {
|
|
274
|
+
// Prefer the classified summary over raw git stderr so the escalation never carries
|
|
275
|
+
// git's misleading `use 'git pull'` hint for a diverged branch (NOT-137).
|
|
276
|
+
return { ok: false, reason: facts.summary, rejected: true, facts };
|
|
277
|
+
}
|
|
278
|
+
return { ok: false, reason: message, rejected: true };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The coordinator — never the developer worker — pushes the branch, after the worker's
|
|
282
|
+
* session has already ended (design §"Role permissions": the credentialed push effect
|
|
283
|
+
* never runs inside the worker's own process; see profile-snapshot.ts's PermissionPolicy
|
|
284
|
+
* doc comment for why this could never be an enforceable worker-side policy toggle).
|
|
285
|
+
* Distinguishes a clean rejection (remote diverged — `unpushed_commit`, a policy_escalation
|
|
286
|
+
* a human resolves) from an unexpected tooling failure (`adapter_failure`).
|
|
287
|
+
*/
|
|
288
|
+
export async function pushBranch(opts) {
|
|
289
|
+
try {
|
|
290
|
+
await git(opts.worktreePath, ["push", "-u", "origin", `HEAD:refs/heads/${opts.branch}`]);
|
|
291
|
+
return { ok: true };
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
return pushFailureResult(opts.worktreePath, opts.branch, "HEAD", err.message);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Push an existing local branch by ref, from the repo itself rather than a checkout. The
|
|
299
|
+
* republish path (NOT-129) recovers commits a dead attempt left on the branch, and by then
|
|
300
|
+
* there is no worktree whose HEAD `pushBranch` could use — the branch ref in the shared repo
|
|
301
|
+
* is the only durable handle on that work.
|
|
302
|
+
*/
|
|
303
|
+
export async function pushBranchRef(opts) {
|
|
304
|
+
try {
|
|
305
|
+
await git(opts.repo, ["push", "-u", "origin", `refs/heads/${opts.branch}:refs/heads/${opts.branch}`]);
|
|
306
|
+
return { ok: true };
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
return pushFailureResult(opts.repo, opts.branch, `refs/heads/${opts.branch}`, err.message);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Commits on HEAD not on `baseRef` — zero means the developer produced nothing to push/PR. */
|
|
313
|
+
export async function commitsAhead(opts) {
|
|
314
|
+
const { stdout } = await git(opts.worktreePath, ["rev-list", "--count", `${opts.baseRef}..HEAD`]);
|
|
315
|
+
return Number(stdout.trim());
|
|
316
|
+
}
|
|
317
|
+
/** `commitsAhead` between two arbitrary refs — no worktree, no HEAD (see `pushBranchRef`). */
|
|
318
|
+
export async function countCommitsBetween(opts) {
|
|
319
|
+
const { stdout } = await git(opts.repo, ["rev-list", "--count", `${opts.base}..${opts.head}`]);
|
|
320
|
+
return Number(stdout.trim());
|
|
321
|
+
}
|
|
322
|
+
/** Whether any ref (SHA, `origin/<branch>`, branch name) resolves to a commit in this repo. */
|
|
323
|
+
export async function refExists(repo, ref) {
|
|
324
|
+
try {
|
|
325
|
+
await git(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The base→head diff, computed by the coordinator (not the reviewer worker — a claude
|
|
334
|
+
* reviewer has no Bash tool at all, see prompts.ts's module doc) and embedded in the
|
|
335
|
+
* reviewer prompt. Both SHAs must already be present as objects in `worktreePath`'s repo
|
|
336
|
+
* — true for a reviewer worktree, since it shares object storage with the repo the
|
|
337
|
+
* developer round already fetched/merge-based against.
|
|
338
|
+
*/
|
|
339
|
+
export async function diffShas(opts) {
|
|
340
|
+
const { stdout } = await git(opts.worktreePath, ["diff", opts.baseSha, opts.headSha]);
|
|
341
|
+
return stdout;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Remove a worktree only when it is safe to. A reviewer checkout can always go once
|
|
345
|
+
* its evidence is stored; a developer checkout is removed only when it is clean AND its
|
|
346
|
+
* branch is pushed. Anything else is preserved and handed back so the caller can raise
|
|
347
|
+
* a policy_escalation — the coordinator never force-removes potentially valuable work
|
|
348
|
+
* (design §"Worktree lifecycle and concurrency").
|
|
349
|
+
*/
|
|
350
|
+
export async function safeRemoveWorktree(opts) {
|
|
351
|
+
const clean = await isWorktreeClean(opts.path).catch(() => false);
|
|
352
|
+
const safe = clean && (opts.role === "reviewer" || opts.branchPushed === true);
|
|
353
|
+
if (!safe) {
|
|
354
|
+
return {
|
|
355
|
+
removed: false,
|
|
356
|
+
preserved: true,
|
|
357
|
+
path: opts.path,
|
|
358
|
+
reason: !clean ? "worktree has uncommitted changes" : "issue branch is not pushed",
|
|
359
|
+
recoveryCommands: [
|
|
360
|
+
`cd ${opts.path}`,
|
|
361
|
+
"git status",
|
|
362
|
+
"git stash list",
|
|
363
|
+
`git -C ${opts.repo} worktree remove ${opts.path} # only after the work is saved`,
|
|
364
|
+
],
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
await withRepoLock(opts.repo, async () => {
|
|
368
|
+
await removeWorktree({ repo: opts.repo, path: opts.path });
|
|
369
|
+
await pruneWorktrees(opts.repo);
|
|
370
|
+
});
|
|
371
|
+
return { removed: true };
|
|
372
|
+
}
|
|
373
|
+
/** Classify a worktree left behind by a crash so recovery can decide what to do. */
|
|
374
|
+
export async function inspectLeftoverWorktree(worktreePath) {
|
|
375
|
+
try {
|
|
376
|
+
const clean = await isWorktreeClean(worktreePath);
|
|
377
|
+
return clean ? "clean" : "dirty_or_unpushed";
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return "missing";
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The checkout path currently holding `branch` in `repo`'s worktree list, if any — `git
|
|
385
|
+
* worktree add`/checkout refuses a branch already checked out elsewhere, and the failure
|
|
386
|
+
* message alone doesn't tell the caller whether that other checkout is safe to reuse. Reads
|
|
387
|
+
* `git worktree list --porcelain`: entries are blank-line-separated blocks of `worktree
|
|
388
|
+
* <path>` / `HEAD <sha>` / `branch refs/heads/<name>` (omitted entirely for a detached
|
|
389
|
+
* checkout, which can never be the collision this looks for).
|
|
390
|
+
*/
|
|
391
|
+
export async function findWorktreeForBranch(repo, branch) {
|
|
392
|
+
const { stdout } = await git(repo, ["worktree", "list", "--porcelain"]);
|
|
393
|
+
const ref = `refs/heads/${branch}`;
|
|
394
|
+
let currentPath = null;
|
|
395
|
+
for (const line of stdout.split("\n")) {
|
|
396
|
+
if (line.startsWith("worktree "))
|
|
397
|
+
currentPath = line.slice("worktree ".length).trim();
|
|
398
|
+
else if (line.startsWith("branch ") && line.slice("branch ".length).trim() === ref)
|
|
399
|
+
return currentPath;
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
/** `git worktree list` resolves symlinks in the paths it reports (e.g. macOS's /tmp →
|
|
404
|
+
* /private/tmp); comparing that against a literally-constructed path would misclassify a
|
|
405
|
+
* coordinator-owned worktree as external. Falls back to the raw path if it doesn't exist. */
|
|
406
|
+
function tryRealpath(p) {
|
|
407
|
+
try {
|
|
408
|
+
return fs.realpathSync(p);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
return p;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Session id encoded in a coordinator-managed role worktree path
|
|
416
|
+
* (`…/.agent-dealer-worktrees/<sessionId>-developer`). Returns null when the basename is
|
|
417
|
+
* not that shape (external checkouts, hand-renamed dirs).
|
|
418
|
+
*/
|
|
419
|
+
export function sessionIdFromRoleWorktreePath(worktreePath) {
|
|
420
|
+
const base = path.basename(worktreePath);
|
|
421
|
+
const m = /^(.*)-(developer|reviewer)$/.exec(base);
|
|
422
|
+
return m?.[1] ?? null;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The developer-setup half of design §"Worktree lifecycle and concurrency"'s crash-recovery
|
|
426
|
+
* requirement: a leftover worktree from an earlier round/escalation on the SAME issue branch
|
|
427
|
+
* must be detected before a fresh `git worktree add` collides with it, not left to surface as
|
|
428
|
+
* an opaque git error. A clean leftover under this coordinator's own worktrees root is reused
|
|
429
|
+
* in place (whatever commits it already carries are preserved); a dirty/unpushed one, or one
|
|
430
|
+
* outside the coordinator's management, is reported as a conflict for a human to resolve
|
|
431
|
+
* instead of being blindly retried — reusing or force-removing it here would silently discard
|
|
432
|
+
* or hide work exactly like the dirty-handoff case this mirrors (`safeRemoveWorktree`).
|
|
433
|
+
*
|
|
434
|
+
* NOT-127: before reuse or conflict, `ownerLiveness` (when provided) asks whether the
|
|
435
|
+
* session that owns the leftover still has a live process. A live owner is neither adopted
|
|
436
|
+
* nor escalated as a worktree conflict — that is the same-worker case. Omitting the check
|
|
437
|
+
* preserves the pre-NOT-127 filesystem-only behaviour (dead owner).
|
|
438
|
+
*/
|
|
439
|
+
export async function resolveDeveloperWorktree(opts) {
|
|
440
|
+
return withRepoLock(opts.repo, async () => {
|
|
441
|
+
await pruneWorktrees(opts.repo);
|
|
442
|
+
const existing = await findWorktreeForBranch(opts.repo, opts.branchName);
|
|
443
|
+
if (existing) {
|
|
444
|
+
const managedRoot = opts.worktreePath
|
|
445
|
+
? tryRealpath(path.dirname(opts.worktreePath))
|
|
446
|
+
: tryRealpath(worktreesRoot(opts.repo));
|
|
447
|
+
const underRoot = tryRealpath(existing).startsWith(managedRoot + path.sep);
|
|
448
|
+
if (!underRoot) {
|
|
449
|
+
return {
|
|
450
|
+
kind: "conflict",
|
|
451
|
+
path: existing,
|
|
452
|
+
reason: `Branch ${opts.branchName} is already checked out at ${existing}, outside the coordinator's managed worktrees — it cannot be safely reused or removed automatically.`,
|
|
453
|
+
recoveryCommands: [`git -C ${opts.repo} worktree list`, `# free the branch, then Resume: cd ${existing} && git status`],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
// Live owner first — clean/dirty are only meaningful once the predecessor is gone.
|
|
457
|
+
const owner = opts.ownerLiveness ? await opts.ownerLiveness(existing) : { state: "dead" };
|
|
458
|
+
if (owner.state === "alive") {
|
|
459
|
+
return {
|
|
460
|
+
kind: "live_owner",
|
|
461
|
+
path: tryRealpath(existing),
|
|
462
|
+
ownerSessionId: owner.sessionId,
|
|
463
|
+
reason: `Developer worktree for branch ${opts.branchName} at ${existing} is still in use by session ${owner.sessionId} (live process) — refusing to adopt it or escalate as a worktree conflict.`,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const state = await inspectLeftoverWorktree(existing);
|
|
467
|
+
if (state === "clean") {
|
|
468
|
+
return { kind: "reused", path: tryRealpath(existing) };
|
|
469
|
+
}
|
|
470
|
+
if (state === "dirty_or_unpushed") {
|
|
471
|
+
return {
|
|
472
|
+
kind: "conflict",
|
|
473
|
+
path: existing,
|
|
474
|
+
reason: `A previous developer worktree for branch ${opts.branchName} still holds it at ${existing} with uncommitted changes.`,
|
|
475
|
+
recoveryCommands: [
|
|
476
|
+
`cd ${existing}`,
|
|
477
|
+
"git status",
|
|
478
|
+
"git log --oneline -5",
|
|
479
|
+
`# once the work there is safe (pushed or intentionally discarded): git -C ${opts.repo} worktree remove ${existing} --force`,
|
|
480
|
+
],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// inspectLeftoverWorktree reports "missing" for ANY `git status` failure, not just a
|
|
484
|
+
// deleted directory (a review round flagged this: a corrupt/unreadable checkout that's
|
|
485
|
+
// still on disk and still registered would otherwise be silently reinterpreted as a
|
|
486
|
+
// harmless stale entry, pruned as a no-op, and immediately re-collide on the `addWorktree`
|
|
487
|
+
// below — a narrower repeat of the exact loop this function exists to close). Only a
|
|
488
|
+
// truly gone directory is safe to treat as a stale administrative entry.
|
|
489
|
+
if (fs.existsSync(existing)) {
|
|
490
|
+
return {
|
|
491
|
+
kind: "conflict",
|
|
492
|
+
path: existing,
|
|
493
|
+
reason: `A previous developer worktree for branch ${opts.branchName} exists at ${existing} but its status could not be determined.`,
|
|
494
|
+
recoveryCommands: [
|
|
495
|
+
`cd ${existing}`,
|
|
496
|
+
"git status",
|
|
497
|
+
`# once resolved: git -C ${opts.repo} worktree remove ${existing} --force`,
|
|
498
|
+
],
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
await pruneWorktrees(opts.repo);
|
|
502
|
+
}
|
|
503
|
+
const worktreePath = opts.worktreePath ?? roleWorktreePath(opts.repo, opts.sessionId, "developer");
|
|
504
|
+
ensureParentDir(worktreePath);
|
|
505
|
+
await addWorktree({
|
|
506
|
+
repo: opts.repo,
|
|
507
|
+
path: worktreePath,
|
|
508
|
+
ref: opts.reuseBranch ? opts.branchName : opts.baseBranch,
|
|
509
|
+
detach: false,
|
|
510
|
+
newBranch: opts.reuseBranch ? undefined : opts.branchName,
|
|
511
|
+
});
|
|
512
|
+
return { kind: "created", path: tryRealpath(worktreePath) };
|
|
513
|
+
});
|
|
514
|
+
}
|