Skip to content
Merged
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: 7 additions & 8 deletions docs/plans/2026-09-06-multi-agent-board-collaboration.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
# Multi-agent collaboration on a shared thread

> Status: Revised after source-backed review. Admission, runtime preparation,
> capability, and versioned storage are committed on #11206. The hidden-host
> launcher is locally implemented on its stacked step branch; dispatch remains
> unbuilt.
> Status: Implemented through the runtime wiring needed for the live slice;
> the end-to-end model run remains unverified.
> Baseline: `origin/main` @ `703678136a` (2026-09-06)
> Verification: targeted tests, build, typecheck, and lint are recorded in §0.2;
> no agent has run this design end to end
Expand Down Expand Up @@ -610,10 +608,11 @@ Dependencies, with an early vertical proof before reliability and UI breadth.
failure it exists to catch, so it must fail loudly rather than shadow.
6. **Minimal in-process dispatcher, no recovery** — pick and atomically claim
one queued run per agent by `queueSequence`; launch, continue resident,
resume `paused`, or cold revive; bind the session on success; and consume the
parent-report outbox. Handle `capacity_wait` by releasing the claim without
spending the attempt. This is intentionally the smallest dispatcher that
can make the next step executable.
resume `paused`, or cold revive; bind the session on success; record runtime
delivery and usage events; finish the mesh run when the body returns; and
consume the parent-report outbox. Handle `capacity_wait` by releasing the
claim without spending the attempt. This is intentionally the smallest
dispatcher that can make the next step executable.
7. **Minimal live vertical slice** — assigned parent → launch → assigned child →
parent wait → child review → parent dependency wake → parent review. Run it
against two live agents before building the full daemon; this is the first
Expand Down
2 changes: 1 addition & 1 deletion docs/plans/2026-09-07-mesh-implementation-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Gate (a): one `startRun`/`finishRun` per assignment — covered. Gate (b): two t
Observed locally: 11 files, 127 tests passed; targeted ESLint clean.
Writing the production port corrected the design's four-branch idle path to three. Whether a completed body still has a resident runtime is not a choice the dispatcher can make — only the registry knows, and #11204 already reports its own fallback — so a dispatcher choosing between "continue resident" and "cold revive" would be guessing at state it cannot see and would cold-revive a live body. The three entry points it does choose between are `launch`, `resume` (a restart-recovered `paused` entry, which the revive path rejects) and `continue_completed`.
`dispatch-port.ts` binds those to the runtime and is the single place a non-local runtime would be substituted (§9.12). Its tests pin the registry-state mapping, the hot path not touching the transcript, capacity reported before any mutation, a state change under it not being forced, and a thrown runtime error becoming a typed failure rather than a start.
Still open in step 6: `runWithMeshRunContext` is not yet established at the turn seam; the six tools are not registered into a mesh agent's tool set; and `consumedMessageIds` / `usageByRound` still need the runtime event streams. Those are the wiring that makes step 7 runnable.
**Runtime wiring landed for the demo path.** Every launch, resident continuation, resume, and revive persists the next run binding, and both real background-turn seams establish it inside the turn body. Mesh agents see the six thread tools while ordinary subagents do not. Structured resident delivery uses the run id as its correlation id; the consumed event advances that run's accepted/consumed ids and watermark, usage events upsert cumulative rounds, and body completion terminalizes the mesh run. Launch/revive inputs are marked consumed when the runtime accepts their initial prompt. This correction was deliberately not expanded with new test code or a local CI/build pass; step 7's live model run is the next evidence gate.

### Step 7 — Live vertical slice (first integration gate)

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/agents/agent-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { MeshRunContext } from './mesh/run-context.js';
import {
AgentEventType,
type AgentEventEmitter,
Expand Down Expand Up @@ -112,6 +113,8 @@ export interface AgentMeta {
agentId: string;
/** Durable mesh identity when this runtime belongs to the shared-thread mesh. */
meshAgentId?: string;
/** The mesh run this body's next turn executes. */
meshRun?: MeshRunContext;
agentType: string;
description: string;
/** SessionId of the user session that launched this agent. */
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/agents/background-agent-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
buildMeshToolConfig,
createMeshToolInvocationGuard,
} from './mesh/capability.js';
import { runMeshTurn } from './mesh/runtime-bridge.js';

const debugLogger = createDebugLogger('BACKGROUND_AGENT_RESUME');

Expand Down Expand Up @@ -1419,10 +1420,22 @@ export class BackgroundAgentResumeService {
// Restore the persisted launch depth so a resumed nested agent keeps
// its original nesting level (and spawn eligibility) instead of
// recomputing to depth 0 from this top-level resume frame.
const meshRun = readAgentMeta(metaPath)?.meshRun;
const body = () =>
runBody(turnContextState, turnAbortController, fireStartHook);
const framedRunBody = () =>
runWithAgentContext(
meta.agentId,
() => runBody(turnContextState, turnAbortController, fireStartHook),
meshRun
? () =>
runMeshTurn({
projectRoot: this.config.getProjectRoot(),
context: meshRun,
emitter: bgEmitter,
metaPath,
body,
})
: body,
normalizeResumedAgentDepth(meta.depth),
);
const invocationRunBody = () =>
Expand Down
24 changes: 12 additions & 12 deletions packages/core/src/agents/mesh/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ import { classifyShellCommandSafetyInDirectory } from '../../utils/shellAstParse
export type MeshToolClassification = 'allow' | 'deny' | 'thread';

export const MESH_THREAD_TOOL_NAMES = [
'thread_post',
'thread_wait',
'thread_block',
'thread_review',
'thread_create',
'thread_read',
ToolNames.THREAD_POST,
ToolNames.THREAD_WAIT,
ToolNames.THREAD_BLOCK,
ToolNames.THREAD_REVIEW,
ToolNames.THREAD_CREATE,
ToolNames.THREAD_READ,
] as const;

type CoreToolName = (typeof ToolNames)[keyof typeof ToolNames];
Expand Down Expand Up @@ -76,12 +76,12 @@ export const MESH_TOOL_CLASSIFICATION = {
[ToolNames.UPDATE_GOAL]: 'deny',
[ToolNames.PROPOSE_GOAL]: 'deny',
[ToolNames.DISPLAY_IMAGE]: 'allow',
thread_post: 'thread',
thread_wait: 'thread',
thread_block: 'thread',
thread_review: 'thread',
thread_create: 'thread',
thread_read: 'thread',
[ToolNames.THREAD_POST]: 'thread',
[ToolNames.THREAD_WAIT]: 'thread',
[ToolNames.THREAD_BLOCK]: 'thread',
[ToolNames.THREAD_REVIEW]: 'thread',
[ToolNames.THREAD_CREATE]: 'thread',
[ToolNames.THREAD_READ]: 'thread',
} as const satisfies Record<
CoreToolName | MeshThreadToolName,
MeshToolClassification
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/agents/mesh/dispatch-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ import {
} from './dispatch-port.js';
import type { MeshAgent } from './types.js';

vi.mock('../agent-transcript.js', () => ({
getAgentMetaPath: () => '/mesh-agent.meta.json',
patchAgentMeta: () => {},
readAgentMeta: () => ({
meshRun: {
workspaceId: 'ws_1',
threadId: 'th_1',
runId: 'rn_1',
attempt: 1,
},
}),
}));

const ALICE: MeshAgent = { id: 'ag_alice', name: 'alice', createdAt: 1 };

function makeConfig(
Expand All @@ -29,6 +42,7 @@ function makeConfig(
continueResidentAgent: vi.fn(() => overrides.continueResult ?? 'continued'),
};
const config = {
getProjectRoot: () => '/workspace',
getBackgroundTaskRegistry: () => registry,
getSessionId: () => 'se_host',
reviveCompletedBackgroundAgent: vi.fn(async () => overrides.revive),
Expand Down Expand Up @@ -65,10 +79,12 @@ describe('createMeshDispatchPort', () => {
action,
agent: ALICE,
prompt: 'YOUR RUN ...',
workspaceId: 'ws_1',
threadId: 'th_1',
rootThreadId: 'th_1',
runId: 'rn_1',
attempt: 1,
contextThroughSequence: 1,
});

it('continues a completed body hot without touching the transcript', async () => {
Expand All @@ -77,10 +93,12 @@ describe('createMeshDispatchPort', () => {
await expect(start(config, 'continue_completed')).resolves.toEqual({
status: 'started',
sessionId: 'se_host',
consumedOnStart: false,
});
expect(registry.continueResidentAgent).toHaveBeenCalledWith(
'mesh-ag_alice',
'YOUR RUN ...',
'rn_1',
);
expect(config.reviveCompletedBackgroundAgent).not.toHaveBeenCalled();
});
Expand Down
90 changes: 82 additions & 8 deletions packages/core/src/agents/mesh/dispatch-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@
*/

import type { Config } from '../../config/config.js';
import {
getAgentMetaPath,
patchAgentMeta,
readAgentMeta,
} from '../agent-transcript.js';
import { launchMeshAgent } from './launcher.js';
import type { MeshRunContext } from './run-context.js';
import type {
MeshBodyState,
MeshDispatchPort,
Expand Down Expand Up @@ -81,12 +87,21 @@ async function continueCompleted(
config: Config,
agent: MeshAgent,
prompt: string,
deliveryId: string,
): Promise<MeshStartResult> {
const registry = config.getBackgroundTaskRegistry();
const agentId = meshBackgroundAgentId(agent);
const outcome = registry.continueResidentAgent(agentId, prompt);
const outcome = registry.continueResidentAgent(
agentId,
prompt,
deliveryId,
);
if (outcome === 'continued') {
return { status: 'started', sessionId: config.getSessionId() };
return {
status: 'started',
sessionId: config.getSessionId(),
consumedOnStart: false,
};
}
if (outcome === 'capacity_wait') return { status: 'capacity_wait' };
if (outcome === 'not_completed') {
Expand All @@ -104,7 +119,33 @@ async function continueCompleted(
failureStage: 'revive',
};
}
return { status: 'started', sessionId: config.getSessionId() };
return {
status: 'started',
sessionId: config.getSessionId(),
consumedOnStart: true,
};
}

function bindNextTurn(
config: Config,
agent: MeshAgent,
binding: MeshRunContext,
): void {
const metaPath = getAgentMetaPath(
config.getProjectRoot(),
config.getSessionId(),
meshBackgroundAgentId(agent),
);
patchAgentMeta(metaPath, { meshRun: binding });
const stored = readAgentMeta(metaPath)?.meshRun;
if (
stored?.workspaceId !== binding.workspaceId ||
stored.threadId !== binding.threadId ||
stored.runId !== binding.runId ||
stored.attempt !== binding.attempt
) {
throw new Error(`Could not bind mesh run "${binding.runId}" to its body.`);
}
}

/**
Expand All @@ -119,13 +160,42 @@ export function createMeshDispatchPort(config: Config): MeshDispatchPort {
async inspect(agent) {
return inspectBody(config, agent);
},
async start({ action, agent, prompt }) {
async start({
action,
agent,
prompt,
workspaceId,
threadId,
rootThreadId,
runId,
attempt,
contextThroughSequence,
}) {
try {
const binding: MeshRunContext = {
workspaceId,
agentId: agent.id,
runId,
threadId,
rootThreadId,
attempt,
contextThroughSequence,
};
if (action !== 'launch') bindNextTurn(config, agent, binding);
switch (action) {
case 'launch': {
const result = await launchMeshAgent(config, agent, prompt);
const result = await launchMeshAgent(
config,
agent,
prompt,
binding,
);
if (result.status === 'started') {
return { status: 'started', sessionId: result.sessionId };
return {
status: 'started',
sessionId: result.sessionId,
consumedOnStart: true,
};
}
if (result.status === 'capacity_wait') {
return { status: 'capacity_wait' };
Expand All @@ -151,10 +221,14 @@ export function createMeshDispatchPort(config: Config): MeshDispatchPort {
failureStage: 'resume',
};
}
return { status: 'started', sessionId: config.getSessionId() };
return {
status: 'started',
sessionId: config.getSessionId(),
consumedOnStart: true,
};
}
case 'continue_completed':
return await continueCompleted(config, agent, prompt);
return await continueCompleted(config, agent, prompt, runId);
default: {
const exhaustive: never = action;
return failure(
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/agents/mesh/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ export type MeshBodyState =
export type MeshStartAction = 'launch' | 'resume' | 'continue_completed';

export type MeshStartResult =
| { status: 'started'; sessionId: string; transcriptStartOffset?: number }
| {
status: 'started';
sessionId: string;
transcriptStartOffset?: number;
consumedOnStart?: boolean;
}
| { status: 'capacity_wait' }
| { status: 'agent_unavailable'; error: string }
| { status: 'launch_failed'; error: string; failureStage?: string };
Expand All @@ -72,10 +77,12 @@ export interface MeshDispatchPort {
action: MeshStartAction;
agent: MeshAgent;
prompt: string;
workspaceId: string;
threadId: string;
rootThreadId: string;
runId: string;
attempt: number;
contextThroughSequence: number;
}): Promise<MeshStartResult>;
/** Definition content hash, when the port can supply one (§9.4). */
definitionVersion?(agent: MeshAgent): Promise<string | undefined>;
Expand Down Expand Up @@ -215,10 +222,12 @@ export async function dispatchOnce(
action,
agent,
prompt: prompt.text,
workspaceId: workspace.workspaceId,
threadId: thread.id,
rootThreadId: thread.rootThreadId,
runId: run.id,
attempt: claimed.run.attempts,
contextThroughSequence: prompt.contextThroughSequence,
});

if (result.status === 'started') {
Expand All @@ -228,6 +237,7 @@ export async function dispatchOnce(
attempt: claimed.run.attempts,
sessionId: result.sessionId,
contextThroughSequence: prompt.contextThroughSequence,
consumedOnStart: result.consumedOnStart,
...(definitionVersion ? { definitionVersion } : {}),
...(result.transcriptStartOffset !== undefined
? { transcriptStartOffset: result.transcriptStartOffset }
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/agents/mesh/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
buildMeshToolConfig,
createMeshToolInvocationGuard,
} from './capability.js';
import type { MeshRunContext } from './run-context.js';
import type { MeshAgent } from './types.js';

export type MeshAgentLaunchResult =
Expand All @@ -32,6 +33,7 @@ export async function launchMeshAgent(
config: Config,
agent: MeshAgent,
prompt: string,
meshRun?: MeshRunContext,
): Promise<MeshAgentLaunchResult> {
if (agent.enabled === false) {
return {
Expand Down Expand Up @@ -83,6 +85,7 @@ export async function launchMeshAgent(
{
agentId: backgroundAgentId,
meshAgentId: agent.id,
...(meshRun ? { meshRun } : {}),
subagentConfig: definition,
toolConfig: buildMeshToolConfig(runtimeConfig.toolConfig),
},
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/agents/mesh/run-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface MeshRunContext {
rootThreadId: string;
/** 1 for the first execution of this run; higher after a revive. */
attempt: number;
/** Last thread message included in this turn's delivery. */
contextThroughSequence?: number;
}

const store = new AsyncLocalStorage<MeshRunContext>();
Expand Down
Loading
Loading