agent-dealer 1.2.9 → 1.2.10

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.
@@ -16,6 +16,16 @@
16
16
  // dismiss the approval the merge depended on in repos with dismiss-stale-
17
17
  // approvals; that retry failure then takes this same direct-escalation path.
18
18
  //
19
+ // NOT-354 adds a second entry point, `checks_wait`: a developer PR that conflicts
20
+ // with its base while Dealer waits for CI never gets checks (GitHub creates no
21
+ // merge ref), so the developer effect runs this same sync instead of deferring
22
+ // "Waiting for CI (queued)" forever. That entry stops at the push (no checks
23
+ // poll, no merge retry — the deferred CI wait re-polls the pushed head) and does
24
+ // not queue the repair round itself: the effect still holds its work item's
25
+ // lease, so applyCompletion queues it through {@link queueConflictRepairRound}
26
+ // while finishing that item. One sync per episode there too — a later conflict
27
+ // after a checks-wait sync earns the repair round, never a second sync.
28
+ //
19
29
  // Bound: at most one automatic sync + one conflict-repair round per
20
30
  // merge-failure episode. The episode resets on any resolved human action (a
21
31
  // retry_merge / repair click starts a fresh episode); the repair-spent marker is
@@ -38,7 +48,7 @@ import { MERGE_CONFLICT_FILES_EVIDENCE_KEY, MERGE_FAILURE_EVIDENCE_KEY, } from "
38
48
  import { getAgent } from "../repository/agents.js";
39
49
  import { getDb } from "../db/index.js";
40
50
  import { getIssue, incrementIssueRound, transitionIssue } from "../repository/issues.js";
41
- import { enqueueWorkItem, listWorkItemsForIssue, } from "../repository/work-items.js";
51
+ import { enqueueWorkItem, finishWorkItem, listWorkItemsForIssue, } from "../repository/work-items.js";
42
52
  import { appendWorkflowEvent, getActiveWorkflowInstance, listWorkflowEventsForIssue, } from "../repository/workflow-events.js";
43
53
  import { addWorktree, DEFAULT_BASE_FETCH_TIMEOUT_MS, fastForwardLocalBranchToSha, fetchFreshBase, fetchReusedBranch, findWorktreeForBranch, isAncestor, isWorktreeClean, pruneWorktrees, removeWorktree, revParseHead, safeRemoveWorktree, } from "../adapters/git-worktree.js";
44
54
  import { classifyIssueRepo, worktreesRootForResolution, } from "../adapters/managed-repo.js";
@@ -180,6 +190,32 @@ export function conflictRepairSpent(issueId, instanceId) {
180
190
  ? { spent: true, files }
181
191
  : { spent: false, files: [] };
182
192
  }
193
+ /**
194
+ * NOT-354: whether a checks-wait sync already pushed the base this episode (same
195
+ * episode rule as {@link conflictRepairSpent}). Merge-entry syncs never count —
196
+ * that path retries the merge in the same call, so it cannot sync twice.
197
+ */
198
+ export function checksWaitSyncSpent(issueId, instanceId) {
199
+ const events = listWorkflowEventsForIssue(issueId).filter((e) => e.workflowInstanceId === instanceId);
200
+ let lastSync = -1;
201
+ let lastResolved = -1;
202
+ events.forEach((e, index) => {
203
+ if (e.type === "auto_merge.conflict_sync") {
204
+ try {
205
+ const payload = JSON.parse(e.payloadJson ?? "{}");
206
+ if (payload.entry === "checks_wait" && payload.outcome === "synced")
207
+ lastSync = index;
208
+ }
209
+ catch {
210
+ // unreadable audit payload cannot mark a sync
211
+ }
212
+ }
213
+ else if (e.type === "human_action.resolved") {
214
+ lastResolved = index;
215
+ }
216
+ });
217
+ return lastSync > lastResolved;
218
+ }
183
219
  /**
184
220
  * Mirrors commands.ts's `queuedProfileSnapshot` (kept local: auto-merge →
185
221
  * commands would close an import cycle — commands already imports auto-merge).
@@ -201,18 +237,20 @@ function conflictRepairWorkItemKey(issueId, instanceId, round) {
201
237
  }
202
238
  function auditSync(input, outcome, detail) {
203
239
  try {
240
+ const issue = getIssue(input.issueId);
204
241
  appendWorkflowEvent({
205
242
  issueId: input.issueId,
206
243
  workflowInstanceId: input.instanceId,
207
244
  workerSessionId: null,
208
245
  type: "auto_merge.conflict_sync",
209
246
  actorType: "system",
210
- stage: "final_review",
211
- round: getIssue(input.issueId)?.currentRound ?? null,
247
+ stage: input.entry === "merge" ? "final_review" : (issue?.status ?? "developing"),
248
+ round: issue?.currentRound ?? null,
212
249
  payload: {
213
250
  outcome,
214
251
  branch: input.branch,
215
252
  baseBranch: input.baseBranch,
253
+ ...(input.entry === "merge" ? {} : { entry: input.entry }),
216
254
  ...(detail ?? {}),
217
255
  },
218
256
  });
@@ -232,18 +270,34 @@ function formatFileList(files) {
232
270
  * with the conflict directive on the work-item payload for exactly this round.
233
271
  * Null when the issue left the merge park first (a racer won) — the caller
234
272
  * then falls back to today's escalation, which no-ops off-park itself.
273
+ *
274
+ * NOT-354 `checks_wait`: the issue is still in its developer stage (developing
275
+ * or repairing, which it keeps), and `finish` names the leased developer item
276
+ * the CI wait ran in — it is CAS-finished in the same transaction, so a lost
277
+ * lease queues nothing (null).
235
278
  */
236
- function queueConflictRepairRound(input) {
279
+ export function queueConflictRepairRound(input) {
237
280
  return getDb().transaction(() => {
238
281
  const current = getIssue(input.issueId);
239
- if (!current || current.status !== "final_review")
282
+ if (!current)
283
+ return null;
284
+ const fromStatuses = input.entry === "checks_wait" ? ["developing", "repairing"] : ["final_review"];
285
+ if (!fromStatuses.includes(current.status))
240
286
  return null;
241
287
  const active = getActiveWorkflowInstance(input.issueId);
242
288
  if (!active || active.id !== input.instanceId)
243
289
  return null;
290
+ if (input.finish &&
291
+ !finishWorkItem(input.finish.workItemId, input.finish.leaseToken, {
292
+ status: "done",
293
+ result: input.finish.result,
294
+ })) {
295
+ return null;
296
+ }
297
+ const toStatus = input.entry === "checks_wait" ? current.status : "repairing";
244
298
  const round = current.currentRound + 1;
245
299
  incrementIssueRound(input.issueId);
246
- transitionIssue(input.issueId, "repairing", {
300
+ transitionIssue(input.issueId, toStatus, {
247
301
  currentOwner: "developer",
248
302
  currentIntent: `Resolving merge conflict with ${input.baseBranch} (round ${round})`,
249
303
  });
@@ -253,7 +307,7 @@ function queueConflictRepairRound(input) {
253
307
  workerSessionId: null,
254
308
  type: "repair.started",
255
309
  actorType: "system",
256
- stage: "repairing",
310
+ stage: toStatus,
257
311
  round,
258
312
  });
259
313
  const directive = {
@@ -278,7 +332,7 @@ function queueConflictRepairRound(input) {
278
332
  workerSessionId: null,
279
333
  type: "auto_merge.conflict_repair_queued",
280
334
  actorType: "system",
281
- stage: "repairing",
335
+ stage: toStatus,
282
336
  round,
283
337
  payload: {
284
338
  baseBranch: input.baseBranch,
@@ -357,14 +411,27 @@ async function cleanupSyncCheckout(repoPath, syncPath) {
357
411
  * Runs the NOT-310 self-resolution for one not-mergeable failure. See the
358
412
  * module doc for the bound, the safety invariant, and the fail-closed shape:
359
413
  * every refusal returns `skipped` so the caller escalates exactly as today.
414
+ *
415
+ * `entry` defaults to the merge path; `checks_wait` (NOT-354) needs no
416
+ * `mergePr`, and its `mergeReason` is the whole escalation lead-in (there is no
417
+ * "Auto-merge failed" to report before review).
360
418
  */
361
419
  export async function runMergeConflictSync(opts) {
420
+ const entry = opts.entry ?? "merge";
421
+ const checksWait = entry === "checks_wait";
362
422
  const auditInput = {
363
423
  issueId: opts.issueId,
364
424
  instanceId: opts.instanceId,
365
425
  branch: opts.branch,
366
426
  baseBranch: opts.baseBranch,
427
+ entry,
367
428
  };
429
+ const lead = checksWait ? opts.mergeReason : `Auto-merge failed: ${opts.mergeReason}`;
430
+ // A merge failure's escalation offers retry_merge; a pre-review conflict has
431
+ // nothing to retry, so it keeps the default policy_escalation choices.
432
+ const escalationEvidence = checksWait
433
+ ? { checksConflict: true }
434
+ : { [MERGE_FAILURE_EVIDENCE_KEY]: true };
368
435
  const skip = (reason) => {
369
436
  auditSync(auditInput, "skipped", { reason });
370
437
  return { outcome: "skipped", reason };
@@ -373,8 +440,8 @@ export async function runMergeConflictSync(opts) {
373
440
  auditSync(auditInput, "failed", { detail });
374
441
  return {
375
442
  outcome: "escalate",
376
- reason: `Auto-merge failed: ${opts.mergeReason} (base sync ${opts.baseBranch}: ${detail})`,
377
- evidence: { [MERGE_FAILURE_EVIDENCE_KEY]: true },
443
+ reason: `${lead} (base sync ${opts.baseBranch}: ${detail})`,
444
+ evidence: escalationEvidence,
378
445
  };
379
446
  };
380
447
  // The conflict-repair round already ran for this episode and the merge still
@@ -387,15 +454,24 @@ export async function runMergeConflictSync(opts) {
387
454
  : "The conflicting files are unknown — the retry failed without a local merge.";
388
455
  return {
389
456
  outcome: "escalate",
390
- reason: `Auto-merge failed: ${opts.mergeReason} ` +
457
+ reason: `${lead} ` +
391
458
  `Dealer already synced ${opts.baseBranch} and ran one conflict-repair round; ` +
392
459
  `the PR still conflicts. ${filesText}`,
393
460
  evidence: {
394
- [MERGE_FAILURE_EVIDENCE_KEY]: true,
461
+ ...escalationEvidence,
395
462
  [MERGE_CONFLICT_FILES_EVIDENCE_KEY]: spent.files,
396
463
  },
397
464
  };
398
465
  }
466
+ // NOT-354: a checks-wait sync already pushed this episode and the PR conflicts
467
+ // again (the base moved under it) — the one repair round re-syncs and resolves,
468
+ // never a second automatic sync.
469
+ if (checksWait && checksWaitSyncSpent(opts.issueId, opts.instanceId)) {
470
+ auditSync(auditInput, "repair_needed", { files: [], baseMovedAgain: true });
471
+ return { outcome: "repair_needed", files: [] };
472
+ }
473
+ if (!checksWait && !opts.mergePr)
474
+ return skip("merge entry requires mergePr");
399
475
  if (!opts.branch.trim())
400
476
  return skip("issue has no branch to sync");
401
477
  let repoPath;
@@ -532,6 +608,11 @@ export async function runMergeConflictSync(opts) {
532
608
  if (unmerged.length === 0) {
533
609
  return failed(`merge failed: ${mergeError.message}`);
534
610
  }
611
+ if (checksWait) {
612
+ const files = unmerged.slice(0, CONFLICTING_FILES_MAX);
613
+ auditSync(auditInput, "repair_needed", { files: unmerged });
614
+ return { outcome: "repair_needed", files };
615
+ }
535
616
  const queued = queueConflictRepairRound({
536
617
  issueId: opts.issueId,
537
618
  instanceId: opts.instanceId,
@@ -563,6 +644,13 @@ export async function runMergeConflictSync(opts) {
563
644
  const detail = err instanceof Error ? err.message : String(err);
564
645
  return failed(`could not push the synced branch: ${detail}`);
565
646
  }
647
+ // NOT-354: the checks-wait entry stops at the push — the deferred CI wait
648
+ // re-polls the pushed head through the normal publish-only path.
649
+ if (checksWait) {
650
+ await cleanupSyncCheckout(repoPath, syncPath);
651
+ auditSync(auditInput, "synced", { baseSha: base.sha, headSha: headAfterMerge, mergeChangedHead });
652
+ return { outcome: "synced", headSha: headAfterMerge };
653
+ }
566
654
  // Wait for checks on the new head exactly as the developer effect does, then
567
655
  // retry the merge once. A failed/timed-out poll escalates directly — retrying
568
656
  // the merge against red or unknown checks cannot succeed.
@@ -119,3 +119,193 @@ test("conflictRepairSpent ignores other issues/instances and malformed file list
119
119
  assert.equal(spent.spent, true);
120
120
  assert.deepEqual(spent.files, []);
121
121
  });
122
+ // ---------------------------------------------------------------------------
123
+ // NOT-354: the checks-wait entry — a developer PR that conflicts while Dealer
124
+ // waits for CI. Real git (origin + managed clone); the sync's git shell-out is
125
+ // recorded so the safety rules are asserted on the exact commands issued.
126
+ const { execFileSync } = await import("node:child_process");
127
+ const { checksWaitSyncSpent, defaultSyncGitExec, queueConflictRepairRound, runMergeConflictSync, setConflictSyncGitExecForTests, } = await import("./merge-conflict-sync.js");
128
+ const { claimWorkItem, getWorkItem, listWorkItemsForIssue } = await import("../repository/work-items.js");
129
+ const { getIssue } = await import("../repository/issues.js");
130
+ /** origin + clone where `issue-1` is pushed, then origin/main moves. `conflict`
131
+ * makes the base edit the same line of shared.txt the branch edited. */
132
+ function initChecksWaitRepos(conflict) {
133
+ const g = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
134
+ const origin = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not354-origin-"));
135
+ g(origin, "init", "-q", "-b", "main");
136
+ g(origin, "config", "user.email", "test@example.com");
137
+ g(origin, "config", "user.name", "Test");
138
+ fs.writeFileSync(path.join(origin, "shared.txt"), "line1\nline2\n");
139
+ g(origin, "add", ".");
140
+ g(origin, "commit", "-q", "-m", "A base");
141
+ const local = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not354-local-"));
142
+ fs.rmSync(local, { recursive: true, force: true });
143
+ execFileSync("git", ["clone", "-q", origin, local]);
144
+ g(local, "config", "user.email", "test@example.com");
145
+ g(local, "config", "user.name", "Test");
146
+ g(local, "checkout", "-q", "-b", "issue-1");
147
+ fs.writeFileSync(path.join(local, "shared.txt"), "line1\nfeature change\n");
148
+ g(local, "add", ".");
149
+ g(local, "commit", "-q", "-m", "B feature");
150
+ g(local, "push", "-q", "-u", "origin", "issue-1");
151
+ g(local, "checkout", "-q", "main");
152
+ if (conflict)
153
+ fs.writeFileSync(path.join(origin, "shared.txt"), "line1\nbase change\n");
154
+ else
155
+ fs.writeFileSync(path.join(origin, "other.txt"), "c\n");
156
+ g(origin, "add", ".");
157
+ g(origin, "commit", "-q", "-m", "C base moved (sibling PR merged)");
158
+ return { origin, local, baseMovedSha: g(origin, "rev-parse", "HEAD") };
159
+ }
160
+ function developingIssueIn(repo) {
161
+ const issueId = createIssue({
162
+ title: "Conflicting PR",
163
+ description: "d",
164
+ acceptanceCriteria: "It works",
165
+ repo,
166
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
167
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
168
+ baseBranch: "main",
169
+ maxReviewRounds: 3,
170
+ maxInfraAttempts: 3,
171
+ source: "manual",
172
+ }).id;
173
+ startWorkflow(issueId);
174
+ return { issueId, instanceId: getActiveWorkflowInstance(issueId).id };
175
+ }
176
+ function recordGit() {
177
+ const calls = [];
178
+ setConflictSyncGitExecForTests(async (args, opts) => {
179
+ calls.push(args);
180
+ return defaultSyncGitExec(args, opts);
181
+ });
182
+ return calls;
183
+ }
184
+ function assertSafeGit(calls) {
185
+ for (const args of calls) {
186
+ assert.ok(!args.includes("rebase"), `never rebase: git ${args.join(" ")}`);
187
+ if (args[0] === "push") {
188
+ assert.ok(args.every((a) => !/^(-f|--force)/.test(a) && !a.startsWith("+")), `push must never force: git ${args.join(" ")}`);
189
+ }
190
+ }
191
+ }
192
+ function contains(repo, sha, ref) {
193
+ try {
194
+ execFileSync("git", ["merge-base", "--is-ancestor", sha, ref], { cwd: repo });
195
+ return true;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
201
+ const CHECKS_WAIT_REASON = "PR #7 conflicts with main (mergeable: CONFLICTING), so its CI cannot start.";
202
+ test("NOT-354: checks_wait clean sync merges the base and plain-pushes, then never syncs twice in the episode", async () => {
203
+ const { origin, local, baseMovedSha } = initChecksWaitRepos(false);
204
+ const { issueId, instanceId } = developingIssueIn(local);
205
+ const calls = recordGit();
206
+ try {
207
+ const sync = {
208
+ issueId,
209
+ instanceId,
210
+ repo: local,
211
+ branch: "issue-1",
212
+ baseBranch: "main",
213
+ prNumber: 7,
214
+ mergeReason: CHECKS_WAIT_REASON,
215
+ entry: "checks_wait",
216
+ };
217
+ const first = await runMergeConflictSync(sync);
218
+ assert.equal(first.outcome, "synced");
219
+ const originTip = execFileSync("git", ["rev-parse", "issue-1"], { cwd: origin, encoding: "utf8" }).trim();
220
+ assert.equal(first.outcome === "synced" ? first.headSha : null, originTip, "reports the pushed head");
221
+ assert.equal(contains(origin, baseMovedSha, "issue-1"), true, "origin's branch now contains the moved base");
222
+ assert.equal(calls.filter((a) => a.includes("merge") && a.includes("origin/main")).length, 1);
223
+ assert.deepEqual(calls.filter((a) => a[0] === "push"), [["push", "-u", "origin", "HEAD:refs/heads/issue-1"]]);
224
+ assertSafeGit(calls);
225
+ assert.equal(checksWaitSyncSpent(issueId, instanceId), true);
226
+ const root = path.join(local, ".agent-dealer-worktrees");
227
+ const leftovers = fs.existsSync(root) ? fs.readdirSync(root).filter((e) => e.startsWith("merge-sync-")) : [];
228
+ assert.deepEqual(leftovers, []);
229
+ // The base moved again and the PR conflicts again: the episode's one sync is
230
+ // spent, so the repair round is due — and no git command runs.
231
+ const before = calls.length;
232
+ const second = await runMergeConflictSync(sync);
233
+ assert.deepEqual(second, { outcome: "repair_needed", files: [] });
234
+ assert.equal(calls.length, before, "no second sync");
235
+ assert.equal(getIssue(issueId).status, "developing", "the sync itself never moves the issue");
236
+ }
237
+ finally {
238
+ setConflictSyncGitExecForTests(null);
239
+ }
240
+ });
241
+ test("NOT-354: checks_wait textual conflict aborts without pushing, one repair round queues on the leased item, then escalates once with the files", async () => {
242
+ const { local, baseMovedSha } = initChecksWaitRepos(true);
243
+ const { issueId, instanceId } = developingIssueIn(local);
244
+ const calls = recordGit();
245
+ try {
246
+ const sync = {
247
+ issueId,
248
+ instanceId,
249
+ repo: local,
250
+ branch: "issue-1",
251
+ baseBranch: "main",
252
+ prNumber: 7,
253
+ mergeReason: CHECKS_WAIT_REASON,
254
+ entry: "checks_wait",
255
+ };
256
+ const first = await runMergeConflictSync(sync);
257
+ assert.deepEqual(first, { outcome: "repair_needed", files: ["shared.txt"] });
258
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0, "a conflicted merge is never pushed");
259
+ assert.equal(contains(local, baseMovedSha, "issue-1"), false, "the merge was aborted");
260
+ assertSafeGit(calls);
261
+ // The developer item the CI wait ran in is still leased; the repair round
262
+ // queues while CAS-finishing it.
263
+ const leased = claimWorkItem("not354-test", { leaseMs: 60_000 });
264
+ assert.equal(leased.issueId, issueId);
265
+ assert.equal(queueConflictRepairRound({
266
+ issueId,
267
+ instanceId,
268
+ baseBranch: "main",
269
+ branch: "issue-1",
270
+ files: ["shared.txt"],
271
+ entry: "checks_wait",
272
+ finish: { workItemId: leased.id, leaseToken: "stale-token", result: {} },
273
+ }), null, "a lost lease queues nothing");
274
+ const queued = queueConflictRepairRound({
275
+ issueId,
276
+ instanceId,
277
+ baseBranch: "main",
278
+ branch: "issue-1",
279
+ files: ["shared.txt"],
280
+ entry: "checks_wait",
281
+ finish: { workItemId: leased.id, leaseToken: leased.leaseToken, result: {} },
282
+ });
283
+ assert.ok(queued);
284
+ assert.equal(getWorkItem(leased.id).status, "done");
285
+ const pending = listWorkItemsForIssue(issueId).filter((i) => i.status === "pending");
286
+ assert.equal(pending.length, 1);
287
+ assert.equal(pending[0].id, queued.id);
288
+ assert.deepEqual(JSON.parse(pending[0].payloadJson).conflictRepair, {
289
+ baseBranch: "main",
290
+ branch: "issue-1",
291
+ files: ["shared.txt"],
292
+ });
293
+ assert.equal(getIssue(issueId).status, "developing", "developing has no repairing edge — the stage is kept");
294
+ assert.equal(getIssue(issueId).currentRound, 2);
295
+ // The repair round's CI wait still sees a conflicting PR: escalate with the
296
+ // file list, no second sync.
297
+ const before = calls.length;
298
+ const again = await runMergeConflictSync(sync);
299
+ assert.equal(again.outcome, "escalate");
300
+ if (again.outcome === "escalate") {
301
+ assert.match(again.reason, /^PR #7 conflicts with main/);
302
+ assert.match(again.reason, /shared\.txt/);
303
+ assert.deepEqual(again.evidence.conflictingFiles, ["shared.txt"]);
304
+ assert.equal(again.evidence.mergeFailure, undefined);
305
+ }
306
+ assert.equal(calls.length, before, "no git command once the episode is spent");
307
+ }
308
+ finally {
309
+ setConflictSyncGitExecForTests(null);
310
+ }
311
+ });
@@ -22,9 +22,9 @@ import { resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
22
22
  import { getAgentDeckMcpUrl } from "../adapters/agent-deck.js";
23
23
  import { getWorkerMcpConfigDir } from "../paths.js";
24
24
  import { prepareMuseAttempt, } from "../runners/muse-config.js";
25
- import { parseMuseRun, } from "../runners/muse-code-jsonl.js";
25
+ import { parseMuseRun, createMusePrimaryTerminalMatcher, } from "../runners/muse-code-jsonl.js";
26
26
  import { spawnCli } from "../runners/spawn-cli.js";
27
- import { museIdleTimeoutMs } from "./session-timeouts.js";
27
+ import { museIdleTimeoutMs, museTerminalGraceMs } from "./session-timeouts.js";
28
28
  /**
29
29
  * Step cap when the profile sets no `maxTurns`. High on purpose (the PoC's value): the developer
30
30
  * wall-clock timeout is the real limit, the cap only stops a runaway loop.
@@ -40,6 +40,7 @@ export function museStallMetadata(summary) {
40
40
  lastToolName: summary.lastToolName,
41
41
  firstOutputMs: summary.firstOutputMs,
42
42
  idleTimedOut: summary.idleTimedOut,
43
+ lingeredAfterTerminal: summary.lingeredAfterTerminal,
43
44
  };
44
45
  }
45
46
  /** `sessions/YYYY/MM/DD/<session-id>/session.jsonl` under the attempt's data dir, if present. */
@@ -231,6 +232,10 @@ export async function runMuseDeveloperSession(input) {
231
232
  // NOT-307: silence bound for this session. Undefined disables the watchdog
232
233
  // (`MUSE_IDLE_TIMEOUT_MS=0`); only the Muse lane opts in.
233
234
  const idleTimeoutMs = museIdleTimeoutMs();
235
+ // NOT-342: post-terminal linger bound. Undefined disables (`MUSE_TERMINAL_GRACE_MS=0`).
236
+ // Stateful: only the primary run's terminal arms the grace (cron terminals must not).
237
+ const terminalGraceMs = museTerminalGraceMs();
238
+ const primaryTerminal = createMusePrimaryTerminalMatcher();
234
239
  try {
235
240
  // The exact launch about to happen — a tampered settings file or a differing
236
241
  // cwd/argv/env/stdin fails here, before any process exists.
@@ -250,6 +255,9 @@ export async function runMuseDeveloperSession(input) {
250
255
  onStdoutLine: (_line, atMs) => {
251
256
  lineTs.push(atMs);
252
257
  },
258
+ terminalGrace: terminalGraceMs !== undefined
259
+ ? { isTerminalLine: primaryTerminal.isTerminalLine, graceMs: terminalGraceMs }
260
+ : undefined,
253
261
  });
254
262
  sessionLog = readSessionLog(attempt.env.XDG_DATA_HOME, input.sessionId);
255
263
  }
@@ -257,11 +265,16 @@ export async function runMuseDeveloperSession(input) {
257
265
  attempt.cleanup();
258
266
  }
259
267
  const rawLog = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : spawned.transcript;
268
+ // Only remap a linger kill to exit 0 when the primary run's terminal was actually
269
+ // seen — a spurious linger (e.g. armed on a secondary run) must keep the real
270
+ // signal exit so parseMuseRun does not invent a clean "exited 0 without terminal".
271
+ const lingerAsCleanExit = spawned.lingeredAfterTerminal && primaryTerminal.sawPrimaryTerminal();
272
+ const parseExit = spawned.timedOut ? null : lingerAsCleanExit ? 0 : spawned.exitCode;
260
273
  const { stdout, stderr } = splitSpawnLog(rawLog);
261
274
  const run = parseMuseRun({
262
275
  stdout,
263
276
  stderr,
264
- exitCode: spawned.timedOut ? null : spawned.exitCode,
277
+ exitCode: parseExit,
265
278
  sessionLog,
266
279
  expectedModel: model,
267
280
  lineTs,
@@ -281,6 +294,7 @@ export async function runMuseDeveloperSession(input) {
281
294
  firstOutputMs: spawned.firstOutputMs,
282
295
  lastActivityAt: spawned.lastActivityAt,
283
296
  idleTimedOut: spawned.idleTimedOut,
297
+ lingeredAfterTerminal: spawned.lingeredAfterTerminal,
284
298
  toolCallCount: run.tools.length,
285
299
  lastToolName: run.tools.length > 0 ? (run.tools[run.tools.length - 1].name ?? null) : null,
286
300
  };
@@ -299,15 +313,19 @@ export async function runMuseDeveloperSession(input) {
299
313
  // best-effort capacity refresh (no synthetic turn, never throws, never
300
314
  // blocks this result).
301
315
  refreshMuseCapacityAfterSession();
316
+ const reportedExit = lingerAsCleanExit ? 0 : spawned.exitCode;
302
317
  return {
303
318
  // A failed session must not read as success just because Muse exited 0 (wrong model, no terminal event).
304
- exitCode: run.failure && spawned.exitCode === 0 ? 1 : spawned.exitCode,
319
+ // A linger kill after a terminal event is not a timeout: classify via parseMuseRun (failed
320
+ // keeps its reason; completed is a normal completion) and report exit 0 unless the run failed.
321
+ exitCode: run.failure && reportedExit === 0 ? 1 : reportedExit,
305
322
  transcript: run.finalText ?? "",
306
323
  logPath,
307
324
  timedOut: spawned.timedOut,
308
325
  // NOT-307: an idle kill reads as a timeout downstream (same salvage, same
309
326
  // infra-retry budget) with the idle flag distinguishing the cause.
310
327
  idleTimedOut: spawned.idleTimedOut,
328
+ lingeredAfterTerminal: spawned.lingeredAfterTerminal,
311
329
  muse: summary,
312
330
  };
313
331
  }