Skip to content

Commit 3ce52eb

Browse files
mchwangclaude
andcommitted
Complete only into a running task (contract round 35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 70a1d40 commit 3ce52eb

2 files changed

Lines changed: 11 additions & 2 deletions

File tree

‎runner/store.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -665,8 +665,10 @@ export class Store {
665665
const contextCurrent = sameContext(decode<InvocationContext>(row.context), this.#contextOf(key));
666666
let outcome = classifySettlement({ ...settlement, firstReason, contextCurrent });
667667
if (outcome.state === 'completed' && row.state !== 'running') throw new GuardRefusal('Only a running attempt can complete.');
668-
if (outcome.state === 'completed' && (this.#closed(task.status) || task.cancel_requested !== null))
669-
outcome = { state: 'cancelled', reason: 'The task was closed before the result was saved.', timeLimit: false };
668+
// The task must still be running; a task never leaves running while an attempt is active, so this is a second safeguard.
669+
if (outcome.state === 'completed' && (task.status !== 'running' || task.cancel_requested !== null))
670+
outcome = { state: 'cancelled', reason: this.#closed(task.status) || task.cancel_requested !== null
671+
? 'The task was closed before the result was saved.' : 'The task left the running state before the result was saved.', timeLimit: false };
670672
let result: string | null = null;
671673
if (outcome.state === 'completed') {
672674
result = encode(settlement.result ?? null);

‎test/runner-lifecycle-store.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ describe('attempt transitions', () => {
179179
const next = admit(store); store.markRunning(identity, next.id);
180180
expect(settle(store, next.id, { result: 'x'.repeat(1024 * 1024 + 1) })).toMatchObject({ state: 'failed', reason: 'The result exceeds 1 MiB.' });
181181
});
182+
it('freezes the task status while an attempt is active, and never completes into a non-running task', () => {
183+
const { store, path } = queued(); const attempt = admit(store); store.markRunning(identity, attempt.id);
184+
expect(() => store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs amendment')).toThrow(/still active/);
185+
const db = new DatabaseSync(path); db.exec(`UPDATE tasks SET status='needs amendment'`); db.close();
186+
expect(settle(store, attempt.id, { result: 'late' })).toMatchObject({ state: 'cancelled', reason: 'The task left the running state before the result was saved.' });
187+
expect(store.getTask(identity).status).toBe('needs amendment');
188+
});
182189
it('refuses a late settlement from an old attempt after a retry, without touching the retry', () => {
183190
const { store } = queued(); const old = admit(store);
184191
store.recordFirstReason(identity, old.id, 'cancelled'); settle(store, old.id);

0 commit comments

Comments
 (0)