-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(mesh): record run closes and derive thread state from them #11234
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: codex/mesh-step-5-status
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,344 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import * as fs from 'node:fs/promises'; | ||
| import * as os from 'node:os'; | ||
| import * as path from 'node:path'; | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
|
|
||
| import { Storage } from '../../config/storage.js'; | ||
| import { | ||
| createThread, | ||
| readThread, | ||
| updateMeshAgents, | ||
| writeThread, | ||
| } from './mesh-store.js'; | ||
| import { | ||
| closeRun, | ||
| finishRunInTransaction, | ||
| hasLiveDescendant, | ||
| MeshCloseRejectedError, | ||
| } from './run-lifecycle.js'; | ||
| import { withMeshStoreTransaction } from './mesh-store.js'; | ||
| import { postMessage } from './thread-actions.js'; | ||
| import { | ||
| HUMAN_AUTHOR_ID, | ||
| type MeshAgent, | ||
| type Thread, | ||
| type ThreadRun, | ||
| } from './types.js'; | ||
|
|
||
| const PROJECT_ROOT = '/mesh-lifecycle-test'; | ||
| const ALICE: MeshAgent = { id: 'ag_alice', name: 'alice', createdAt: 1 }; | ||
| const BOB: MeshAgent = { id: 'ag_bob', name: 'bob', createdAt: 1 }; | ||
|
|
||
| function run(overrides: Partial<ThreadRun> = {}): ThreadRun { | ||
| return { | ||
| id: 'rn_alice', | ||
| agentId: ALICE.id, | ||
| status: 'running', | ||
| triggerMessageIds: [], | ||
| acceptedMessageIds: [], | ||
| consumedMessageIds: [], | ||
| usageByRound: [], | ||
| // Well clear of the workspace counter: these fixtures are hand-written and | ||
| // must not collide with a sequence the store allocates during the test. | ||
| queueSequence: 100, | ||
| queuedAt: 1_000, | ||
| attempts: 1, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| async function seed(overrides: Partial<Thread> = {}): Promise<Thread> { | ||
| const created = await createThread(PROJECT_ROOT, { title: 'Investigate' }); | ||
| const thread: Thread = { | ||
| ...created, | ||
| status: 'in_progress', | ||
| runs: [run()], | ||
| ...overrides, | ||
| }; | ||
| await writeThread(PROJECT_ROOT, thread); | ||
| return thread; | ||
| } | ||
|
|
||
| function finish( | ||
| threadId: string, | ||
| runId: string, | ||
| outcome: Parameters<typeof finishRunInTransaction>[1]['outcome'], | ||
| ) { | ||
| return withMeshStoreTransaction(PROJECT_ROOT, (transaction) => | ||
| finishRunInTransaction(transaction, { threadId, runId, outcome }), | ||
| ); | ||
| } | ||
|
|
||
| describe('mesh run lifecycle', () => { | ||
| let runtimeDir: string; | ||
|
|
||
| beforeEach(async () => { | ||
| runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mesh-lifecycle-')); | ||
| Storage.setRuntimeBaseDir(runtimeDir); | ||
| await updateMeshAgents(PROJECT_ROOT, () => [ALICE, BOB]); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| Storage.setRuntimeBaseDir(null); | ||
| await fs.rm(runtimeDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('posts the question, records the close, and ends the turn without finishing the run', async () => { | ||
| const thread = await seed(); | ||
|
|
||
| const result = await closeRun(PROJECT_ROOT, { | ||
| threadId: thread.id, | ||
| runId: 'rn_alice', | ||
| agentId: ALICE.id, | ||
| request: { kind: 'blocked', question: 'which retry path?' }, | ||
| }); | ||
|
|
||
| expect(result.message?.text).toBe('which retry path?'); | ||
| expect(result.message?.authorKind).toBe('agent'); | ||
| expect(result.message?.sourceRunId).toBe('rn_alice'); | ||
| expect(result.message?.authorNameSnapshot).toBe('alice'); | ||
| // The runtime is still executing, so the run may not be marked terminal. | ||
| expect(result.thread.runs[0]?.status).toBe('finishing'); | ||
| expect(result.thread.runs[0]?.closeKind).toBe('blocked'); | ||
| expect(result.thread.runs[0]?.finalMessageId).toBe(result.message?.id); | ||
| expect(result.thread.status).toBe('in_progress'); | ||
| expect(result.thread.outbox).toHaveLength(1); | ||
| expect(result.thread.outbox[0]?.payload['event']).toBe('blocker_raised'); | ||
|
Comment on lines
+111
to
+112
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-33: No test in the mesh suite asserts the
Witness: Suggested fix: Pin the produced state where the event is already held — extend the The fix has to respect this: The asserted literal must be Acceptance criterion: The new — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
| }); | ||
|
|
||
| it('refuses a wait that nothing could ever wake', async () => { | ||
| const thread = await seed(); | ||
|
|
||
| await expect( | ||
| closeRun(PROJECT_ROOT, { | ||
| threadId: thread.id, | ||
| runId: 'rn_alice', | ||
| agentId: ALICE.id, | ||
| request: { kind: 'waiting' }, | ||
| }), | ||
| ).rejects.toThrow(MeshCloseRejectedError); | ||
|
Comment on lines
+123
to
+125
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-14: The same-thread-peer branch of the waiting gate ( Deleting the Witness: Suggested fix: Add a case that closes with The fix has to respect this: The gate counts only Acceptance criterion: The new test throws — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
| }); | ||
|
|
||
| it('allows a wait once a sub-thread is open, and not for a mere sibling', async () => { | ||
| const parent = await seed(); | ||
| const child = await createThread(PROJECT_ROOT, { | ||
| title: 'read the code', | ||
| parentThreadId: parent.id, | ||
| }); | ||
|
|
||
| const waited = await closeRun(PROJECT_ROOT, { | ||
| threadId: parent.id, | ||
| runId: 'rn_alice', | ||
| agentId: ALICE.id, | ||
| request: { kind: 'waiting' }, | ||
| }); | ||
| expect(waited.thread.runs[0]?.closeKind).toBe('waiting'); | ||
|
|
||
| // A sibling under the same root is not this thread's dependency. | ||
| const sibling = await createThread(PROJECT_ROOT, { | ||
| title: 'unrelated', | ||
| parentThreadId: parent.id, | ||
| }); | ||
| const threads = [ | ||
| { ...parent }, | ||
| { ...child, status: 'done' as const }, | ||
| { ...sibling, status: 'done' as const }, | ||
| ]; | ||
| expect(hasLiveDescendant(threads, parent.id)).toBe(false); | ||
| expect(hasLiveDescendant([{ ...parent }, { ...child }], parent.id)).toBe( | ||
| true, | ||
| ); | ||
|
Comment on lines
+153
to
+156
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-13: The test named "allows a wait once a sub-thread is open, and not for a mere sibling" never exercises a sibling, and the assertion its comment names is vacuous: the fixture's Three mutations all survive the suite: (a) re-key Witness: Suggested fix: Pin the walk from the child's point of view — The fix has to respect this: Liveness is Acceptance criterion: Those added assertions — the first goes red under a root/same-root re-key, the second under a flattened recursion, the third under a root-scoped call site. All green against the implementation as committed. Please prove it by mutation — apply the fix, then remove it again and confirm that test goes red. — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
| }); | ||
|
|
||
| it('refuses a close for a run the caller does not own', async () => { | ||
| const thread = await seed(); | ||
|
Comment on lines
+159
to
+160
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-25: The All eight Witness: Suggested fix: Add a case that closes an already- The fix has to respect this: Acceptance criterion: The new case goes red when — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
|
|
||
| await expect( | ||
| closeRun(PROJECT_ROOT, { | ||
| threadId: thread.id, | ||
| runId: 'rn_alice', | ||
| agentId: BOB.id, | ||
| request: { kind: 'review', summary: 'done' }, | ||
| }), | ||
| ).rejects.toThrow(/not a running run of agent "ag_bob"/); | ||
| }); | ||
|
|
||
| it('discharges a peer wait so a review is not reported as blocked', async () => { | ||
| const thread = await seed({ | ||
| runs: [ | ||
| run({ id: 'rn_wait', status: 'completed', closeKind: 'waiting' }), | ||
| run({ | ||
| id: 'rn_bob', | ||
| agentId: BOB.id, | ||
| status: 'running', | ||
| queueSequence: 101, | ||
| }), | ||
| ], | ||
| }); | ||
|
|
||
| const closed = await closeRun(PROJECT_ROOT, { | ||
| threadId: thread.id, | ||
| runId: 'rn_bob', | ||
| agentId: BOB.id, | ||
| request: { kind: 'review', summary: 'the flake is the retry path' }, | ||
| }); | ||
| expect( | ||
| closed.thread.runs.find((entry) => entry.id === 'rn_wait') | ||
| ?.closeAcknowledgedAtSequence, | ||
| ).toBe(1); | ||
|
|
||
| const finished = await finish(thread.id, 'rn_bob', { status: 'completed' }); | ||
| expect(finished.status).toBe('in_review'); | ||
| }); | ||
|
|
||
| it('records a clean exit with no closing tool as unclosed and blocks', async () => { | ||
| const thread = await seed(); | ||
|
|
||
| const finished = await finish(thread.id, 'rn_alice', { | ||
| status: 'completed', | ||
| }); | ||
|
|
||
| expect(finished.runs[0]?.closeKind).toBe('unclosed'); | ||
| expect(finished.status).toBe('blocked'); | ||
| expect( | ||
| finished.outbox.some( | ||
| (event) => event.payload['event'] === 'thread_blocked', | ||
| ), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it('reports a child in review to its parent exactly once', async () => { | ||
| const parent = await createThread(PROJECT_ROOT, { title: 'parent' }); | ||
| const created = await createThread(PROJECT_ROOT, { | ||
| title: 'child', | ||
| parentThreadId: parent.id, | ||
| }); | ||
| await writeThread(PROJECT_ROOT, { | ||
| ...created, | ||
| status: 'in_progress', | ||
| runs: [run()], | ||
| }); | ||
|
|
||
| await closeRun(PROJECT_ROOT, { | ||
| threadId: created.id, | ||
| runId: 'rn_alice', | ||
| agentId: ALICE.id, | ||
| request: { kind: 'review', summary: 'root cause found' }, | ||
| }); | ||
| const finished = await finish(created.id, 'rn_alice', { | ||
| status: 'completed', | ||
| }); | ||
|
|
||
| expect(finished.status).toBe('in_review'); | ||
| const reports = finished.outbox.filter( | ||
| (event) => event.kind === 'parent_report', | ||
| ); | ||
| expect(reports).toHaveLength(1); | ||
| expect(reports[0]?.payload['parentThreadId']).toBe(parent.id); | ||
|
|
||
| // Re-running the terminal write must not enqueue a second report. | ||
| const again = await finish(created.id, 'rn_alice', { status: 'completed' }); | ||
|
Comment on lines
+245
to
+246
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-26: Every Witness: Suggested fix: Add a test seeding a run with The fix has to respect this: Acceptance criterion: The new cancelled-run case goes red when the four-status precondition is dropped, which no existing test detects. Please prove it by mutation — apply the fix, then remove it again and confirm that test goes red. — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
| expect(again.outbox.filter((e) => e.kind === 'parent_report')).toHaveLength( | ||
| 1, | ||
| ); | ||
| }); | ||
|
|
||
| it('carries a typed failure stage onto the run and blocks the thread', async () => { | ||
| const thread = await seed(); | ||
|
|
||
| const finished = await finish(thread.id, 'rn_alice', { | ||
| status: 'failed', | ||
| error: 'definition missing', | ||
| failureStage: 'launch', | ||
|
Comment on lines
+255
to
+258
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-36: The only launch-failure test seeds the run in a state a launch failure cannot be in ( In production a launch failure is recorded against a run that is still Witness: Suggested fix: Seed the launch-failure case in the state production reaches it in — The fix has to respect this: The guard's accepted set must stay a superset of Acceptance criterion: That case goes red when — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
| }); | ||
|
|
||
| expect(finished.runs[0]?.failureStage).toBe('launch'); | ||
| expect(finished.status).toBe('blocked'); | ||
|
Comment on lines
+261
to
+262
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-35: Two tests pass The run validator makes Witness: Suggested fix: Add The fix has to respect this: Acceptance criterion: The new — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
| }); | ||
|
|
||
| it('refuses any close on a thread a person already marked done', async () => { | ||
| const thread = await seed({ status: 'done' }); | ||
|
|
||
| await expect( | ||
| closeRun(PROJECT_ROOT, { | ||
| threadId: thread.id, | ||
| runId: 'rn_alice', | ||
| agentId: ALICE.id, | ||
| request: { kind: 'review', summary: 'late' }, | ||
| }), | ||
| ).rejects.toThrow(/is done/); | ||
| }); | ||
|
|
||
| it('clears an obsolete failure when a later post books real work', async () => { | ||
| const thread = await seed({ assigneeAgentId: ALICE.id }); | ||
| const failed = await finish(thread.id, 'rn_alice', { | ||
| status: 'failed', | ||
| error: 'launch failed', | ||
| }); | ||
| expect(failed.status).toBe('blocked'); | ||
|
|
||
| const posted = await postMessage(PROJECT_ROOT, thread.id, { | ||
| from: HUMAN_AUTHOR_ID, | ||
| text: 'try again please', | ||
| }); | ||
|
Comment on lines
+286
to
+289
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-10: The only test offered as the producer-side witness for I2 posts as a human, but I2's defect is precisely that acknowledgement happened only by human feedback — so this test does not discriminate the fix from the defect it claims to pin. Gate the new admission call on authorship ( Witness: Suggested fix: Add one case that books work as an agent: after a The fix has to respect this: The design's aggregate paragraph ( Acceptance criterion: That new case goes red if the acknowledgement is gated on — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
|
|
||
| expect(posted.dispatched).toHaveLength(1); | ||
| expect(posted.thread.status).toBe('in_progress'); | ||
| expect( | ||
| posted.thread.runs.find((entry) => entry.id === 'rn_alice') | ||
| ?.closeAcknowledgedAtSequence, | ||
| ).toBe(1); | ||
| }); | ||
|
|
||
| it('blocks a quiescent thread whose post books nothing at all', async () => { | ||
| const created = await createThread(PROJECT_ROOT, { title: 'unassigned' }); | ||
|
|
||
| const posted = await postMessage(PROJECT_ROOT, created.id, { | ||
| from: HUMAN_AUTHOR_ID, | ||
| text: 'anyone?', | ||
| }); | ||
|
|
||
| expect(posted.dispatched).toHaveLength(0); | ||
| expect(posted.thread.status).toBe('blocked'); | ||
| expect( | ||
| posted.thread.outbox.some( | ||
| (event) => event.payload['event'] === 'thread_blocked', | ||
| ), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it('leaves a thread in_progress while another run is still live', async () => { | ||
| const thread = await seed({ | ||
| runs: [ | ||
| run(), | ||
| run({ | ||
| id: 'rn_bob', | ||
| agentId: BOB.id, | ||
| status: 'queued', | ||
| queueSequence: 101, | ||
| }), | ||
| ], | ||
| }); | ||
|
|
||
| await closeRun(PROJECT_ROOT, { | ||
| threadId: thread.id, | ||
| runId: 'rn_alice', | ||
| agentId: ALICE.id, | ||
| request: { kind: 'review', summary: 'my part is done' }, | ||
| }); | ||
| const finished = await finish(thread.id, 'rn_alice', { | ||
| status: 'completed', | ||
| }); | ||
|
|
||
| expect(finished.status).toBe('in_progress'); | ||
| expect(await readThread(PROJECT_ROOT, thread.id)).toMatchObject({ | ||
| status: 'in_progress', | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] R1-29: The acceptance entry this diff adds names one aggregate-status writer when the same diff adds two, contradicts itself four sentences later on the same line, and attributes
closeRunto all six of step 5's tools when only three can call it. This file is the gate ledger the next step is written from.A step-6 implementer who trusts "the only place the aggregate status is recomputed" reasons about run completion as the sole status writer and never considers that an ordinary human or agent post also recomputes and persists status — which is exactly the write path confirmed as Critical R1-22 (
thread-actions.ts:337). Separately, "The six thread tools that callcloseRun" is a restrictive relative clause and it is false: onlythread_wait/thread_block/thread_reviewmap ontoRunCloseRequest's three kinds,thread_postgoes throughpostMessage, andthread_create/thread_readnever close a run — so a step-5 implementer wiring from this record callscloseRunfrom tools that must not.Witness:
Suggested fix: Correct that one sentence in both respects: name the two recomputation sites (the terminal callback and the admission path), and scope the closing tools to the three that can call
closeRun— e.g. "…the runtime callback records the terminal state; the closing tool never writes the aggregate status itself, and the terminal callback and the admission path are the two places it is recomputed. The three closing tools (thread_wait,thread_block,thread_review) are still to come…".The fix has to respect this: AGENTS.md requires the design doc and this acceptance file to stay current in the same commit as the code that changes them, and this file is designated the step's contract — so the correction belongs in this PR, not a follow-up. Note the gate-(d) half of the original claim was verified and rejected: the sentence's causal scoping ("…are still to come, so gates (a), (b), (c) and (e) remain unexecuted") legitimately excludes gate (d), which needs the separately-listed prompt assembler.
— qwen3.8-max via Qwen Code /review (v0.23.3)