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.
- package/bundle/server/dist/adapters/github.js +13 -0
- package/bundle/server/dist/adapters/github.test.js +13 -0
- package/bundle/server/dist/coordinator/commands.js +147 -0
- package/bundle/server/dist/coordinator/developer-effect.js +93 -1
- package/bundle/server/dist/coordinator/developer-effect.test.js +172 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +100 -12
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +190 -0
- package/bundle/server/dist/coordinator/muse-spawn.js +22 -4
- package/bundle/server/dist/coordinator/muse-spawn.test.js +166 -10
- package/bundle/server/dist/coordinator/routing.js +10 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
- package/bundle/server/dist/runners/muse-code-jsonl.js +61 -0
- package/bundle/server/dist/runners/muse-code.test.js +13 -1
- package/bundle/server/dist/runners/spawn-cli.js +93 -20
- package/bundle/server/dist/runners/spawn-cli.test.js +38 -0
- package/bundle/server/package.json +2 -2
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
|
@@ -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:
|
|
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
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
377
|
-
evidence:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|