agent-dealer 1.2.5 → 1.2.7
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 +33 -3
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
- package/bundle/server/dist/adapters/agent-health.js +3 -4
- package/bundle/server/dist/adapters/agent-health.test.js +11 -9
- package/bundle/server/dist/adapters/git-worktree.js +187 -18
- package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
- package/bundle/server/dist/adapters/muse-capability.js +51 -25
- package/bundle/server/dist/adapters/muse-capability.test.js +138 -52
- package/bundle/server/dist/capacity/claude-local-cache.js +59 -27
- package/bundle/server/dist/capacity/claude-local-cache.test.js +163 -9
- package/bundle/server/dist/capacity/muse-host.js +34 -32
- package/bundle/server/dist/capacity/muse-host.test.js +5 -3
- package/bundle/server/dist/coordinator/args.js +4 -3
- package/bundle/server/dist/coordinator/checkpoint.js +5 -3
- package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
- package/bundle/server/dist/coordinator/commands.js +66 -5
- package/bundle/server/dist/coordinator/developer-effect.js +88 -10
- package/bundle/server/dist/coordinator/developer-effect.test.js +170 -0
- package/bundle/server/dist/coordinator/human-resolution.js +2 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -174
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +10 -6
- package/bundle/server/dist/coordinator/prompts.test.js +16 -8
- package/bundle/server/dist/coordinator/routing.js +1 -0
- package/bundle/server/dist/coordinator/routing.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +2 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
- package/bundle/server/dist/repository/human-actions.js +13 -0
- package/bundle/server/dist/routes/index.js +11 -8
- package/bundle/server/dist/routes/version.js +30 -0
- package/bundle/server/dist/routes/version.test.js +21 -0
- package/bundle/server/dist/runners/muse-config-core.js +63 -16
- package/bundle/server/dist/runners/muse-config.js +1 -1
- package/bundle/server/dist/runners/muse-config.test.js +135 -29
- package/bundle/server/dist/runners/spawn-cli.js +17 -3
- package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DyAJNyfV.css +1 -0
- package/bundle/server/static-ui/assets/index-yLyxRd-7.js +64 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/action-presentation.d.ts +51 -0
- package/bundle/shared/dist/action-presentation.js +280 -0
- package/bundle/shared/dist/action-presentation.test.d.ts +1 -0
- package/bundle/shared/dist/action-presentation.test.js +180 -0
- package/bundle/shared/dist/attempt-waste.d.ts +2 -2
- package/bundle/shared/dist/execution-report.d.ts +2 -2
- package/bundle/shared/dist/human-actions.d.ts +12 -12
- package/bundle/shared/dist/index.d.ts +41 -40
- package/bundle/shared/dist/index.js +1 -0
- package/bundle/shared/dist/issues.d.ts +12 -12
- package/bundle/shared/dist/outbound-draft.d.ts +6 -6
- package/bundle/shared/dist/profile-snapshot.d.ts +2 -2
- package/bundle/shared/dist/usage-events.d.ts +2 -2
- package/bundle/shared/dist/worker-sessions.d.ts +6 -6
- package/bundle/shared/dist/workflow.d.ts +2 -2
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.js +4 -1
- package/dist/managed/index.d.ts +1 -1
- package/dist/managed/index.js +1 -1
- package/dist/managed/updater.d.ts +16 -6
- package/dist/managed/updater.js +28 -11
- package/dist/managed-update-restart.test.d.ts +1 -0
- package/dist/managed-update-restart.test.js +227 -0
- package/dist/ports.d.ts +6 -0
- package/dist/ports.js +10 -0
- package/dist/runtime-state.d.ts +10 -0
- package/dist/runtime-state.js +26 -0
- package/dist/start.js +67 -2
- package/dist/status.js +30 -2
- package/dist/update-check.js +3 -6
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-BII-LgB8.css +0 -1
- package/bundle/server/static-ui/assets/index-Bq8wWpZm.js +0 -60
|
@@ -203,10 +203,11 @@ async function materializeWorkerMcpConfig(opts) {
|
|
|
203
203
|
const mcpBase = getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "");
|
|
204
204
|
const mcpUrl = `${mcpBase}/mcp`;
|
|
205
205
|
const headers = deckLaunchHeaders(opts.deckId, opts.worktreePath);
|
|
206
|
-
// Muse Code
|
|
207
|
-
//
|
|
206
|
+
// NOT-278: Muse Code settings come only from `prepareMuseAttempt`
|
|
207
|
+
// (runners/muse-config.ts) — never emit a Claude/Codex/Cursor config for Muse. Refuse
|
|
208
|
+
// before anything is written rather than fall through to the Claude config below.
|
|
208
209
|
if (opts.runtime === "muse_code") {
|
|
209
|
-
throw new Error("Muse Code does not support Agent Deck MCP configuration");
|
|
210
|
+
throw new Error("Muse Code does not support Agent Deck MCP configuration via the shared materializer — its settings come only from prepareMuseAttempt");
|
|
210
211
|
}
|
|
211
212
|
if (opts.runtime === "codex_local") {
|
|
212
213
|
const codexHome = path.join(getWorkerMcpConfigDir(), `codex-home-${opts.deckId.slice(0, 8)}-${randomUUID()}`);
|
|
@@ -370,6 +371,32 @@ async function verifyDeckConnection(opts) {
|
|
|
370
371
|
}
|
|
371
372
|
}
|
|
372
373
|
}
|
|
374
|
+
function toVerificationOutcome(verified) {
|
|
375
|
+
if (verified.ok)
|
|
376
|
+
return { ok: true };
|
|
377
|
+
return verified.kind === "deck_unavailable"
|
|
378
|
+
? { ok: false, kind: "deck_unavailable", reason: `Agent Deck is unreachable — ${verified.reason}` }
|
|
379
|
+
: { ok: false, kind: "infra_failure", reason: `preflight failed: ${verified.reason}` };
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* NOT-278: live `get_bound_deck` (+ configured-playbook) preflight without materializing any
|
|
383
|
+
* runtime MCP config. Muse Code sessions use this: their settings.json comes only from
|
|
384
|
+
* `prepareMuseAttempt` (runners/muse-config.ts), so the shared materializer below must
|
|
385
|
+
* never run for them. Same result mapping as the materializing path: no response is
|
|
386
|
+
* `deck_unavailable`; a response that fails validation is `infra_failure` (surfaced by the
|
|
387
|
+
* effect as `deck_failure`).
|
|
388
|
+
*/
|
|
389
|
+
export async function verifyWorkerDeckConnection(opts) {
|
|
390
|
+
const timeoutMs = opts.timeoutMs ?? Number(process.env.DECK_BIND_TIMEOUT_MS ?? 30_000);
|
|
391
|
+
const verified = await verifyDeckConnection({
|
|
392
|
+
deckId: opts.deckId,
|
|
393
|
+
worktreePath: opts.worktreePath,
|
|
394
|
+
playbookIds: opts.playbookIds ?? [],
|
|
395
|
+
callTool: opts.callTool,
|
|
396
|
+
timeoutMs,
|
|
397
|
+
});
|
|
398
|
+
return toVerificationOutcome(verified);
|
|
399
|
+
}
|
|
373
400
|
/**
|
|
374
401
|
* Materialize a per-attempt MCP config for the profile's deck and verify with
|
|
375
402
|
* `get_bound_deck` and every configured playbook before spawn. No mint, no ledger, no
|
|
@@ -408,6 +435,9 @@ export async function prepareWorkerDeckConnection(opts) {
|
|
|
408
435
|
catch {
|
|
409
436
|
// best-effort
|
|
410
437
|
}
|
|
438
|
+
// Same mapping as toVerificationOutcome, inlined so the result carries the
|
|
439
|
+
// materializing-path type (the shared helper's `ok: true` variant has no
|
|
440
|
+
// mcpConfigPath and is unreachable in this branch).
|
|
411
441
|
return verified.kind === "deck_unavailable"
|
|
412
442
|
? { ok: false, kind: "deck_unavailable", reason: `Agent Deck is unreachable — ${verified.reason}` }
|
|
413
443
|
: { ok: false, kind: "infra_failure", reason: `preflight failed: ${verified.reason}` };
|
|
@@ -12,7 +12,7 @@ import { parse as parseToml } from "smol-toml";
|
|
|
12
12
|
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
13
13
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deckbind-"));
|
|
14
14
|
const { migrate } = await import("../db/index.js");
|
|
15
|
-
const { prepareWorkerDeckConnection, releaseWorkerDeckConnection, parseDeckToolResult, isDeckUnreachableError } = await import("./agent-deck-bind.js");
|
|
15
|
+
const { prepareWorkerDeckConnection, releaseWorkerDeckConnection, parseDeckToolResult, isDeckUnreachableError, verifyWorkerDeckConnection } = await import("./agent-deck-bind.js");
|
|
16
16
|
const { codexScopedConfigDeniesSendGate } = await import("./codex-scoped-config.js");
|
|
17
17
|
const { roleCeiling } = await import("@agent-dealer/shared");
|
|
18
18
|
/** Default worker policy: outboundMutation is false for BOTH roles (NOT-134). */
|
|
@@ -59,7 +59,65 @@ test("prepareWorkerDeckConnection verifies and writes a claude MCP config with d
|
|
|
59
59
|
assert.equal(fs.existsSync(result.mcpConfigPath), false);
|
|
60
60
|
}
|
|
61
61
|
});
|
|
62
|
-
|
|
62
|
+
// NOT-278: Muse Code settings come only from prepareMuseAttempt — the shared materializer
|
|
63
|
+
// must never run for Muse. Muse preflights through verifyWorkerDeckConnection instead.
|
|
64
|
+
test("verifyWorkerDeckConnection preflights Muse without materializing any MCP config", async () => {
|
|
65
|
+
const calls = [];
|
|
66
|
+
const result = await verifyWorkerDeckConnection({
|
|
67
|
+
deckId: DECK,
|
|
68
|
+
worktreePath: WT,
|
|
69
|
+
callTool: async (name, args) => {
|
|
70
|
+
calls.push(name);
|
|
71
|
+
if (name === "get_bound_deck")
|
|
72
|
+
return textResult({ id: DECK, name: "personal-dev" });
|
|
73
|
+
return textResult({ id: args.playbook_id });
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
assert.equal(result.ok, true);
|
|
77
|
+
assert.deepEqual(calls, ["get_bound_deck"]);
|
|
78
|
+
});
|
|
79
|
+
test("verifyWorkerDeckConnection maps a wrong deck to infra_failure and silence to deck_unavailable", async () => {
|
|
80
|
+
const wrong = await verifyWorkerDeckConnection({
|
|
81
|
+
deckId: DECK,
|
|
82
|
+
worktreePath: WT,
|
|
83
|
+
callTool: async () => textResult({ id: "some-other-deck-id" }),
|
|
84
|
+
});
|
|
85
|
+
assert.equal(wrong.ok, false);
|
|
86
|
+
if (!wrong.ok) {
|
|
87
|
+
assert.equal(wrong.kind, "infra_failure");
|
|
88
|
+
assert.match(wrong.reason, /returned deck some-other-deck-id, expected/);
|
|
89
|
+
}
|
|
90
|
+
const silent = await verifyWorkerDeckConnection({
|
|
91
|
+
deckId: DECK,
|
|
92
|
+
worktreePath: WT,
|
|
93
|
+
callTool: async () => {
|
|
94
|
+
throw Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:1110"), { code: "ECONNREFUSED" });
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
assert.equal(silent.ok, false);
|
|
98
|
+
if (!silent.ok) {
|
|
99
|
+
assert.equal(silent.kind, "deck_unavailable");
|
|
100
|
+
assert.match(silent.reason, /unreachable/);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
test("verifyWorkerDeckConnection maps a missing configured playbook to infra_failure (NOT-278)", async () => {
|
|
104
|
+
const missing = await verifyWorkerDeckConnection({
|
|
105
|
+
deckId: DECK,
|
|
106
|
+
worktreePath: WT,
|
|
107
|
+
playbookIds: ["pb-required"],
|
|
108
|
+
callTool: async (name, args) => {
|
|
109
|
+
if (name === "get_bound_deck")
|
|
110
|
+
return textResult({ id: DECK, name: "personal-dev" });
|
|
111
|
+
return errorResult(`playbook ${args.playbook_id} not found`);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
assert.equal(missing.ok, false);
|
|
115
|
+
if (!missing.ok) {
|
|
116
|
+
assert.equal(missing.kind, "infra_failure");
|
|
117
|
+
assert.match(missing.reason, /preflight failed/);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
test("prepareWorkerDeckConnection refuses Muse Code before writing any MCP config or verifying (NOT-278)", async () => {
|
|
63
121
|
let verifyCalled = false;
|
|
64
122
|
const result = await prepareWorkerDeckConnection({
|
|
65
123
|
policy: DENIED,
|
|
@@ -469,10 +469,9 @@ function resolveMcpRegistration(mcpRegistered) {
|
|
|
469
469
|
}
|
|
470
470
|
function agentSpecificIssues(agent, agentDeckOnline, mcpRegistration, deckAccessResult) {
|
|
471
471
|
const issues = [];
|
|
472
|
-
// NOT-
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
return issues;
|
|
472
|
+
// NOT-278: a Muse Code developer session receives the selected deck exactly like the other
|
|
473
|
+
// developer runtimes, so `deck_missing`, `deck_offline`, and `deck_unauthorized` apply to
|
|
474
|
+
// Muse too. Only the Claude-only `mcp_not_registered` check below stays Claude-only.
|
|
476
475
|
if (!agent.deckId) {
|
|
477
476
|
issues.push({
|
|
478
477
|
code: "deck_missing",
|
|
@@ -37,18 +37,20 @@ describe("agent-health", { concurrency: false }, () => {
|
|
|
37
37
|
assert.equal(result.issues.some((i) => i.code === "deck_missing"), true);
|
|
38
38
|
assert.equal(result.healthy, false);
|
|
39
39
|
});
|
|
40
|
-
// NOT-
|
|
41
|
-
|
|
40
|
+
// NOT-278: a Muse Code developer session receives the selected deck exactly like the other
|
|
41
|
+
// developer runtimes, so deck_missing, deck_offline, and deck_unauthorized apply to Muse too.
|
|
42
|
+
test("muse_code reports deck state: missing deck, offline deck, and unreadable deck raise deck issues", async () => {
|
|
42
43
|
const created = createAgent({ name: "muse-no-deck", runtime: "muse_code", deckId: randomUUID() });
|
|
43
44
|
getDb().prepare("UPDATE agents SET deck_id = NULL WHERE id = ?").run(created.id);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
]
|
|
45
|
+
const cases = [
|
|
46
|
+
{ agent: getAgent(created.id), online: true, code: "deck_missing" },
|
|
47
|
+
{ agent: createAgent({ name: "muse-offline", runtime: "muse_code", deckId: randomUUID() }), online: false, code: "deck_offline" },
|
|
48
|
+
{ agent: createAgent({ name: "muse-stale", runtime: "muse_code", deckId: randomUUID() }), online: true, code: "deck_unauthorized" },
|
|
49
|
+
];
|
|
50
|
+
for (const { agent, online, code } of cases) {
|
|
49
51
|
const result = await healthForAgent(agent, online, new Map(), true, FAILURE, NO_GITHUB);
|
|
50
|
-
assert.
|
|
51
|
-
assert.equal(result.healthy,
|
|
52
|
+
assert.equal(result.issues.some((i) => i.code === code), true, `${agent.name} reports ${code}`);
|
|
53
|
+
assert.equal(result.healthy, false);
|
|
52
54
|
}
|
|
53
55
|
});
|
|
54
56
|
test("agent deck offline: reports deck_offline, not deck_unauthorized", async () => {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// Lifted from archive/not-57-full-p0-slice and extended with role-aware creation,
|
|
10
10
|
// safe (non-destructive) removal, and leftover inspection for crash recovery.
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
12
13
|
import fs from "node:fs";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import { promisify } from "node:util";
|
|
@@ -192,14 +193,28 @@ export async function removeWorktree(opts) {
|
|
|
192
193
|
args.push(opts.path);
|
|
193
194
|
await git(opts.repo, args);
|
|
194
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* NOT-280: `git status --porcelain` without the opportunistic index refresh — plain status
|
|
198
|
+
* rewrites a stale-stat index, which would mutate a checkout a refusal path must leave
|
|
199
|
+
* untouched (outside root, wrong branch, unknown owner).
|
|
200
|
+
*/
|
|
201
|
+
async function readOnlyStatus(worktreePath) {
|
|
202
|
+
return (await git(worktreePath, ["--no-optional-locks", "status", "--porcelain"])).stdout;
|
|
203
|
+
}
|
|
195
204
|
export async function isWorktreeClean(worktreePath) {
|
|
196
|
-
|
|
197
|
-
return stdout.trim().length === 0;
|
|
205
|
+
return (await readOnlyStatus(worktreePath)).trim().length === 0;
|
|
198
206
|
}
|
|
199
207
|
/** Commit message for a timeout salvage tip (NOT-145). */
|
|
200
208
|
export const SALVAGE_TIMEOUT_MESSAGE = "wip: timeout salvage";
|
|
201
209
|
/** Commit message for a crash/non-zero exit salvage tip (NOT-145). */
|
|
202
210
|
export const SALVAGE_CRASH_MESSAGE = "wip: crash salvage";
|
|
211
|
+
/** Commit message for a dead predecessor's dirt salvaged before a new attempt (NOT-280). */
|
|
212
|
+
export const SALVAGE_RESUME_MESSAGE = "wip: resume salvage";
|
|
213
|
+
const SALVAGE_MESSAGES = {
|
|
214
|
+
timeout: SALVAGE_TIMEOUT_MESSAGE,
|
|
215
|
+
crash: SALVAGE_CRASH_MESSAGE,
|
|
216
|
+
resume: SALVAGE_RESUME_MESSAGE,
|
|
217
|
+
};
|
|
203
218
|
/**
|
|
204
219
|
* NOT-145: stage everything and commit a clearly marked salvage tip so a timeout/crash
|
|
205
220
|
* can remove the worktree for retry without silently discarding uncommitted WIP.
|
|
@@ -207,7 +222,7 @@ export const SALVAGE_CRASH_MESSAGE = "wip: crash salvage";
|
|
|
207
222
|
* (parent NOT-143), not a preserved dirty checkout the next attempt cannot adopt.
|
|
208
223
|
*/
|
|
209
224
|
export async function salvageDirtyWorktree(worktreePath, kind) {
|
|
210
|
-
const message = kind
|
|
225
|
+
const message = SALVAGE_MESSAGES[kind];
|
|
211
226
|
try {
|
|
212
227
|
await git(worktreePath, ["add", "-A"]);
|
|
213
228
|
// Explicit identity: coordinator-managed worktrees may lack user.name/email, and a
|
|
@@ -690,6 +705,96 @@ export function sessionIdFromRoleWorktreePath(worktreePath) {
|
|
|
690
705
|
const m = /^(.*)-(developer|reviewer)$/.exec(base);
|
|
691
706
|
return m?.[1] ?? null;
|
|
692
707
|
}
|
|
708
|
+
function sha256Hex(parts) {
|
|
709
|
+
return createHash("sha256").update(parts.join("\0")).digest("hex");
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* NOT-280: stable identity of a leftover's blocking state. Best-effort reads of HEAD and
|
|
713
|
+
* status: an unreadable checkout still fingerprints (by path + reason), so repeated
|
|
714
|
+
* Resumes against the same corrupt tree dedupe too.
|
|
715
|
+
*/
|
|
716
|
+
export async function leftoverFingerprint(worktreePath, reason) {
|
|
717
|
+
const head = await git(worktreePath, ["rev-parse", "HEAD"]).then((r) => r.stdout.trim(), () => "");
|
|
718
|
+
const status = await readOnlyStatus(worktreePath).catch(() => "");
|
|
719
|
+
return sha256Hex([tryRealpath(worktreePath), reason, head, status]);
|
|
720
|
+
}
|
|
721
|
+
/** Unmerged index entries (`git status --porcelain` XY codes) — a half-resolved merge. */
|
|
722
|
+
const UNMERGED_STATUS = /^(DD|AU|UD|UA|DU|AA|UU) /m;
|
|
723
|
+
/**
|
|
724
|
+
* NOT-280: commit a proven-dead predecessor's dirty managed checkout on the issue branch
|
|
725
|
+
* so the next attempt can continue from that tip. Inspects before touching anything:
|
|
726
|
+
* the checkout must be on exactly `refs/heads/<branch>` with no in-progress merge/
|
|
727
|
+
* cherry-pick/revert and no unmerged entries — anything else fails closed. A failed
|
|
728
|
+
* commit (e.g. a rejecting hook) restores the index it staged, so the checkout is left
|
|
729
|
+
* exactly as found.
|
|
730
|
+
*/
|
|
731
|
+
export async function salvagePredecessorWorktree(worktreePath, branchName) {
|
|
732
|
+
let headRef;
|
|
733
|
+
let status;
|
|
734
|
+
let indexTree;
|
|
735
|
+
// Branch identity first, so a wrong-branch checkout is refused before any status read.
|
|
736
|
+
try {
|
|
737
|
+
headRef = (await git(worktreePath, ["symbolic-ref", "-q", "HEAD"])).stdout.trim();
|
|
738
|
+
}
|
|
739
|
+
catch (err) {
|
|
740
|
+
return { ok: false, reason: `inspection failed: ${err.message}` };
|
|
741
|
+
}
|
|
742
|
+
if (headRef !== `refs/heads/${branchName}`) {
|
|
743
|
+
return { ok: false, reason: `checkout is on ${headRef || "a detached HEAD"}, not refs/heads/${branchName}` };
|
|
744
|
+
}
|
|
745
|
+
try {
|
|
746
|
+
status = await readOnlyStatus(worktreePath);
|
|
747
|
+
}
|
|
748
|
+
catch (err) {
|
|
749
|
+
return { ok: false, reason: `inspection failed: ${err.message}` };
|
|
750
|
+
}
|
|
751
|
+
if (UNMERGED_STATUS.test(status)) {
|
|
752
|
+
return { ok: false, reason: "checkout has unmerged paths (an unfinished merge/rebase)" };
|
|
753
|
+
}
|
|
754
|
+
for (const marker of ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]) {
|
|
755
|
+
if (await refExists(worktreePath, marker)) {
|
|
756
|
+
return { ok: false, reason: `checkout has an in-progress operation (${marker} exists)` };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
indexTree = (await git(worktreePath, ["write-tree"])).stdout.trim();
|
|
761
|
+
}
|
|
762
|
+
catch (err) {
|
|
763
|
+
return { ok: false, reason: `inspection failed: ${err.message}` };
|
|
764
|
+
}
|
|
765
|
+
const headBefore = await revParseHead(worktreePath);
|
|
766
|
+
const salvaged = await salvageDirtyWorktree(worktreePath, "resume");
|
|
767
|
+
if (salvaged.ok)
|
|
768
|
+
return salvaged;
|
|
769
|
+
// Undo the `git add -A` staging when the commit itself did not land; a landed commit
|
|
770
|
+
// with residual dirt keeps its commit (the work is saved) and still fails closed.
|
|
771
|
+
// A failed rollback leaves the salvage's staging in place, so it is reported alongside
|
|
772
|
+
// the commit failure rather than claiming the checkout was left as found.
|
|
773
|
+
const headAfter = await revParseHead(worktreePath).catch(() => null);
|
|
774
|
+
if (headAfter === headBefore) {
|
|
775
|
+
try {
|
|
776
|
+
await git(worktreePath, ["read-tree", indexTree]);
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
return {
|
|
780
|
+
ok: false,
|
|
781
|
+
reason: `${salvaged.reason}; restoring the index afterwards also failed (git read-tree ${indexTree}): ${err.message} — files in ${worktreePath} are untouched but \`git add -A\` staging remains`,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return { ok: false, reason: salvaged.reason };
|
|
786
|
+
}
|
|
787
|
+
/** Whether HEAD's subject is a resume-salvage tip (restart after the commit landed). */
|
|
788
|
+
async function headIsResumeSalvage(worktreePath) {
|
|
789
|
+
try {
|
|
790
|
+
const { stdout } = await git(worktreePath, ["log", "-1", "--format=%H%x00%s"]);
|
|
791
|
+
const [sha, subject] = stdout.trim().split("\0");
|
|
792
|
+
return subject === SALVAGE_RESUME_MESSAGE && sha ? sha : null;
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
693
798
|
/**
|
|
694
799
|
* The developer-setup half of design §"Worktree lifecycle and concurrency"'s crash-recovery
|
|
695
800
|
* requirement: a leftover worktree from an earlier round/escalation on the SAME issue branch
|
|
@@ -707,31 +812,40 @@ export function sessionIdFromRoleWorktreePath(worktreePath) {
|
|
|
707
812
|
*/
|
|
708
813
|
export async function resolveDeveloperWorktree(opts) {
|
|
709
814
|
return withRepoLock(opts.repo, async () => {
|
|
710
|
-
|
|
711
|
-
//
|
|
815
|
+
// NOT-280: no up-front `git worktree prune` — it deletes `.git/worktrees/*` admin
|
|
816
|
+
// entries, so it runs only once no refusal (outside root, live owner, dirt, unreadable
|
|
817
|
+
// checkout) applies: for a vanished leftover here, otherwise right before the add below.
|
|
818
|
+
// NOT-219: a repair round reuses an existing issue branch, so fetch
|
|
712
819
|
// `origin/<branch>` — the worktree must start at exactly what Dealer pushed,
|
|
713
820
|
// never the managed clone's possibly-stale local ref. A failed fetch defers the
|
|
714
821
|
// start (`base_unavailable`) instead of falling back to the stale local branch.
|
|
822
|
+
// NOT-280: the fetch runs only once every refusal check (outside root, live owner,
|
|
823
|
+
// unknown-owner dirt, unreadable checkout) has passed, so a refused leftover leaves
|
|
824
|
+
// the shared repository's refs, FETCH_HEAD and objects untouched.
|
|
825
|
+
const fetchReuse = async () => opts.reuseBranch
|
|
826
|
+
? fetchReusedBranch(opts.repo, opts.branchName, opts.fetchTimeoutMs)
|
|
827
|
+
: { ok: true, remoteSha: null };
|
|
715
828
|
let reuseRemoteSha = null;
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
829
|
+
let existing = await findWorktreeForBranch(opts.repo, opts.branchName);
|
|
830
|
+
if (existing && !fs.existsSync(existing)) {
|
|
831
|
+
// A registered checkout whose directory is gone is a stale administrative entry
|
|
832
|
+
// (pre-NOT-280 this was pruned before the lookup): prune it and look again.
|
|
833
|
+
await pruneWorktrees(opts.repo);
|
|
834
|
+
existing = await findWorktreeForBranch(opts.repo, opts.branchName);
|
|
722
835
|
}
|
|
723
|
-
const existing = await findWorktreeForBranch(opts.repo, opts.branchName);
|
|
724
836
|
if (existing) {
|
|
725
837
|
const managedRoot = opts.worktreePath
|
|
726
838
|
? tryRealpath(path.dirname(opts.worktreePath))
|
|
727
839
|
: tryRealpath(worktreesRoot(opts.repo));
|
|
728
840
|
const underRoot = tryRealpath(existing).startsWith(managedRoot + path.sep);
|
|
729
841
|
if (!underRoot) {
|
|
842
|
+
const reason = `Branch ${opts.branchName} is already checked out at ${existing}, outside the coordinator's managed worktrees — it cannot be safely reused or removed automatically.`;
|
|
730
843
|
return {
|
|
731
844
|
kind: "conflict",
|
|
732
845
|
path: existing,
|
|
733
|
-
reason
|
|
846
|
+
reason,
|
|
734
847
|
recoveryCommands: [`git -C ${opts.repo} worktree list`, `# free the branch, then Resume: cd ${existing} && git status`],
|
|
848
|
+
fingerprint: await leftoverFingerprint(existing, reason),
|
|
735
849
|
};
|
|
736
850
|
}
|
|
737
851
|
// Live owner first — clean/dirty are only meaningful once the predecessor is gone.
|
|
@@ -744,8 +858,45 @@ export async function resolveDeveloperWorktree(opts) {
|
|
|
744
858
|
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.`,
|
|
745
859
|
};
|
|
746
860
|
}
|
|
747
|
-
|
|
861
|
+
// NOT-280: only a known Dealer session proven dead may have its dirt salvaged.
|
|
862
|
+
const deadPredecessor = owner.state === "dead" ? (owner.sessionId ?? null) : null;
|
|
863
|
+
let state = await inspectLeftoverWorktree(existing);
|
|
864
|
+
let salvage;
|
|
865
|
+
let salvageFailure = null;
|
|
866
|
+
if (state === "dirty_or_unpushed" && deadPredecessor) {
|
|
867
|
+
const salvaged = await salvagePredecessorWorktree(existing, opts.branchName);
|
|
868
|
+
if (salvaged.ok) {
|
|
869
|
+
salvage = {
|
|
870
|
+
commitSha: salvaged.commitSha,
|
|
871
|
+
message: salvaged.message,
|
|
872
|
+
predecessorSessionId: deadPredecessor,
|
|
873
|
+
recovered: false,
|
|
874
|
+
};
|
|
875
|
+
state = await inspectLeftoverWorktree(existing);
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
salvageFailure = salvaged.reason;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
else if (state === "clean" && deadPredecessor) {
|
|
882
|
+
// Restart after the salvage commit landed but before the attempt continued: the
|
|
883
|
+
// saved tip is detected, never re-committed.
|
|
884
|
+
const landed = await headIsResumeSalvage(existing);
|
|
885
|
+
if (landed) {
|
|
886
|
+
salvage = {
|
|
887
|
+
commitSha: landed,
|
|
888
|
+
message: SALVAGE_RESUME_MESSAGE,
|
|
889
|
+
predecessorSessionId: deadPredecessor,
|
|
890
|
+
recovered: true,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
}
|
|
748
894
|
if (state === "clean") {
|
|
895
|
+
const reused = await fetchReuse();
|
|
896
|
+
if (!reused.ok) {
|
|
897
|
+
return { kind: "base_unavailable", reason: reused.reason };
|
|
898
|
+
}
|
|
899
|
+
reuseRemoteSha = reused.remoteSha;
|
|
749
900
|
// NOT-219: a clean leftover holding a stale local branch advances to the
|
|
750
901
|
// fetched remote tip when that is a strict fast-forward (nothing unique to
|
|
751
902
|
// lose — the local tip is already an ancestor of the remote one). Any other
|
|
@@ -758,19 +909,26 @@ export async function resolveDeveloperWorktree(opts) {
|
|
|
758
909
|
await git(existing, ["merge", "--ff-only", "-q", `origin/${opts.branchName}`]).catch(() => null);
|
|
759
910
|
}
|
|
760
911
|
}
|
|
761
|
-
return { kind: "reused", path: tryRealpath(existing) };
|
|
912
|
+
return { kind: "reused", path: tryRealpath(existing), ...(salvage ? { salvage } : {}) };
|
|
762
913
|
}
|
|
763
914
|
if (state === "dirty_or_unpushed") {
|
|
915
|
+
const base = `A previous developer worktree for branch ${opts.branchName} still holds it at ${existing} with uncommitted changes.`;
|
|
916
|
+
const reason = salvageFailure
|
|
917
|
+
? `${base} Automatic salvage of dead session ${deadPredecessor}'s work failed and the checkout was preserved (not removed or reset): ${salvageFailure}`
|
|
918
|
+
: salvage
|
|
919
|
+
? `${base} Salvage tip ${salvage.commitSha.slice(0, 7)} landed but the checkout is still dirty.`
|
|
920
|
+
: base;
|
|
764
921
|
return {
|
|
765
922
|
kind: "conflict",
|
|
766
923
|
path: existing,
|
|
767
|
-
reason
|
|
924
|
+
reason,
|
|
768
925
|
recoveryCommands: [
|
|
769
926
|
`cd ${existing}`,
|
|
770
927
|
"git status",
|
|
771
928
|
"git log --oneline -5",
|
|
772
929
|
`# once the work there is safe (pushed or intentionally discarded): git -C ${opts.repo} worktree remove ${existing} --force`,
|
|
773
930
|
],
|
|
931
|
+
fingerprint: await leftoverFingerprint(existing, reason),
|
|
774
932
|
};
|
|
775
933
|
}
|
|
776
934
|
// inspectLeftoverWorktree reports "missing" for ANY `git status` failure, not just a
|
|
@@ -780,23 +938,34 @@ export async function resolveDeveloperWorktree(opts) {
|
|
|
780
938
|
// below — a narrower repeat of the exact loop this function exists to close). Only a
|
|
781
939
|
// truly gone directory is safe to treat as a stale administrative entry.
|
|
782
940
|
if (fs.existsSync(existing)) {
|
|
941
|
+
const reason = `A previous developer worktree for branch ${opts.branchName} exists at ${existing} but its status could not be determined.`;
|
|
783
942
|
return {
|
|
784
943
|
kind: "conflict",
|
|
785
944
|
path: existing,
|
|
786
|
-
reason
|
|
945
|
+
reason,
|
|
787
946
|
recoveryCommands: [
|
|
788
947
|
`cd ${existing}`,
|
|
789
948
|
"git status",
|
|
790
949
|
`# once resolved: git -C ${opts.repo} worktree remove ${existing} --force`,
|
|
791
950
|
],
|
|
951
|
+
fingerprint: await leftoverFingerprint(existing, reason),
|
|
792
952
|
};
|
|
793
953
|
}
|
|
794
|
-
await pruneWorktrees(opts.repo);
|
|
795
954
|
}
|
|
955
|
+
// Clear stale registrations (e.g. a vanished checkout at the target path) before the
|
|
956
|
+
// add — every refusal path above has already returned without mutating anything.
|
|
957
|
+
await pruneWorktrees(opts.repo);
|
|
796
958
|
// NOT-197: a fresh issue branch starts from the freshly fetched
|
|
797
959
|
// `origin/<baseBranch>` tip — never the cached clone's possibly-stale local base.
|
|
798
960
|
// A failed fetch returns `base_unavailable` (no branch is created) instead of
|
|
799
961
|
// silently falling back to the stale local branch.
|
|
962
|
+
if (opts.reuseBranch) {
|
|
963
|
+
const reused = await fetchReuse();
|
|
964
|
+
if (!reused.ok) {
|
|
965
|
+
return { kind: "base_unavailable", reason: reused.reason };
|
|
966
|
+
}
|
|
967
|
+
reuseRemoteSha = reused.remoteSha;
|
|
968
|
+
}
|
|
800
969
|
let ref;
|
|
801
970
|
let newBranch;
|
|
802
971
|
let baseSha = null;
|