Skip to content
Closed
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
25 changes: 21 additions & 4 deletions docs/plans/2026-09-06-multi-agent-board-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ kept in the next isolated child PR:
| `core/src/tools/mesh-thread.ts` | The six thread tools; ambient identity only |
| `core/src/agents/mesh/dispatcher.ts` | FIFO selection, runtime entry point, parent reports |
| `core/src/agents/mesh/dispatch-port.ts` | The one binding to the background-agent runtime |
| `cli/src/serve/mesh/mesh-host-session.ts` | Hidden ACP host ownership, keepalive, reload |
| `cli/src/serve/mesh/mesh-host-session.ts` | Hidden ACP host ownership, keepalive, reload |
| `acp-bridge` + `cli/src/acp-integration/` | Private daemon-to-host launch control |

### 5.1 Local review correction — committed and verified
Expand Down Expand Up @@ -631,6 +631,20 @@ Dependencies, with an early vertical proof before reliability and UI breadth.
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.
**Where the loop runs (decided 2026-09-07).** Inside the hidden host
session, not in the daemon. The launcher and the background-agent registry
live in that session's process, and the first live slice was driven by hand
from exactly there; ticking where the state is makes `inspect` a local
registry read and a start a local call. The daemon's whole job is to keep
the host resident: `server.ts` starts one `startMeshHostSessionOwner` per
trusted workspace and ensures residency whenever the roster is non-empty,
and the ACP child starts `startMeshSupervisor` for any session whose source
type is the mesh host. The supervisor dispatches only while its session
still holds the workspace's host claim, so a stale duplicate cannot give an
agent two bodies, and it polls rather than waits for notifications because
posts written by the daemon's REST route land in the store, not in this
process. Option (b), a daemon-side loop with an `inspect` round trip per
candidate, was rejected as a process boundary on every tick for no gain.
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 All @@ -641,9 +655,12 @@ Dependencies, with an early vertical proof before reliability and UI breadth.
Alice body continued and closed the root `review`. The first continuation
attempt exposed and fixed the sidecar storage-root mismatch described in
§0.2.
8. **Dispatcher reliability** — direct running delivery,
acceptance recording, completion reconciliation, launch failure, done/
cancellation, restart and stall recovery, and full outbox replay.
8. **Dispatcher reliability** — launch failure, done/cancellation, restart and
stall recovery, and full outbox replay. Direct running delivery, acceptance
recording and unconsumed-trigger reconciliation moved ahead of step 7: they
are what "a person can interject at any moment" means, and until they
existed a post into a running turn was recorded on the run and then
silently dropped — the one failure this design says it will not have.
9. **REST routes and Web Shell** — roster, thread list/view, busy reason, gates,
failures, cancellation, and transcript slices; absorb #11140's entry.
10. **Channel notifications** for blocker raised, aggregate in_review, gate
Expand Down
7 changes: 7 additions & 0 deletions docs/plans/2026-09-07-mesh-implementation-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ Writing the production port corrected the design's four-branch idle path to thre
`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.
**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 6 closed: the loop now runs unattended.** `supervisor.ts` ticks `dispatchOnce` inside the host session on a 2 s interval, first pass immediately, ticks coalesced rather than overlapped, and refuses to dispatch unless its session holds the host claim. `server.ts` keeps one host resident per trusted workspace whenever `agents.json` is non-empty; `acpAgent.ts` starts the supervisor for a mesh-host session and stops it when the session is discarded. Observed locally: `supervisor.test.ts` 3 tests (claim guard, empty roster, coalesced ticks); `src/agents/mesh/` 14 files / 139 tests after two stale assertions were aligned with #11252's watermark placement and #11252's `ToolNames` change.

**Local demo recipe (needs a build-capable machine and a model key).** `qwen serve`, then `POST /mesh/agents` twice (alice, bob — each `agentType` an existing read-only definition), then `POST /mesh/threads` with a body and `assignee: "alice"`. Within one keepalive interval the daemon spawns the hidden host; within 2 s the host's supervisor launches alice. Watch `Agents → Shared threads` in Web Shell (#11260) for the run row, alice's `thread_create` of a child for bob, her `thread_wait`, bob's `thread_review`, the parent report waking alice, and both threads reaching `in_review`. `qwen serve --debug` shows `MESH_SUPERVISOR` ticks.

**Mid-run steering landed, ahead of step 8.** It was scheduled with the reliability work, which left the system's one advantage over Multica unbuilt while the path through it looked supported: a post into a running turn was coalesced onto the run, charged, and then never delivered or rebooked. The dispatcher now pushes pending triggers into a running body through `queueExternalInput` with the run id as the delivery id, records them as accepted (not consumed — the drain event still commits), and the terminal write rebooks anything the run was told to answer and never read. Human and system triggers are replayed; an agent-authored post that missed is not, because its author is still on the thread and the turn gate exists to stop two agents re-triggering each other.
Observed: `src/agents/mesh/` 14 files / 144 tests, including steering a running agent, a refused delivery rebooked at finish, a consumed trigger not replayed, an agent post not replayed, and no rebook onto a `done` thread.

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

Lands: normally nothing; the first run may carry only defects that directly
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ import {
launchMeshAgent,
readMeshAgents,
readMeshWorkspace,
createMeshDispatchPort,
startMeshSupervisor,
type MeshSupervisor,
type MeshAgent,
type MeshAgentLaunchResult,
} from '@qwen-code/qwen-code-core';
Expand Down Expand Up @@ -4496,6 +4499,9 @@ class QwenAgent implements Agent {
return { closed: true, holds: [] };
}

/** One dispatch loop per mesh host session this process holds. */
private readonly meshSupervisors = new Map<string, MeshSupervisor>();

private async discardStoredSessionIfCurrent(
sessionId: string,
session: Session,
Expand All @@ -4509,6 +4515,8 @@ class QwenAgent implements Agent {
if (this.sessions.get(sessionId) !== session) {
return;
}
this.meshSupervisors.get(sessionId)?.stop();
this.meshSupervisors.delete(sessionId);
await this.closeStoredSession(sessionId, opts);
}

Expand Down Expand Up @@ -14553,6 +14561,23 @@ class QwenAgent implements Agent {
);
}
this.sessions.set(sessionId, session);
// A mesh host session dispatches its own workspace's booked work. The
// loop lives here rather than in the daemon because the launcher and the
// background-agent registry live in this process; the daemon's part is
// only to keep this session resident. The supervisor refuses to start
// anything unless this session still holds the workspace's host claim,
// so a stale duplicate cannot give one agent two bodies.
if (config.getSessionSourceType() === MESH_HOST_SESSION_SOURCE_TYPE) {
this.meshSupervisors.get(sessionId)?.stop();
this.meshSupervisors.set(
sessionId,
startMeshSupervisor({
projectRoot: config.getProjectRoot(),
sessionId: config.getSessionId(),
port: createMeshDispatchPort(config),
}),
);
}
// The session boots converged on the mode its settings derived; later
// reloads track convergence from here. Restricted sessions derive
// DEFAULT, mirroring the fold the reload loop applies to them.
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/src/serve/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Storage,
WebTerminalRegistry,
type DurableCronTask,
readMeshAgents,
} from '@qwen-code/qwen-code-core';
import type { DaemonLogger } from './daemon-logger.js';
import type { DaemonTrustPolicySnapshot } from '../config/daemon-trust-policy.js';
Expand Down Expand Up @@ -147,6 +148,7 @@ import {
} from './routes/scheduled-tasks.js';
import { registerChannelNotifyRoutes } from './routes/channel-notify.js';
import { registerGoalsRoutes } from './routes/goals.js';
import { startMeshHostSessionOwner } from './mesh/mesh-host-session.js';
import { registerUsageStatsRoutes } from './routes/usage-stats.js';
import {
collectBoundSessionIds,
Expand Down Expand Up @@ -3246,6 +3248,58 @@ export function createServeApp(
startKeepaliveForWorkspace(runtime);
}

// Agents-and-threads host. A workspace whose roster is non-empty gets one
// hidden host session kept resident; the dispatch loop runs inside that
// session, so all the daemon owes it is existence. Checked on the keepalive
// cadence: an empty roster costs one file read per interval and no session.
const meshHostStops = new Map<string, () => void>();
const startMeshHostForWorkspace = (runtime: WorkspaceRuntime) => {
const trusted = runtime.primary
? isPrimaryWorkspaceTrusted()
: runtime.trusted;
if (!trusted) return;
if (meshHostStops.has(runtime.workspaceCwd)) return;
const owner = startMeshHostSessionOwner({
bridge: runtime.bridge,
workspaceCwd: runtime.workspaceCwd,
intervalMs: keepaliveIntervalMs,
});
let ensuring = false;
const ensureIfRostered = async () => {
if (ensuring) return;
ensuring = true;
try {
const agents = await readMeshAgents(runtime.workspaceCwd);
if (agents.length > 0) await owner.ensureResident();
} catch (error) {
daemonLog?.warn(
`mesh host for ${runtime.workspaceCwd} not ensured: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
ensuring = false;
}
};
void ensureIfRostered();
const rosterTimer = setInterval(
() => void ensureIfRostered(),
keepaliveIntervalMs,
);
rosterTimer.unref?.();
meshHostStops.set(runtime.workspaceCwd, () => {
clearInterval(rosterTimer);
owner.stop();
});
};
for (const runtime of workspaceRegistry.list()) {
startMeshHostForWorkspace(runtime);
}
(app.locals as { stopMeshHosts?: () => void }).stopMeshHosts = () => {
for (const stop of meshHostStops.values()) stop();
meshHostStops.clear();
};

// Park a combined stop fn on `app.locals` (same pattern as `fsFactory` /
// `boundWorkspace` / `acpHandle` above) so the shutdown sequence in
// run-qwen-serve.ts can invoke it without threading it back through the
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/serve/server/session-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1586,7 +1586,11 @@ export async function searchWorkspaceSessionsForResponse(
for (const hit of hits) {
readOptions.signal?.throwIfAborted();
const item = await sessionService.getSessionListItem(hit.sessionId);
if (item?.sourceType !== MESH_HOST_SESSION_SOURCE_TYPE)
// Both conditions, and in this order. `item?.sourceType !== X` is true
// when the read found nothing, so folding the existence check into the
// optional chain lets a session that vanished between the search hit and
// this read through as an undefined summary.
if (item && item.sourceType !== MESH_HOST_SESSION_SOURCE_TYPE)
bySessionId.set(
hit.sessionId,
applyOrganization(
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,15 @@ export {
} from './mesh/mesh-store.js';
export { launchMeshAgent } from './mesh/launcher.js';
export type { MeshAgentLaunchResult } from './mesh/launcher.js';
// The dispatcher and its loop run inside the hidden host session, so the ACP
// child needs them; the daemon only keeps that session alive.
export { dispatchOnce } from './mesh/dispatcher.js';
export type { DispatchRecord, MeshDispatchPort } from './mesh/dispatcher.js';
export { createMeshDispatchPort } from './mesh/dispatch-port.js';
export {
DEFAULT_MESH_SUPERVISOR_INTERVAL_MS,
startMeshSupervisor,
} from './mesh/supervisor.js';
export type { MeshSupervisor, MeshTickOutcome } from './mesh/supervisor.js';
export type { MeshAgent, MeshWorkspaceState } from './mesh/types.js';
export * from './tasks/types.js';
13 changes: 10 additions & 3 deletions packages/core/src/agents/mesh/capability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,16 @@ describe('mesh capability boundary', () => {
expect(new Set(Object.keys(MESH_TOOL_CLASSIFICATION))).toEqual(
new Set([...Object.values(ToolNames), ...MESH_THREAD_TOOL_NAMES]),
);
expect(Object.values(ToolNames).map(classifyMeshTool)).not.toContain(
'thread',
);
// The thread tools are registered under ToolNames too, so the "core"
// side of this check excludes them by name rather than by class.
expect(
Object.values(ToolNames)
.filter(
(name) =>
!(MESH_THREAD_TOOL_NAMES as readonly string[]).includes(name),
)
.map(classifyMeshTool),
).not.toContain('thread');
expect(MESH_THREAD_TOOL_NAMES.map(classifyMeshTool)).toEqual(
MESH_THREAD_TOOL_NAMES.map(() => 'thread'),
);
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/agents/mesh/dispatch-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ function makeConfig(
};
const config = {
getProjectRoot: () => '/workspace',
// #11255 moved the sidecar lookup under the runtime project dir; the mock
// has to offer it or every start reads as a launch failure.
storage: { getProjectDir: () => '/workspace-runtime' },
getBackgroundTaskRegistry: () => registry,
getSessionId: () => 'se_host',
reviveCompletedBackgroundAgent: vi.fn(async () => overrides.revive),
Expand Down
18 changes: 13 additions & 5 deletions packages/core/src/agents/mesh/dispatch-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,7 @@ async function continueCompleted(
): Promise<MeshStartResult> {
const registry = config.getBackgroundTaskRegistry();
const agentId = meshBackgroundAgentId(agent);
const outcome = registry.continueResidentAgent(
agentId,
prompt,
deliveryId,
);
const outcome = registry.continueResidentAgent(agentId, prompt, deliveryId);
if (outcome === 'continued') {
return {
status: 'started',
Expand Down Expand Up @@ -160,6 +156,18 @@ export function createMeshDispatchPort(config: Config): MeshDispatchPort {
async inspect(agent) {
return inspectBody(config, agent);
},
async deliver({ agent, text, deliveryId }) {
// Structured input, not the plain string path: only the structured form
// carries a delivery id, and the id is what lets the drain event be
// matched back to this run rather than guessed at from the text.
return config
.getBackgroundTaskRegistry()
.queueExternalInput(meshBackgroundAgentId(agent), {
kind: 'message',
text,
deliveryId,
});
},
async start({
action,
agent,
Expand Down
Loading
Loading