diff --git a/src/repair/comment-router-utils.ts b/src/repair/comment-router-utils.ts index 2598879992..a308fccf4d 100644 --- a/src/repair/comment-router-utils.ts +++ b/src/repair/comment-router-utils.ts @@ -104,7 +104,7 @@ function checkTimestamp(check: LooseRecord) { export function shouldSuppressProcessedCommentVersion(entry: LooseRecord) { const status = String(entry.status ?? "").toLowerCase(); - if (!["executed", "skipped"].includes(status)) return false; + if (!["claimed", "executed", "skipped"].includes(status)) return false; const intent = String(entry.intent ?? ""); if ( status === "skipped" && @@ -244,7 +244,9 @@ export function readLedger(file: JsonValue) { export function appendLedger(current: LooseRecord, entries: LooseRecord[]) { const compact = entries - .filter((entry: JsonValue) => ["executed", "skipped", "waiting"].includes(entry.status)) + .filter((entry: JsonValue) => + ["claimed", "executed", "skipped", "waiting"].includes(entry.status), + ) .filter((entry: JsonValue) => !isNoopSkip(entry)) .map((entry: JsonValue) => { const actions = compactLedgerActions(entry.actions); diff --git a/src/repair/comment-router.ts b/src/repair/comment-router.ts index fc274acea0..db77365cba 100644 --- a/src/repair/comment-router.ts +++ b/src/repair/comment-router.ts @@ -294,8 +294,6 @@ const report: LooseRecord = { if (execute) { await measureAsync("execute_commands", async () => { assertMutationActorIsClawsweeperBot(); - for (const command of commands) convergePrecreatedCommandAckComments(command); - for (const command of commands) acknowledgeSkippedMaintainerCommand(command); const capacityRequests = workerCapacityRequests(actionable); if (capacityRequests.length > 0) { const capacities = capacityRequests.map((request) => @@ -304,6 +302,12 @@ if (execute) { report.live_worker_capacity_before_dispatch = capacities.length === 1 ? capacities[0] : capacities; } + report.ledger_claimed = measure("claim_dispatch_commands", () => + claimDispatchCommands(actionable), + ); + if (report.ledger_claimed) writeLedger(ledgerPath(), ledger); + for (const command of commands) convergePrecreatedCommandAckComments(command); + for (const command of commands) acknowledgeSkippedMaintainerCommand(command); for (const command of actionable) executeCommand(command); }); report.ledger_changed = measure("append_ledger", () => appendLedger(ledger, commands)); @@ -335,6 +339,34 @@ async function measureAsync(name: string, fn: () => Promise): Promise { } } +function claimDispatchCommands(commands: LooseRecord[]) { + const claims = commands.filter(commandNeedsDurableDispatchClaim).map((command) => ({ + ...command, + status: "claimed", + actions: Array.isArray(command.actions) + ? command.actions.map((action: JsonValue) => + actionNeedsDurableDispatchClaim(action) ? { ...action, status: "claimed" } : action, + ) + : command.actions, + })); + return appendLedger(ledger, claims); +} + +function commandNeedsDurableDispatchClaim(command: LooseRecord) { + return ( + String(command.status ?? "") === "ready" && + (commandHasAction(command, "dispatch_clawsweeper") || + commandHasAction(command, "dispatch_repair") || + commandHasAction(command, "dispatch_assist")) + ); +} + +function actionNeedsDurableDispatchClaim(action: JsonValue) { + return ["dispatch_clawsweeper", "dispatch_repair", "dispatch_assist"].includes( + String(action?.action ?? ""), + ); +} + function assertMutationActorIsClawsweeperBot() { try { const viewer = ghJson(["api", "user"]); diff --git a/test/repair/comment-router-core.test.ts b/test/repair/comment-router-core.test.ts index 0439b116bf..bcd59168bd 100644 --- a/test/repair/comment-router-core.test.ts +++ b/test/repair/comment-router-core.test.ts @@ -1378,6 +1378,29 @@ test("router classifies fresh human-review pauses before label sweeps", () => { assert.match(source, /\.filter\(isReadyHumanReviewPause\)/); }); +test("comment router durably claims dispatch commands before external execution", () => { + const source = readFileSync("src/repair/comment-router.ts", "utf8"); + const executeBlock = source.slice( + source.indexOf('await measureAsync("execute_commands"'), + source.indexOf('report.ledger_changed = measure("append_ledger"'), + ); + const claimIndex = executeBlock.indexOf("claimDispatchCommands(actionable)"); + const ackIndex = executeBlock.indexOf("convergePrecreatedCommandAckComments(command)"); + const executeIndex = executeBlock.indexOf("executeCommand(command)"); + const claimFunction = source.slice( + source.indexOf("function claimDispatchCommands"), + source.indexOf("function assertMutationActorIsClawsweeperBot"), + ); + + assert.ok(claimIndex >= 0); + assert.ok(ackIndex > claimIndex); + assert.ok(executeIndex > claimIndex); + assert.match(claimFunction, /status:\s*"claimed"/); + assert.match(claimFunction, /commandHasAction\(command,\s*"dispatch_clawsweeper"\)/); + assert.match(claimFunction, /commandHasAction\(command,\s*"dispatch_repair"\)/); + assert.match(claimFunction, /commandHasAction\(command,\s*"dispatch_assist"\)/); +}); + test("trusted autoclose markers are live close gated before close execution", () => { const source = readFileSync("src/repair/comment-router.ts", "utf8"); const autocloseClassifier = source.slice( diff --git a/test/repair/comment-router-utils.test.ts b/test/repair/comment-router-utils.test.ts index 17504d375e..02fcbfd50a 100644 --- a/test/repair/comment-router-utils.test.ts +++ b/test/repair/comment-router-utils.test.ts @@ -130,6 +130,85 @@ test("appendLedger records waiting commands without making them terminal", () => assert.equal(shouldSuppressProcessedCommentVersion(ledger.commands[0]), false); }); +test("appendLedger records claimed dispatch commands as terminal idempotency claims", () => { + const ledger = { updated_at: null, commands: [] }; + + assert.equal( + appendLedger(ledger, [ + { + idempotency_key: "claim-before-dispatch", + comment_id: "125", + comment_version_key: "125:2026-04-29T03:01:00Z", + comment_updated_at: "2026-04-29T03:01:00Z", + status: "claimed", + intent: "clawsweeper_re_review", + issue_number: 74499, + repo: "openclaw/openclaw", + actions: [{ action: "dispatch_clawsweeper", status: "claimed" }], + }, + ]), + true, + ); + + assert.equal(ledger.commands.length, 1); + assert.equal(ledger.commands[0].status, "claimed"); + assert.equal(shouldSuppressProcessedCommentVersion(ledger.commands[0]), true); + assert.deepEqual(ledger.commands[0].actions, [ + { + action: "dispatch_clawsweeper", + status: "claimed", + label: null, + job_path: null, + }, + ]); +}); + +test("appendLedger upgrades claimed dispatch commands after execution", () => { + const ledger = { updated_at: null, commands: [] }; + + appendLedger(ledger, [ + { + idempotency_key: "claim-before-dispatch", + comment_id: "125", + comment_version_key: "125:2026-04-29T03:01:00Z", + comment_updated_at: "2026-04-29T03:01:00Z", + status: "claimed", + intent: "clawsweeper_re_review", + issue_number: 74499, + repo: "openclaw/openclaw", + actions: [{ action: "dispatch_clawsweeper", status: "claimed" }], + }, + ]); + + assert.equal( + appendLedger(ledger, [ + { + idempotency_key: "claim-before-dispatch", + comment_id: "125", + comment_version_key: "125:2026-04-29T03:01:00Z", + comment_updated_at: "2026-04-29T03:01:00Z", + status: "executed", + intent: "clawsweeper_re_review", + issue_number: 74499, + repo: "openclaw/openclaw", + actions: [{ action: "dispatch_clawsweeper", status: "executed" }], + }, + ]), + true, + ); + + assert.equal(ledger.commands.length, 1); + assert.equal(ledger.commands[0].status, "executed"); + assert.deepEqual(ledger.commands[0].actions, [ + { + action: "dispatch_clawsweeper", + status: "executed", + label: null, + job_path: null, + }, + ]); +}); + test("appendLedger ignores no-op skipped command versions", () => { const ledger = { updated_at: null, commands: [] };