agent-dealer 1.2.4 → 1.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +28 -6
- package/bundle/server/dist/adapters/agent-health.test.js +66 -11
- 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/github.js +110 -12
- package/bundle/server/dist/adapters/github.test.js +274 -3
- package/bundle/server/dist/adapters/muse-capability.js +356 -0
- package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
- package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
- package/bundle/server/dist/capacity/muse-host.js +92 -32
- package/bundle/server/dist/capacity/muse-host.test.js +130 -4
- package/bundle/server/dist/coordinator/admission.test.js +85 -0
- 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 +100 -12
- package/bundle/server/dist/coordinator/developer-effect.js +128 -16
- package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
- package/bundle/server/dist/coordinator/human-resolution.js +33 -1
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +25 -6
- package/bundle/server/dist/coordinator/prompts.test.js +36 -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 +9 -1
- package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
- package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
- 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/human-actions.js +10 -1
- package/bundle/server/dist/routes/human-actions.test.js +117 -1
- package/bundle/server/dist/routes/index.js +12 -5
- package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
- 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-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
- package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +15 -15
- package/bundle/shared/dist/agents.js +6 -0
- package/bundle/shared/dist/index.d.ts +7 -7
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +7 -3
- package/dist/doctor.js +21 -13
- package/dist/doctor.test.js +9 -8
- 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-UO4lHZw4.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,
|
|
@@ -4,6 +4,7 @@ import { CODEX_AUTH_REMEDIATION, MUSE_AUTH_REMEDIATION, cursorAuthIssueFromOutpu
|
|
|
4
4
|
import { claudeBinExists, cursorBinExists, resolveClaudeBin, cursorInvokeArgs, resolveCursorBin, resolveCodexBin, codexBinExists, resolveMuseBin, museBinExists, resolveMuseAuthFile, MUSE_CLI_ENV, } from "../cli-env.js";
|
|
5
5
|
import { checkAgentDeckHealth, checkAgentDeckMcpRegistration, fetchDecks, } from "./agent-deck.js";
|
|
6
6
|
import { runtimeAvailability } from "../repository/runtime-availability.js";
|
|
7
|
+
import { museCapabilityCheckInFlight, museCapabilityIssues, museCapabilitySettleCount, parseMuseVersion, resetMuseCapabilityStateForTests, } from "./muse-capability.js";
|
|
7
8
|
const RUNTIME_LABEL = {
|
|
8
9
|
claude_code: "Claude",
|
|
9
10
|
cursor_local: "Cursor",
|
|
@@ -112,6 +113,7 @@ export function clearAgentHealthCaches() {
|
|
|
112
113
|
githubIssueCache = null;
|
|
113
114
|
cursorSoftFailStreak = 0;
|
|
114
115
|
cursorLastHealthyAt = null;
|
|
116
|
+
resetMuseCapabilityStateForTests();
|
|
115
117
|
}
|
|
116
118
|
function isSoftCursorProbeIssue(issue) {
|
|
117
119
|
return (issue.code === "runtime_auth" &&
|
|
@@ -195,6 +197,11 @@ async function cursorRuntimeIssues() {
|
|
|
195
197
|
* billed `muse exec`, so a *present* but expired login is not detectable here — it surfaces at
|
|
196
198
|
* the first run, which the same classifier reads from stderr. `auth.json` is tested for
|
|
197
199
|
* existence only, never read.
|
|
200
|
+
*
|
|
201
|
+
* NOT-277: with CLI and credentials present, the reported version is handed to the capability
|
|
202
|
+
* check — a version not yet checked runs one real developer-shell probe (muse-capability.ts);
|
|
203
|
+
* `runtime_capability` blocks while it runs, when it finds shell/write missing, or when it could
|
|
204
|
+
* not complete.
|
|
198
205
|
*/
|
|
199
206
|
async function museRuntimeIssues() {
|
|
200
207
|
const bin = resolveMuseBin();
|
|
@@ -224,7 +231,17 @@ async function museRuntimeIssues() {
|
|
|
224
231
|
if (!process.env.META_API_KEY && !fs.existsSync(resolveMuseAuthFile())) {
|
|
225
232
|
return [{ code: "runtime_auth", message: MUSE_AUTH_REMEDIATION }];
|
|
226
233
|
}
|
|
227
|
-
|
|
234
|
+
const version = parseMuseVersion(ver.output);
|
|
235
|
+
if (!version) {
|
|
236
|
+
return [
|
|
237
|
+
{
|
|
238
|
+
code: "runtime_unknown",
|
|
239
|
+
message: "Could not determine Muse Code health — `muse --version` printed no version",
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
// A settled check drops the cached result so admission sees it on its next health read.
|
|
244
|
+
return museCapabilityIssues(version, () => runtimeIssueCache.delete("muse_code"));
|
|
228
245
|
}
|
|
229
246
|
/** Exported for direct testing — bypasses the 60s cache in runtimeIssues(). */
|
|
230
247
|
export async function runtimeIssuesUncached(runtime) {
|
|
@@ -427,12 +444,18 @@ async function runtimeIssues(runtime) {
|
|
|
427
444
|
if (cached && Date.now() - cached.at < ttl) {
|
|
428
445
|
return [...capIssues, ...cached.issues];
|
|
429
446
|
}
|
|
447
|
+
const museSettles = museCapabilitySettleCount();
|
|
430
448
|
const issues = await runtimeIssuesUncached(runtime);
|
|
431
449
|
const nonCap = issues.filter((i) => i.code !== "usage_capped");
|
|
450
|
+
// NOT-277: a capability check that settled during this read already superseded what it returned.
|
|
451
|
+
if (runtime === "muse_code" && museCapabilitySettleCount() !== museSettles) {
|
|
452
|
+
return runtimeIssues(runtime);
|
|
453
|
+
}
|
|
432
454
|
// Soft fail (published or grace-held) uses a short TTL so a wake retry can clear quickly;
|
|
433
455
|
// a sticky 60s cache of "Could not confirm" is what parked the queue after sleep (NOT-157).
|
|
434
456
|
const softProbeFailure = nonCap.some(isSoftCursorProbeIssue) ||
|
|
435
|
-
(runtime === "cursor_local" && cursorSoftFailStreak > 0)
|
|
457
|
+
(runtime === "cursor_local" && cursorSoftFailStreak > 0) ||
|
|
458
|
+
(runtime === "muse_code" && museCapabilityCheckInFlight());
|
|
436
459
|
runtimeIssueCache.set(runtime, { at: Date.now(), issues: nonCap, softProbeFailure });
|
|
437
460
|
return [...capIssues, ...nonCap];
|
|
438
461
|
}
|
|
@@ -446,10 +469,9 @@ function resolveMcpRegistration(mcpRegistered) {
|
|
|
446
469
|
}
|
|
447
470
|
function agentSpecificIssues(agent, agentDeckOnline, mcpRegistration, deckAccessResult) {
|
|
448
471
|
const issues = [];
|
|
449
|
-
// NOT-
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
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.
|
|
453
475
|
if (!agent.deckId) {
|
|
454
476
|
issues.push({
|
|
455
477
|
code: "deck_missing",
|
|
@@ -16,7 +16,10 @@ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-ag
|
|
|
16
16
|
const { migrate, getDb } = await import("../db/index.js");
|
|
17
17
|
const { createAgent, getAgent } = await import("../repository/agents.js");
|
|
18
18
|
const { healthForAgent, runtimeIssuesUncached, githubIssuesUncached, githubIssuesSync, classifyGithubAuthStatus, clearAgentHealthCaches, setCursorProbeTimingForTests, setRunCommandForTests, } = await import("./agent-health.js");
|
|
19
|
+
const { setMuseCapabilityProbeForTests, settleMuseCapabilityCheckForTests } = await import("./muse-capability.js");
|
|
19
20
|
migrate();
|
|
21
|
+
// NOT-277: health never runs the real (billed) Muse capability probe in tests.
|
|
22
|
+
setMuseCapabilityProbeForTests(async () => ({ status: "capable" }));
|
|
20
23
|
const FAILURE = { ok: false, code: "DECK_UNAVAILABLE", message: "Agent Deck API error: 502" };
|
|
21
24
|
/** Tests inject an empty github list so host `gh auth` does not pollute assertions. */
|
|
22
25
|
const NO_GITHUB = [];
|
|
@@ -34,18 +37,20 @@ describe("agent-health", { concurrency: false }, () => {
|
|
|
34
37
|
assert.equal(result.issues.some((i) => i.code === "deck_missing"), true);
|
|
35
38
|
assert.equal(result.healthy, false);
|
|
36
39
|
});
|
|
37
|
-
// NOT-
|
|
38
|
-
|
|
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 () => {
|
|
39
43
|
const created = createAgent({ name: "muse-no-deck", runtime: "muse_code", deckId: randomUUID() });
|
|
40
44
|
getDb().prepare("UPDATE agents SET deck_id = NULL WHERE id = ?").run(created.id);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
]
|
|
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) {
|
|
46
51
|
const result = await healthForAgent(agent, online, new Map(), true, FAILURE, NO_GITHUB);
|
|
47
|
-
assert.
|
|
48
|
-
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);
|
|
49
54
|
}
|
|
50
55
|
});
|
|
51
56
|
test("agent deck offline: reports deck_offline, not deck_unauthorized", async () => {
|
|
@@ -414,8 +419,14 @@ The keychain item is stuck. Delete it and sign in again:
|
|
|
414
419
|
const withLogin = emptyConfigHome();
|
|
415
420
|
fs.mkdirSync(path.join(withLogin, "muse"));
|
|
416
421
|
fs.writeFileSync(path.join(withLogin, "muse", "auth.json"), "{}");
|
|
417
|
-
|
|
418
|
-
|
|
422
|
+
// NOT-277: the first read of a version starts its one-time capability check; healthy once settled.
|
|
423
|
+
const settledIssues = async () => {
|
|
424
|
+
await runtimeIssuesUncached("muse_code");
|
|
425
|
+
await settleMuseCapabilityCheckForTests();
|
|
426
|
+
return runtimeIssuesUncached("muse_code");
|
|
427
|
+
};
|
|
428
|
+
assert.deepEqual(await withMuseEnv({ MUSE_CLI: stub.bin, configHome: withLogin }, settledIssues), []);
|
|
429
|
+
assert.deepEqual(await withMuseEnv({ MUSE_CLI: stub.bin, META_API_KEY: "k", configHome: emptyConfigHome() }, settledIssues), []);
|
|
419
430
|
});
|
|
420
431
|
test("muse probes run with MUSE_NO_AUTO_UPDATE=1 so the launcher cannot swap the pinned version", async () => {
|
|
421
432
|
const stub = stubMuse("echo 'Muse Code 1.3.0 (1.3.0-R3401.1)'");
|
|
@@ -442,6 +453,50 @@ The keychain item is stuck. Delete it and sign in again:
|
|
|
442
453
|
setCursorProbeTimingForTests(null);
|
|
443
454
|
}
|
|
444
455
|
});
|
|
456
|
+
// NOT-277: through the cached health path the agent list / admission read — a Muse auto-update
|
|
457
|
+
// fires the capability check exactly once for the new version, and the result (not a generic
|
|
458
|
+
// symptom) is what the unhealthy agent shows.
|
|
459
|
+
test("a Muse version change is capability-checked once and its named result surfaces on the agent", async () => {
|
|
460
|
+
const OLD = "1.3.0-R3401.1";
|
|
461
|
+
const NEW = "1.4.0-R4161.1";
|
|
462
|
+
const versionFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-ver-")), "v");
|
|
463
|
+
fs.writeFileSync(versionFile, `Muse Code 1.3.0 (${OLD})\n`);
|
|
464
|
+
const stub = stubMuse(`cat ${JSON.stringify(versionFile)}`);
|
|
465
|
+
const calls = [];
|
|
466
|
+
setMuseCapabilityProbeForTests(async (version) => {
|
|
467
|
+
calls.push(version);
|
|
468
|
+
return version === OLD
|
|
469
|
+
? { status: "capable" }
|
|
470
|
+
: { status: "missing", detail: "probe session completed without running its shell command" };
|
|
471
|
+
});
|
|
472
|
+
const agent = createAgent({ name: "muse-updated", runtime: "muse_code", deckId: randomUUID() });
|
|
473
|
+
const health = () => healthForAgent(agent, true, undefined, true, null, NO_GITHUB);
|
|
474
|
+
try {
|
|
475
|
+
await withMuseEnv({ MUSE_CLI: stub.bin, META_API_KEY: "k", configHome: emptyConfigHome() }, async () => {
|
|
476
|
+
// The stub probe settles within this read; the health read must not cache its stale
|
|
477
|
+
// "verifying" block (the in-flight block itself is covered in muse-capability.test.ts).
|
|
478
|
+
await health();
|
|
479
|
+
await settleMuseCapabilityCheckForTests();
|
|
480
|
+
for (let i = 0; i < 3; i++)
|
|
481
|
+
assert.deepEqual((await health()).issues, []);
|
|
482
|
+
assert.deepEqual(calls, [OLD]);
|
|
483
|
+
// Muse auto-updates. The 60s runtime cache notices on its next miss.
|
|
484
|
+
fs.writeFileSync(versionFile, `Muse Code 1.4.0 (${NEW})\n`);
|
|
485
|
+
await runtimeIssuesUncached("muse_code");
|
|
486
|
+
await settleMuseCapabilityCheckForTests();
|
|
487
|
+
for (let i = 0; i < 3; i++) {
|
|
488
|
+
const after = await health();
|
|
489
|
+
assert.equal(after.healthy, false);
|
|
490
|
+
assert.deepEqual(after.issues.map((x) => x.code), ["runtime_capability"]);
|
|
491
|
+
assert.match(after.issues[0].message, new RegExp(`Muse Code updated ${OLD} → ${NEW}: developer sessions no longer get shell/write access`));
|
|
492
|
+
}
|
|
493
|
+
assert.deepEqual(calls, [OLD, NEW]);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
setMuseCapabilityProbeForTests(async () => ({ status: "capable" }));
|
|
498
|
+
}
|
|
499
|
+
});
|
|
445
500
|
test("claude MCP endpoint mismatch surfaces a distinct message, not the generic setup hint", async () => {
|
|
446
501
|
const agent = createAgent({
|
|
447
502
|
name: "claude-mcp-mismatch",
|
|
@@ -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;
|