Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions docs/plans/2026-09-06-multi-agent-board-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,13 +522,14 @@ action on the child caused it.
Pre-existing on this branch (five production files plus two tests; nothing
starts an agent yet):

| File | Responsibility |
| ----------------------------------------- | ---------------------------------------------- |
| `core/src/agents/mesh/types.ts` | Entities and limits |
| `core/src/agents/mesh/mesh-store.ts` | Paths, validation, locking, CRUD |
| `core/src/agents/mesh/mentions.ts` | `@name` → agent ids |
| `core/src/agents/mesh/dispatch-policy.ts` | `decideDispatch` — pure |
| `core/src/agents/mesh/thread-actions.ts` | `postMessage` — append and book under one lock |
| File | Responsibility |
| ----------------------------------------- | -------------------------------------------------- |
| `core/src/agents/mesh/types.ts` | Entities and limits |
| `core/src/agents/mesh/mesh-store.ts` | Paths, validation, locking, CRUD |
| `core/src/agents/mesh/mentions.ts` | `@name` → agent ids |
| `core/src/agents/mesh/dispatch-policy.ts` | `decideDispatch` — pure |
| `core/src/agents/mesh/thread-actions.ts` | `postMessage` — append and book under one lock |
| `core/src/agents/mesh/thread-status.ts` | Aggregate status over every run's close obligation |

### 5.1 Local review correction — committed and verified

Expand Down
2 changes: 2 additions & 0 deletions docs/plans/2026-09-07-mesh-implementation-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Lands: `runWithMeshRunContext` at the per-turn seam; `thread_post`, `thread_wait
Gate: (a) a mutating tool invoked with a model-supplied `threadId` argument is rejected by schema, and one invoked outside a run context is rejected at execution; (b) `thread_create` under thread A from a body whose previous turn was on thread B creates the child under A, tested by running two turns on one `AgentHeadless` instance; (c) `thread_wait` without a live dependency returns a typed rejection; (d) the assembled prompt for a second wake contains title, body, status, the last N posts, and the delta after `committedThroughSequence`, and a retention gap renders the GAP line; (e) a `USAGE_METADATA` sequence across a `finishingInputs` continuation records rounds `[1, 2]` on one run.
Evidence: the assembled prompt text for cases first-entry / delta / gap / retry, committed as snapshot fixtures.

**Aggregate status landed.** `thread-status.ts` derives the status from every run's close obligation rather than letting the last run to finish stamp it, and the three round-2 findings are each pinned by a test: a same-thread wait is discharged by a later close (I1), any later successful booking discharges an earlier failure or unclosed return (I2), and a quiescent thread whose last admission booked nothing becomes `blocked` (I6). Also covered: a live run outranks another agent's review, a blocker outranks a review, a wait is `in_progress` only while a child can wake it, `done` is sticky against a late post, and a failed run reports as a failure even when it recorded a close kind. Observed locally: `thread-status.test.ts` → 1 file, 13 tests passed; targeted ESLint clean. The producers that write `closeKind` are the thread tools in 5b, so nothing calls this resolver yet.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-22: This added line records round-2 finding I2 as pinned by a test for both halves, but no test in this commit exercises an outstanding unclosed obligation at all — so the artifact that tracks which round-2 findings are closed closes one that is not.

The claim is "any later successful booking discharges an earlier failure or unclosed return (I2)". 'unclosed' occurs exactly once in thread-status.test.ts (at :108) with closeAcknowledgedAtSequence: 1 on the next line, so outstandingCloseObligations filters that run out before any branch reads it; hand-off occurs zero times; and none of the three acknowledgeCloseObligations calls (:141, :161, :275/:284) touches an unclosed obligation. Two mutations that falsify exactly that sentence both leave the cited suite green. The consequence is not just an imprecise sentence: a later reviewer reads this file to decide whether I2 is settled, and it records the unclosed half as closed when nothing pins it.

Witness:

MUTATION A  BLOCKING_KINDS minus 'unclosed'          -> Tests 13 passed (13)
  intact:   cause=unclosed status=blocked   reason="run rn_x ended without a hand-off"
  mutated:  cause=unclosed status=in_progress reason="no outstanding close obligation"

MUTATION C  default select = kind !== 'unclosed'      -> Tests 13 passed (13)
  (removes precisely "discharges an earlier ... unclosed return")
  intact:   stamped => [9]     after ack => {"status":"in_progress","outstanding":[]}
  mutated:  stamped => [null]  after ack => {"status":"blocked",
                                  "reason":"run rn_a ended without a hand-off"}

Greps: 'unclosed' -> 1 hit (test :108, with closeAcknowledgedAtSequence: 1 on :109)
       'hand-off' -> 0 hits
       13 it( blocks, matching this line's own "13 tests passed"

Either narrow the claim to the half that is pinned — "any later successful booking discharges an earlier failure (I2)" — or add the missing case and name it here:

it('discharges an unclosed return when later work books', () => {
  const unclosed = thread({ runs: [run({ closeKind: 'unclosed' })] });
  expect(resolve(unclosed).status).toBe('blocked');
  const discharged = acknowledgeCloseObligations(unclosed, 2);
  expect(outstandingCloseObligations(discharged)).toEqual([]);
});

If the test is the route taken, assert the discharge rather than unconditional blocking: design docs/plans/2026-09-06-multi-agent-board-collaboration.md:476 makes this row conditional ("record closeKind=unclosed; block only if no successor is runnable"), and a separate finding disputes whether the unconditional block is correct — so a test that pins unconditional blocking here would have to be edited when that is settled.

The underlying coverage gap is reported separately at the four untested resolver dimensions; what is distinct here, in a different file, is the false evidence claim in the acceptance record.

— qwen3.8-max via Qwen Code /review (v0.23.0)


### Step 6 — Minimal in-process dispatcher, no recovery

Lands: pick the lowest `queueSequence` queued run per agent; branch on registry state `completed+resident → continue`, `completed → revive`, `paused → resume`, `unbound → launch`; `startRun` / `finishRun`; consume the parent-report outbox; leave `capacity_wait` queued.
Expand Down
287 changes: 287 additions & 0 deletions packages/core/src/agents/mesh/thread-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';

import {
acknowledgeCloseObligations,
outstandingCloseObligations,
resolveThreadStatus,
} from './thread-status.js';
import {
HUMAN_AUTHOR_ID,
MESH_SCHEMA_VERSION,
type MessageOutcome,
type Thread,
type ThreadMessage,
type ThreadRun,
} from './types.js';

function run(overrides: Partial<ThreadRun> = {}): ThreadRun {
return {
id: 'rn_1',
agentId: 'ag_alice',
status: 'completed',
triggerMessageIds: ['ms_1'],
acceptedMessageIds: [],
consumedMessageIds: [],
usageByRound: [],
queueSequence: 1,
queuedAt: 1_000,
attempts: 1,
...overrides,
};
}

function message(overrides: Partial<ThreadMessage> = {}): ThreadMessage {
return {
id: 'ms_1',
sequence: 1,
authorKind: 'human',
from: HUMAN_AUTHOR_ID,
authorNameSnapshot: 'user',
text: 'have a look',
mentions: [],
outcomes: [],
at: 2_000,
...overrides,
};
}

const booked: MessageOutcome[] = [
{ kind: 'dispatch', targetAgentId: 'ag_alice', runId: 'rn_1' },
];

function thread(overrides: Partial<Thread> = {}): Thread {
return {
schemaVersion: MESH_SCHEMA_VERSION,
id: 'th_1',
title: 'Investigate',
body: '',
status: 'in_progress',
createdAt: 1_000,
createdBy: HUMAN_AUTHOR_ID,
rootThreadId: 'th_1',
messages: [message({ outcomes: booked })],
runs: [],
nextMessageSequence: 2,
deliveryByAgent: {},
outbox: [],
autoTurnsUsed: 0,
tokensUsed: 0,
...overrides,
};
}

function resolve(thread: Thread, hasLiveChildDependency = false) {
return resolveThreadStatus({ thread, hasLiveChildDependency });
}

describe('resolveThreadStatus', () => {
it('stays in_progress while any run is live, whatever another run recorded', () => {
// The case status-as-last-writer got wrong: alice reviews, bob is still
// working. A person must not be told this is ready.
const result = resolve(
thread({
runs: [
run({ id: 'rn_alice', closeKind: 'review' }),
run({ id: 'rn_bob', agentId: 'ag_bob', status: 'running' }),
],
}),
);

expect(result.status).toBe('in_progress');
expect(result.reason).toContain('1 run(s) still queued');
});

it('reports in_review once the last run is quiescent', () => {
const result = resolve(
thread({
runs: [
run({ id: 'rn_alice', closeKind: 'review' }),
run({
id: 'rn_bob',
agentId: 'ag_bob',
closeKind: 'unclosed',
closeAcknowledgedAtSequence: 1,
}),
],
}),
);

expect(result.status).toBe('in_review');
expect(result.reason).toContain('rn_alice');
});

it('lets a blocker outrank a review from another agent', () => {
const result = resolve(
thread({
runs: [
run({ id: 'rn_alice', closeKind: 'review' }),
run({ id: 'rn_bob', agentId: 'ag_bob', closeKind: 'blocked' }),
],
}),
);

expect(result.status).toBe('blocked');
expect(result.reason).toContain('rn_bob');
});

it('does not pin the thread to a failure that later work superseded', () => {
// Round-2 finding I2: acknowledgement was human-only, so one launch
// failure blocked the thread forever even after another agent finished.
const failed = thread({
runs: [run({ id: 'rn_alice', status: 'failed', error: 'launch failed' })],
});
expect(resolve(failed).status).toBe('blocked');

const afterBooking = acknowledgeCloseObligations(failed, 2);
const withReview = {
...afterBooking,
runs: [
...afterBooking.runs,
run({ id: 'rn_bob', agentId: 'ag_bob', closeKind: 'review' }),
],
};

expect(resolve(withReview).status).toBe('in_review');
});

it('treats a same-thread wait as satisfied by a later close', () => {
// Round-2 finding I1: A waits for B, B reviews without @-ing A. Blocked
// outranks review, so the thread used to report blocked when it was ready.
const waiting = thread({
runs: [run({ id: 'rn_alice', closeKind: 'waiting' })],
});
expect(resolve(waiting).status).toBe('blocked');

const released = acknowledgeCloseObligations(
waiting,
2,
(obligation) => obligation.kind === 'waiting',
);
const withReview = {
...released,
runs: [
...released.runs,
run({ id: 'rn_bob', agentId: 'ag_bob', closeKind: 'review' }),
],
};

expect(resolve(withReview).status).toBe('in_review');
});

it('keeps a wait in_progress only while a child can still wake it', () => {
const waiting = thread({
runs: [run({ id: 'rn_alice', closeKind: 'waiting' })],
});

expect(resolve(waiting, true).status).toBe('in_progress');
expect(resolve(waiting, false).status).toBe('blocked');
expect(resolve(waiting, false).reason).toContain('no longer exists');
});

it('blocks a quiescent thread whose last admission booked nothing', () => {
// Round-2 finding I6: the silent path. A post whose assignee is gone left
// the thread in in_progress with no live run and no explanation.
const result = resolve(
thread({
messages: [
message({
sequence: 1,
outcomes: [{ kind: 'skip', reason: 'agent_unknown' }],
}),
],
}),
);

expect(result.status).toBe('blocked');
expect(result.reason).toContain('agent_unknown');
});

it('ignores a post that was never an admission', () => {
const result = resolve(
thread({ messages: [message({ sequence: 1, outcomes: [] })] }),
);

expect(result.status).toBe('in_progress');
});

it('keeps done sticky against a late post', () => {
const result = resolve(
thread({
status: 'done',
messages: [
message({
sequence: 1,
outcomes: [{ kind: 'skip', reason: 'thread_done' }],
}),
],
runs: [run({ id: 'rn_alice', status: 'failed' })],
}),
);

expect(result.status).toBe('done');
});

it('leaves an untouched thread open', () => {
expect(resolve(thread({ status: 'open', messages: [] })).status).toBe(
'open',
);
});
});

describe('close obligations', () => {
it('reports a failed run as a failure even when it recorded a close kind', () => {
const obligations = outstandingCloseObligations(
thread({
runs: [run({ status: 'failed', closeKind: 'review' })],
}),
);

expect(obligations).toEqual([
{ runId: 'rn_1', agentId: 'ag_alice', kind: 'failure' },
]);
});

it('ignores live runs and already-acknowledged closes', () => {
const obligations = outstandingCloseObligations(
thread({
runs: [
run({ id: 'rn_live', status: 'running' }),
run({
id: 'rn_done',
closeKind: 'review',
closeAcknowledgedAtSequence: 4,
}),
],
}),
);

expect(obligations).toEqual([]);
});

it('acknowledges only what the selector matches and is a no-op otherwise', () => {
const source = thread({
runs: [
run({ id: 'rn_wait', closeKind: 'waiting' }),
run({ id: 'rn_block', agentId: 'ag_bob', closeKind: 'blocked' }),
],
});

const partial = acknowledgeCloseObligations(
source,
7,
(obligation) => obligation.kind === 'waiting',
);
expect(
partial.runs.map((entry) => entry.closeAcknowledgedAtSequence),
).toEqual([7, undefined]);

const none = acknowledgeCloseObligations(partial, 8, () => false);
expect(none).toBe(partial);
});
});
Loading
Loading