From 1b00f8dbac3b7461fc91826e7447eadc79307979 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Thu, 16 Jul 2026 14:55:34 +0530 Subject: [PATCH] fix(delegation): default the poke-back ON + deliver it to a live caller (v0.223.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent that dispatched a task to another agent never learned it finished unless a human said "check that task status" — two compounding gaps: 1. poke_on_done was opt-in/off and no agent set it, so caller_agent was never recorded (null on every prod task) and the caller was never woken. task_create now defaults the async wake ON for an agent→agent hand-off (opt out with poke_on_done:false; wait:true still supersedes; a self-assignment never wakes, guarded server-side too). 2. pokeCaller skipped a still-live caller on the wrong assumption it would "see the result itself" — an interactive/resident caller sits idle at the prompt and observes nothing. It now injects the result into the live pane (idle→runs now, mid-turn→queues) and only --resumes a caller that exited. Verified: MCP default truth table, server self-poke guard, and an in-memory TaskStore round-trip (caller fields persist; a done status-notice carries them to maybePokeCaller). Typecheck + 68/68 governance conformance green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/edge/automations.ts | 47 ++++++++++++++++++++++++++++++---------- src/memory/memory-mcp.ts | 23 ++++++++++++++------ src/server.ts | 11 ++++++---- 6 files changed, 75 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 272a9f48..75a5f694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ new version heading in the same commit. ## [Unreleased] +## [0.225.0] — 2026-07-16 +### Fixed +- **Delegation loop closes itself: the caller is woken when the delegate finishes.** Two gaps meant an + agent that dispatched a task to another agent never learned it was done unless a human told it to "check + that task status": (1) `poke_on_done` was opt-in and defaulted off, and no agent ever set it, so the + caller was never recorded (`caller_agent` was null on every task in prod) and never woken — only the + human owner got a `task.notified` DM; (2) even when a poke did fire, `pokeCaller` *skipped* a caller + whose session was still alive, assuming it "will see the result itself" — but an interactive/resident + caller sits IDLE at the prompt after ending its turn and observes nothing. Now: `task_create` defaults + the async wake **ON** for an agent→agent hand-off (opt out with `poke_on_done:false`; `wait:true` still + supersedes with a synchronous block; self-assignment never wakes), and `pokeCaller` delivers into a live + caller by **injecting** the result into its pane (idle → runs now, mid-turn → queues to the next turn + boundary) instead of skipping — falling back to a `--resume` only when the caller has already exited. + ## [0.224.0] — 2026-07-16 ### Added - **"Activity" shortcut in the per-session Operations menu.** The session activity trail side panel diff --git a/package-lock.json b/package-lock.json index e498f1d2..85d561f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.224.0", + "version": "0.225.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.224.0", + "version": "0.225.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 207163b4..475ac7df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.224.0", + "version": "0.225.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/edge/automations.ts b/src/edge/automations.ts index 21227aed..c77c152b 100644 --- a/src/edge/automations.ts +++ b/src/edge/automations.ts @@ -415,23 +415,46 @@ export class Automations { } /** - * Wake a CALLER agent by resuming its transcript with a completion poke — the async "really done" signal - * a delegate sends back when it finishes a `poke_on_done` hand-off. Fires IMMEDIATELY (unlike schedule(), - * whose 1-min floor makes it a scheduler, not a wake): `--resume`s `callerClaudeId` with `message` as the - * next turn, so the caller continues its OWN plan with full context. Guarded — if that transcript already - * has a live session, the running caller will observe the outcome itself, so we skip rather than spawn a - * competing run on one conversation (same concern the chat thread-continuity path handles). The delegate + * Wake a CALLER agent with a completion poke — the "really done" signal a delegate sends back when it + * finishes a `poke_on_done` hand-off. Two delivery paths, chosen by whether the caller's transcript + * still has a LIVE session: + * + * - **Live caller → inject** (the common interactive/resident case). The caller ended its turn and is + * sitting IDLE at the prompt — it will NOT observe the delegate's completion on its own (the bug this + * fixes: an interactive session had to be told "check that task status" by hand). So we type the poke + * into its live pane (`injectToSession`, submit): an idle claude runs it immediately, a mid-turn + * claude queues it to the next turn boundary — never a competing `--resume` on one conversation. + * - **Dead caller → resume** (a headless caller that exited at turn-end). Nothing to type into, so we + * `--resume` `callerClaudeId` in a fresh `poke:` run with `message` as the next turn, and the caller + * continues its OWN plan with full context. + * + * Fires IMMEDIATELY (unlike schedule(), whose 1-min floor makes it a scheduler, not a wake). The delegate * (task assignee) is always the actor, never the caller, so this can't self-wake. Audited `agent.poked`. */ pokeCaller(input: { callerAgent: string; callerClaudeId: string; runAs?: string; message: string; source: string }): FireResult { const agentId = input.callerAgent.startsWith('agent:') ? input.callerAgent.slice('agent:'.length) : input.callerAgent; if (!this.os.agents.has(agentId)) return { ok: false, reason: `unknown caller agent: ${agentId}` }; if (!input.callerClaudeId) return { ok: false, reason: 'no caller transcript to resume' }; - const live = this.db - .prepare("SELECT id FROM term_sessions WHERE claude_session_id = ? AND status = 'running'") - .all<{ id: string }>(input.callerClaudeId) - .some((r) => this.tm.isAlive(r.id)); - if (live) return { ok: false, reason: 'caller session still live — it will see the result itself' }; + // Prefer delivering into the caller's OWN live session if it still has one bound to this transcript — + // an idle interactive/resident caller would otherwise never learn the delegate finished. + const liveSession = this.db + .prepare("SELECT id, tmux FROM term_sessions WHERE claude_session_id = ? AND status = 'running'") + .all<{ id: string; tmux: string }>(input.callerClaudeId) + .find((r) => this.tm.isAlive(r.id)); + if (liveSession) { + const injected = this.tm.injectToSession(liveSession.id, input.message, true, input.runAs ? `member:${input.runAs}` : 'system'); + this.os.audit.append({ + ts: Date.now(), + runId: liveSession.id, + tenant: this.os.tenant, + principal: input.runAs ? `member:${input.runAs}` : 'system', + type: 'agent.poked', + data: { caller: agentId, source: input.source, runAs: input.runAs ?? null, via: 'inject', ok: injected.ok }, + }); + // Keystrokes landed → the caller has (or will imminently have) the result. On the rare inject failure + // (unreadable pane) fall through to a resume so the poke is never silently dropped. + if (injected.ok) return { ok: true, sessionId: liveSession.id, tmux: liveSession.tmux }; + } const s = this.tm.createSession(agentId, `Poke ← ${input.source}`, input.message, `poke:${input.source}`, true, undefined, undefined, input.runAs, input.callerClaudeId); this.os.audit.append({ ts: Date.now(), @@ -439,7 +462,7 @@ export class Automations { tenant: this.os.tenant, principal: input.runAs ? `member:${input.runAs}` : 'system', type: 'agent.poked', - data: { caller: agentId, source: input.source, runAs: input.runAs ?? null }, + data: { caller: agentId, source: input.source, runAs: input.runAs ?? null, via: 'resume' }, }); return { ok: true, sessionId: s.id, tmux: s.tmux }; } diff --git a/src/memory/memory-mcp.ts b/src/memory/memory-mcp.ts index 75c2dfd6..4923092c 100644 --- a/src/memory/memory-mcp.ts +++ b/src/memory/memory-mcp.ts @@ -880,7 +880,7 @@ const TOOLS = [ goalId: { type: 'string', description: 'Link this task to a strategic goal it advances (see goal_list for ids). Its progress then counts toward that goal.' }, goal: { type: 'string', description: 'The single-line objective the delegate must achieve — the definition of done. On a headless auto-dispatched task the worker runs under this as a `/goal` and converges autonomously until it holds (alias for `criteria`). This is what to state when you delegate WITH a goal.' }, criteria: { type: 'string', description: 'A single-line, transcript-verifiable acceptance condition, e.g. "all tests in test/auth pass". When set on a headless auto-dispatched task, the worker runs under this as a `/goal` and converges autonomously until it holds. Synonym of `goal`.' }, - poke_on_done: { type: 'boolean', description: 'Fire-and-forget delegation with an async wake-up: hand off, end your turn, and be RESUMED automatically when the delegate finishes (or blocks) — no polling. The async counterpart to `wait` (which blocks). Only for an agent-assigned auto-dispatched task. Default false.' }, + poke_on_done: { type: 'boolean', description: 'Async wake-up on completion: hand off, end your turn, and be woken automatically when the delegate finishes (or blocks) — no polling. The async counterpart to `wait` (which blocks in-line). DEFAULTS ON when you delegate to another agent, so the loop closes itself — set it to false only when you truly want fire-and-forget and do NOT need the result. Ignored on a self-assignment or an open/human task (no separate caller to wake).' }, dependsOn: { type: 'array', items: { type: 'string' }, description: 'Task ids this task is BLOCKED BY — it will not dispatch until they are all done. To encode a pipeline: file the earlier steps first, capture their ids from the results, and pass them here so this step waits for them.' }, autoDispatch: { type: 'boolean', description: 'If true and assigned to an agent, the board auto-spawns a session to work it. Default false.' }, mode: { type: 'string', enum: ['headless', 'interactive'], description: 'How a dispatched session runs: "headless" (default — works to completion then exits) or "interactive" (an attachable TUI a human drives).' }, @@ -2101,6 +2101,15 @@ function parseDue(due: unknown): number | null | undefined { async function taskCreate(args: Record): Promise { const title = String(args.title ?? '').trim(); if (!title) return 'A task needs a title.'; + // Default the delegation loop CLOSED: an agent→agent hand-off wakes the caller when the delegate + // finishes, so a dispatch doesn't silently strand the caller waiting for a result it never learns + // arrived. `wait:true` supersedes (it delivers the result synchronously in-line); opt out of the wake + // with `poke_on_done:false`. Self-assignment ("me") never wakes — the server drops a self-poke too. + const assigneeStr = args.assignee !== undefined ? String(args.assignee) : ''; + const toAgent = assigneeStr === 'me' || assigneeStr.startsWith('agent:'); + const waiting = args.wait === true; + const pokeOnDone = args.poke_on_done === true + || (toAgent && assigneeStr !== 'me' && !waiting && args.poke_on_done !== false); const res = await fetch(AOS_URL + '/api/tasks/create', { method: 'POST', headers: H({ 'content-type': 'application/json' }), @@ -2117,8 +2126,8 @@ async function taskCreate(args: Record): Promise { dependsOn: Array.isArray(args.dependsOn) ? args.dependsOn.map(String) : undefined, // `wait` (block) and `poke_on_done` (async wake) both imply autoDispatch — you can't await/be-woken // by work that never starts. - autoDispatch: args.autoDispatch === true || args.wait === true || args.poke_on_done === true, - pokeOnDone: args.poke_on_done === true, + autoDispatch: args.autoDispatch === true || waiting || pokeOnDone, + pokeOnDone, mode: args.mode === 'interactive' ? 'interactive' : undefined, dueAt: parseDue(args.due), }), @@ -2127,13 +2136,13 @@ async function taskCreate(args: Record): Promise { if (!d.ok) return `Could not create the task: ${d.error ?? 'unknown error'}`; const who = args.assignee ? ` (assigned to ${String(args.assignee)})` : ' (open — anyone can claim it)'; // wait:true → delegate synchronously: file it, then block until the delegate closes the loop. - if (args.wait === true) { + if (waiting) { const outcome = await taskWait({ id: d.id, timeoutSeconds: args.timeoutSeconds }); return `Filed task ${d.id}: "${title}"${who}.\n${outcome}`; } - // poke_on_done → fire-and-forget async: don't poll or wait. End your turn; you'll be resumed with the - // result the moment the delegate finishes (or blocks). - if (args.poke_on_done === true) { + // poke_on_done (explicit or the agent→agent default) → don't poll or wait. End your turn; you'll be + // woken with the result the moment the delegate finishes (or blocks). + if (pokeOnDone) { return `Filed task ${d.id}: "${title}"${who}. You'll be woken with the result when it finishes — you can end your turn now; no need to poll.`; } return `Filed task ${d.id}: "${title}"${who}. Track it with task_get "${d.id}".`; diff --git a/src/server.ts b/src/server.ts index 3f68dea1..5978458a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1420,10 +1420,13 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: return sendJson(res, 200, { ok: false, error: `no agent "${targetId}" — assign to one of: ${valid} (call list_agents to see the roster)` }); } } - // Poke-back: an agent delegating to ANOTHER agent can ask to be woken when the delegate finishes. - // We stamp the caller's agent id + pinned claude transcript so the task notifier can `--resume` this - // session on done/blocked. Only meaningful for an agent→agent hand-off (a poke has a caller to wake). - const pokeOnDone = (b.pokeOnDone === true || b.pokeOnDone === 'true') && !!assignee && assignee.startsWith('agent:'); + // Poke-back: an agent delegating to ANOTHER agent is woken when the delegate finishes (the MCP layer + // defaults this ON for agent→agent hand-offs so the delegation loop closes itself). We stamp the + // caller's agent id + pinned claude transcript so the task notifier can wake this session on + // done/blocked. Only for a hand-off to a DIFFERENT agent — a self-assignment has no separate caller to + // wake (and waking your own transcript from its own delegate would loop), so we drop the self-poke. + const pokeOnDone = (b.pokeOnDone === true || b.pokeOnDone === 'true') + && !!assignee && assignee.startsWith('agent:') && assignee !== `agent:${agent}`; try { // owner defaults to the creating session's run-as member — HUMAN PASSTHROUGH: a task filed by an // agent acting as Alice dispatches (later) as Alice too, so accountability ladders to the person.