From e70fb06c7cd5961815b856cc9a2d0ad38c6336a1 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Tue, 6 Oct 2026 14:20:15 +0530 Subject: [PATCH] fix(inbox): answering an agent's question or its blocked task resolves both (v0.455.1) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ package-lock.json | 4 +- package.json | 4 +- scripts/ask-block-link-test.cjs | 120 ++++++++++++++++++++++++++++++++ src/state/tasks.ts | 4 +- src/tenant-registry.ts | 43 +++++++++++- src/terminal.ts | 68 +++++++++++++++++- 7 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 scripts/ask-block-link-test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f13013a9..62994326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ new version heading in the same commit. ## [Unreleased] +## [0.455.1] - 2026-10-06 +### Fixed +- **An agent's question and its blocked task were two cards for one decision, and answering one left the other.** A run that `ask`s and then parks its task `blocked` raises both "needs your input" and "task blocked — needs you". They weren't linked: answering the question left the task blocked with its card open, and unblocking the task left the question pending. Worse, the answer could be lost: on the live case (instapods `tsk_5f40…`) the human answered 3s after the run parked the task, while its pane was still winding down, so `answerQuestion` assumed a live asker would poll for it — but the run had stopped waiting, ended, and never heard. Now answering the question (Inbox or DM, or a durable default firing) files the answer on the task, returns it to the board (closing the blocked card) and delivers it by dispatching THE TASK with the Q/A in its prompt — retrying while the asking pane is still up, never a second run beside it, and never via the wake queue, whose resume the task's one-live-run guard couldn't see. The reverse: a human unblocking the task (card, board, DM) settles the run's pending questions — the note typed on the card becomes the answer, no note cancels it (`TerminalManager.resolveTaskQuestions`). An agent leaving its own block settles nothing, and a park behind unfinished dependencies is never unblocked by an answer. Pinned by `scripts/ask-block-link-test.cjs`. + **For users:** When an agent both asks you a question and marks its task blocked, answering either one now clears both, and the agent picks the task back up with your answer. [Open Inbox](#/inbox) + ## [0.455.0] - 2026-10-06 ### Fixed - **"Unblock & run" could put two sessions on one task.** The console's dispatch (`POST /api/tasks/:id/dispatch`, `guard:false`) skipped the one-live-run-per-task check entirely, so a second click on the Inbox card — which stayed enabled until the next feed poll dropped it — spawned a parallel session (live: one instapods task got two sessions two seconds apart), and so did a click while the run that raised the block was still up. `canDispatch` now refuses a live run on EVERY path; what a human still forces past is the `blocked` park and the scheduler's caution (pool, per-agent busy). The route answers a refusal with the live session's id (`live: true, sessionId`), and the card keeps its buttons disabled once an action lands. Pinned by `scripts/unblock-run-dedupe-test.cjs`. diff --git a/package-lock.json b/package-lock.json index c3275274..3008983f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.455.0", + "version": "0.455.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.455.0", + "version": "0.455.1", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 11da4bbe..77348270 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.455.0", + "version": "0.455.1", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -27,7 +27,7 @@ "check-deps": "bash scripts/install-deps.sh --check", "dev": "ts-node src/cli.ts serve", "demo:dev": "ts-node src/demo.ts", - "test:governance": "node scripts/version-sync-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/cron-supersede-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-pool-signed-out-test.cjs && node scripts/login-expiry-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/unblock-run-dedupe-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/goal-heartbeat-test.cjs && node scripts/question-durability-test.cjs && node scripts/bets-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", + "test:governance": "node scripts/version-sync-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/cron-supersede-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-pool-signed-out-test.cjs && node scripts/login-expiry-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/unblock-run-dedupe-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/goal-heartbeat-test.cjs && node scripts/question-durability-test.cjs && node scripts/ask-block-link-test.cjs && node scripts/bets-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", "test:alert-staleness": "node scripts/alert-staleness-test.cjs", "test:deps": "node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-login-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs", "test:dm-continuity": "node scripts/dm-continuity-test.cjs", diff --git a/scripts/ask-block-link-test.cjs b/scripts/ask-block-link-test.cjs new file mode 100644 index 00000000..46f8eeab --- /dev/null +++ b/scripts/ask-block-link-test.cjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/* One decision, two cards: an agent that `ask`s and then parks its task `blocked` raises "needs your + * input" AND "task blocked — needs you". Answering EITHER must resolve both. + * (1) answer the question → the answer is filed on the task, the task returns to the board (its blocked + * card closes) and the task is dispatched with the Q/A in its prompt. + * (2) the asking run's pane is still winding down → the dispatch retries until it's gone (never two runs). + * (3) unblock the task with a note → the question is answered with that note; with no note → cancelled. + * (4) the agent leaving its own block settles nothing; a dependency park is never unblocked by an answer. + * Real tenant wiring (TenantRegistry), isolated home; createSession + liveness stubbed — no tmux/claude. */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-ask-block-test-')); +process.env.AGENT_OS_HOME = HOME; +process.env.AGENT_OS_TENANT = 'testco'; +process.env.AOS_NO_TTYD = '1'; +process.env.AOS_ANSWER_RETRY_MS = '40'; +delete process.env.AGENT_OS_SECRET_KEY; + +let pass = 0, fail = 0; +const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d !== undefined ? ' — ' + JSON.stringify(d).slice(0, 300) : ''}`)); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const { TenantRegistry } = require(path.join(ROOT, 'dist/tenant-registry.js')); + const registry = new TenantRegistry(ROOT, 0, path.join(ROOT, 'config/agent-os.config.json')); + registry.bootAll(); + const { os: aos, tm } = registry.default(); + aos.agents.set('engineer', { id: 'engineer', name: 'Engineer', runtime: 'claude-code', dir: HOME }); + const owner = aos.team.listMembers().find((m) => m.role === 'owner'); + + const alive = new Set(); + tm.backend.aliveNames = () => alive; + let sn = 0; + const spawned = []; + const mkRun = (taskId) => { + const id = 'ses_ab' + (++sn); + aos.db.prepare('INSERT INTO term_sessions (id,agent,title,task,tmux,status,spawned_by,run_as,headless,claude_session_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,1,?,?,?)') + .run(id, 'engineer', 't', 'x', 'aos-' + id, 'running', 'task:' + taskId, owner.id, 'cs_' + id, Date.now(), Date.now()); + aos.db.prepare('UPDATE tasks SET last_session_id = ? WHERE id = ?').run(id, taskId); + return id; + }; + tm.createSession = (agent, title, task, spawnedBy) => { + const id = mkRun(spawnedBy.slice('task:'.length)); + alive.add('aos-' + id); + spawned.push({ id, task }); + return { id, tmux: 'aos-' + id }; + }; + const end = (sid) => { alive.delete('aos-' + sid); aos.db.prepare("UPDATE term_sessions SET status='done' WHERE id=?").run(sid); }; + + let n = 0; + const mkTask = (o = {}) => aos.tasks.create({ tenant: aos.tenant, title: 'task ' + (++n), body: '', owner: owner.id, createdBy: owner.id, assignee: 'agent:engineer', ...o }); + // The live shape: the run asks, then parks the task. + const askThenBlock = (task, blockedOn = 'human') => { + const sid = mkRun(task.id); + const q = tm.askQuestion(sid, 'engineer', 'Sweep now or wait for the 24h cutoff?', undefined, ['Now', 'Wait']); + aos.tasks.update(task.id, { status: 'blocked', blockedOn, note: 'waiting on the sweep decision', by: 'agent:engineer' }); + return { sid, qid: q.id }; + }; + const blockedCardOpen = (taskId) => !!aos.db.prepare("SELECT 1 FROM messages WHERE type='task' AND status='open' AND session_id=? AND args LIKE '%\"event\":\"blocked\"%'").get('task:' + taskId); + const qrow = (id) => aos.db.prepare('SELECT status, answer FROM questions WHERE id = ?').get(id); + const questionNeedsYou = (id) => tm.listMessages(owner, 'all').some((m) => m.type === 'question' && m.questionId === id && m.status === 'pending'); + + console.log('\n\x1b[1m1) answering the question resolves the blocked task\x1b[0m'); + const t1 = mkTask(); + const a1 = askThenBlock(t1); + end(a1.sid); + assert(blockedCardOpen(t1.id) && questionNeedsYou(a1.qid), 'both cards are up', { card: blockedCardOpen(t1.id), q: questionNeedsYou(a1.qid) }); + tm.answerQuestion(a1.qid, 'Wait', owner.email); + assert(['todo', 'doing'].includes(aos.tasks.get(t1.id).status), 'the task is unblocked (and picked straight back up)', aos.tasks.get(t1.id).status); + assert(/Answered: Wait/.test(aos.tasks.lastComment(t1.id) ?? ''), 'the answer is filed on the task', aos.tasks.lastComment(t1.id)); + assert(!blockedCardOpen(t1.id), 'the blocked card closed'); + assert(!questionNeedsYou(a1.qid), 'the question card left "needs you"'); + assert(spawned.length === 1 && /A: Wait/.test(spawned[0].task), 'the task was dispatched with the Q/A in its prompt', spawned.map((s) => s.task.slice(-200))); + + console.log('\n\x1b[1m2) the asking pane is still up → wait for it, never a second run\x1b[0m'); + const t2 = mkTask(); + const a2 = askThenBlock(t2); + alive.add('aos-' + a2.sid); // turn-end reap hasn't happened yet + const before = spawned.length; + tm.answerQuestion(a2.qid, 'Now', owner.email); + await sleep(120); + assert(spawned.length === before, 'no dispatch while the asking run is live', spawned.length - before); + end(a2.sid); + await sleep(150); + assert(spawned.length === before + 1 && /A: Now/.test(spawned.at(-1).task), 'dispatched once the pane is gone', spawned.length - before); + + console.log('\n\x1b[1m3) unblocking the task settles the question\x1b[0m'); + const t3 = mkTask(); + const a3 = askThenBlock(t3); + end(a3.sid); + aos.tasks.update(t3.id, { status: 'todo', note: 'Wait for the cutoff', by: owner.id }); + assert(qrow(a3.qid).status === 'answered' && qrow(a3.qid).answer === 'Wait for the cutoff', 'a note on the unblock IS the answer', qrow(a3.qid)); + assert(!questionNeedsYou(a3.qid) && !blockedCardOpen(t3.id), 'both cards are gone'); + const t4 = mkTask(); + const a4 = askThenBlock(t4); + end(a4.sid); + aos.tasks.update(t4.id, { status: 'todo', by: owner.id }); + assert(qrow(a4.qid).status === 'cancelled', 'no note → the question is cancelled', qrow(a4.qid)); + assert(!questionNeedsYou(a4.qid), 'and leaves "needs you"'); + + console.log('\n\x1b[1m4) what does NOT link\x1b[0m'); + const t5 = mkTask(); + const a5 = askThenBlock(t5); + aos.tasks.update(t5.id, { status: 'doing', by: 'agent:engineer' }); + assert(qrow(a5.qid).status === 'pending', 'the agent leaving its own block is not an answer to its own question', qrow(a5.qid)); + const dep = mkTask({ title: 'blocker' }); + const t6 = mkTask({ dependsOn: [dep.id] }); + const a6 = askThenBlock(t6, 'agent'); + end(a6.sid); + tm.answerQuestion(a6.qid, 'Now', owner.email); + assert(aos.tasks.get(t6.id).status === 'blocked', 'a park behind an unfinished dependency stays parked', aos.tasks.get(t6.id).status); + + console.log(`\n${pass} passed, ${fail} failed`); + registry.stopAll?.(); + fs.rmSync(HOME, { recursive: true, force: true }); + process.exit(fail ? 1 : 0); +})().catch((e) => { console.error(e); fs.rmSync(HOME, { recursive: true, force: true }); process.exit(1); }); diff --git a/src/state/tasks.ts b/src/state/tasks.ts index 2742fe10..4f2eca67 100644 --- a/src/state/tasks.ts +++ b/src/state/tasks.ts @@ -68,6 +68,8 @@ export interface TaskNotice { kind: 'created' | 'assigned' | 'status'; by: string; detail?: string; + /** The comment filed in the SAME update, if any — e.g. the answer typed on a blocked card. */ + note?: string; } export class TaskStore { @@ -274,7 +276,7 @@ export class TaskStore { // assignee's "assigned to you"), then any status transition (owner's "blocked"/"done"); the edge // wiring filters which merit a card + resolves the receiver. if (reassigned) this.notify({ task, kind: 'assigned', by: input.by }); - if (statusChange) this.notify({ task, kind: 'status', by: input.by, detail: statusChange }); + if (statusChange) this.notify({ task, kind: 'status', by: input.by, detail: statusChange, ...(input.note?.trim() ? { note: input.note.trim() } : {}) }); return task; } diff --git a/src/tenant-registry.ts b/src/tenant-registry.ts index 2e806efd..4c8e197e 100644 --- a/src/tenant-registry.ts +++ b/src/tenant-registry.ts @@ -14,7 +14,7 @@ import * as path from 'path'; import { spawn, ChildProcess } from 'child_process'; import { AgentOS, loadAgentOS, readRootConfig, RootConfig } from './kernel'; import { exampleCapabilities } from './capabilities/examples'; -import { TerminalManager, ApprovalNotice, QuestionNotice, MemberNotice, SessionEventNotice, TransferNotice, ReviewNotice, renderOptions } from './terminal'; +import { TerminalManager, ApprovalNotice, QuestionNotice, QuestionAnswerNotice, MemberNotice, SessionEventNotice, TransferNotice, ReviewNotice, renderOptions } from './terminal'; import { Automations } from './edge/automations'; import { WAKE_KIND_DONE } from './edge/wakeups'; import { AppSupervisor } from './edge/app-supervisor'; @@ -34,6 +34,11 @@ import { ensureNpmBoundary } from './edge/npm-boundary'; import { TenantRecord, TenantStore } from './state/control'; import { requestMetrics } from './edge/request-metrics'; +/** An answer that unblocks a task is delivered by dispatching it; while the asking run's pane is still + * winding down, retry this often for this long (~5 min) before injecting into the live pane instead. */ +const ANSWER_DISPATCH_RETRY_MS = Number(process.env.AOS_ANSWER_RETRY_MS) || 15_000; // env override: tests only +const ANSWER_DISPATCH_RETRIES = 20; + export interface TenantRuntime { record: TenantRecord; os: AgentOS; @@ -314,6 +319,34 @@ export class TenantRegistry { // can answer an hour later and the agent is actually told. A run with no pinned transcript can't be // resumed, so the queue refuses it and the answer stays readable via `check_inbox`. tm.setQuestionDeliverer((n) => { + // The answer also unblocked the task this run had parked: deliver it by dispatching THE TASK, which + // resumes the same transcript. A wake-queue resume would be a second run the task's one-live-run + // guard can't see — and an auto-dispatch tick would then start a third on the same transcript. + if (n.taskId) { + const who = n.defaulted ? 'nobody answered, so the default applied' : `answered by ${n.by}`; + const extra = `Your question was answered (${who}) and the task is back on the board:\n\nQ: ${n.prompt}\nA: ${n.answer}\n\nPick the work back up from here.`; + // The asking run usually still holds its pane for a few seconds after parking the task (turn-end + // reap), and the one-live-run guard rightly refuses a second session meanwhile. Retry until it is + // gone; a pane that never goes (a human attached) gets the answer injected instead. + const taskId = n.taskId; + let tries = 0; + const attempt = (): void => { + const t = os.tasks.get(taskId); + if (!t || t.status !== 'todo') return; // someone else picked it up / closed it meanwhile + const r = autos.dispatchTask(taskId, { guard: false, by: n.by, extra }); + if (r.ok) return; + if (/already working/.test(r.reason ?? '') && ++tries < ANSWER_DISPATCH_RETRIES) { + setTimeout(attempt, ANSWER_DISPATCH_RETRY_MS).unref?.(); + return; + } + deliverViaWakeup(n); + }; + attempt(); + return; + } + deliverViaWakeup(n); + }); + const deliverViaWakeup = (n: QuestionAnswerNotice): void => { const row = os.db .prepare('SELECT claude_session_id AS transcript, run_as AS runAs FROM term_sessions WHERE id = ?') .get<{ transcript: string | null; runAs: string | null }>(n.sessionId); @@ -328,7 +361,7 @@ export class TenantRegistry { message: `Your question has been answered (${who}).\n\nQ: ${n.prompt}\nA: ${n.answer}\n\n` + 'Pick the work back up from here — this is the decision you were waiting on.', }); - }); + }; // Task lifecycle → Inbox: a create/assign/status change lands an audience-addressed inbox card for // the right human (assignee/owner) — routed via resolveRecipients — and DMs them. Fires for EVERY // mutation path (console, agent MCP, dispatcher) because the sink lives on the store, not the routes. @@ -608,6 +641,12 @@ export function wireTaskNotices( // A block resolved ANYWHERE (a DM reply, the card's buttons, the board, the settled-deps sweep, the // agent itself) closes its "needs you" card — otherwise the Inbox keeps asking for work already done. if (notice.kind === 'status') tm.syncTaskBlockedCards(notice.task.id); + // …and a block a HUMAN resolved settles the question its run asked alongside it, so "needs your + // input" doesn't outlive "task blocked" for the same decision. An agent leaving its own block is not + // an answer to its own question. + if (notice.kind === 'status' && notice.detail?.startsWith('blocked→') && !notice.by.startsWith('agent:')) { + tm.resolveTaskQuestions(notice.task.id, notice.by, notice.note); + } // Async poke-back: a delegate that closed a `poke_on_done` hand-off wakes the CALLER agent with the // outcome, so a fire-and-forget delegation never has to poll. maybePokeCaller(autos, os, notice); diff --git a/src/terminal.ts b/src/terminal.ts index bf402aa6..9cb355b4 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -781,6 +781,10 @@ export interface QuestionAnswerNotice { answer: string; by: string; defaulted?: boolean; + /** The task this run left BLOCKED, now returned to the board by the answer — deliver by dispatching + * the task (which resumes the same transcript) rather than the wake queue, so the task's one-live-run + * guard sees the run. */ + taskId?: string; } /** What the member-notifier sink receives when an agent deliberately notifies a specific teammate via @@ -7172,14 +7176,21 @@ export class TerminalManager { if (!q || q.status !== 'pending') return false; this.db.prepare('UPDATE questions SET status = ?, answer = ?, answered_by = ?, answered_at = ? WHERE id = ?').run('answered', answer, by, Date.now(), id); this.audit(q.run_id, by, 'question.answered', { questionId: id }); + // An agent that asks and then parks its task `blocked` raises TWO cards for one decision — "needs + // your input" and "task blocked — needs you". Answering either must resolve both: here the answer is + // filed on the task and the task goes back to the board, which closes its blocked card (the task + // notifier → syncTaskBlockedCards). The reverse direction is resolveTaskQuestions. + const blocked = this.unblockForAnswer(q.run_id, id, q.prompt, answer, by); // A live asker is polling `/api/ask/:id` and picks this up by itself. A FINISHED one cannot, so the // answer goes through the wake queue — the one place that decides how to reach an agent (inject into a // live pane, else resume the transcript). Without this a durable question could be answered into // nothing, which is the old bug with extra steps. // `reachable` (is the pane there) rather than the row's status — the lesson from the four poke-lane // bugs in edge/wakeups.ts: status is not liveness. - if (!this.reachable(q.run_id)) { - try { this.questionDeliverer?.({ questionId: id, sessionId: q.run_id, agent: q.agent, prompt: q.prompt, answer, by }); } + // A run that went on to park its task `blocked` has stopped waiting on this answer even if its pane is + // still up (it is ending its turn) — so that case is delivered too, through the task. + if (blocked || !this.reachable(q.run_id)) { + try { this.questionDeliverer?.({ questionId: id, sessionId: q.run_id, agent: q.agent, prompt: q.prompt, answer, by, ...(blocked ? { taskId: blocked } : {}) }); } catch { /* delivery is advisory — the answer is recorded either way */ } } return true; @@ -7201,7 +7212,9 @@ export class TerminalManager { this.db.prepare("UPDATE questions SET status = 'answered', answer = ?, answered_by = 'system:default', answered_at = ? WHERE id = ?").run(q.default_answer, now, q.id); this.audit(q.run_id, 'system', 'question.defaulted', { questionId: q.id, answer: q.default_answer }); out.push({ id: q.id, outcome: 'defaulted' }); - try { this.questionDeliverer?.({ questionId: q.id, sessionId: q.run_id, agent: q.agent, prompt: q.prompt, answer: q.default_answer, by: 'system:default', defaulted: true }); } + // A default is a decision too: it unblocks the task the run parked on it, same as a human answer. + const blocked = this.unblockForAnswer(q.run_id, q.id, q.prompt, q.default_answer, 'system:default'); + try { this.questionDeliverer?.({ questionId: q.id, sessionId: q.run_id, agent: q.agent, prompt: q.prompt, answer: q.default_answer, by: 'system:default', defaulted: true, ...(blocked ? { taskId: blocked } : {}) }); } catch { /* advisory */ } } else { this.db.prepare("UPDATE questions SET status = 'cancelled', answered_by = 'system:expired', answered_at = ? WHERE id = ?").run(now, q.id); @@ -7436,6 +7449,55 @@ export class TerminalManager { return pending.length; } + /** File an answer on the task its run left blocked and return that task to the board (closing its + * blocked card via the task notifier). Returns the task id, or undefined when no task is waiting. */ + private unblockForAnswer(runId: string, questionId: string, prompt: string, answer: string, by: string): string | undefined { + const blocked = this.blockedTaskForRun(runId); + if (!blocked) return undefined; + const member = by.includes('@') ? this.os.team.getMemberByEmail(by) : undefined; + const q = prompt.length > 400 ? prompt.slice(0, 400) + '…' : prompt; + this.os.tasks.update(blocked.id, { status: 'todo', note: `Answered: ${answer}\n\n(Q: ${q})`, by: member?.id ?? by }); + this.audit(`task:${blocked.id}`, by, 'task.unblocked.viaQuestion', { id: blocked.id, questionId }); + return blocked.id; + } + + /** The task `runId` worked and left `blocked` — the one an answer to that run's question unblocks. Not a + * park behind unfinished dependencies: that wait is on a machine, and the dependency sweep owns it. */ + private blockedTaskForRun(runId: string): { id: string } | undefined { + const t = this.db + .prepare("SELECT id FROM tasks WHERE last_session_id = ? AND status = 'blocked' LIMIT 1") + .get<{ id: string }>(runId); + return t && !this.os.tasks.unmetDeps(t.id).length ? t : undefined; + } + + /** + * A task left `blocked` by a HUMAN's hand (the card's Unblock / Unblock & run, the board, a DM reply) + * settles the questions its runs left pending — the reverse of the link in {@link answerQuestion}, so + * the "needs your input" card leaves the Inbox with the "task blocked" one. With a note, the note IS + * the answer (a still-polling `ask` receives it); without one the question is cancelled. Written + * straight to the table — not through answerQuestion — so there is no second task update and no + * wake-queue delivery: the task's own dispatch is how the agent hears about it. + */ + resolveTaskQuestions(taskId: string, by: string, note?: string): number { + const pending = this.db + .prepare(`SELECT q.id, q.run_id FROM questions q + WHERE q.status = 'pending' + AND q.run_id IN (SELECT id FROM term_sessions WHERE spawned_by = ? + UNION SELECT last_session_id FROM tasks WHERE id = ? AND last_session_id IS NOT NULL)`) + .all<{ id: string; run_id: string }>(`task:${taskId}`, taskId); + const now = Date.now(); + for (const q of pending) { + if (note) { + this.db.prepare("UPDATE questions SET status = 'answered', answer = ?, answered_by = ?, answered_at = ? WHERE id = ?").run(note, by, now, q.id); + this.audit(q.run_id, by, 'question.answered', { questionId: q.id, via: 'task' }); + } else { + this.db.prepare("UPDATE questions SET status = 'cancelled', answered_by = ?, answered_at = ? WHERE id = ?").run(by, now, q.id); + this.audit(q.run_id, by, 'question.cancelled', { questionId: q.id, reason: 'task unblocked' }); + } + } + return pending.length; + } + /** Copy a dying question onto the task its run left blocked on a human, once (guarded on the text * already being the task's latest comment, so a crash-then-reap can't file it twice). */ private carryAskToBlockedTask(sessionId: string, q: { prompt: string }): void {