From 6f508eb669967f842fb57a296c76739f1325f7cf Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Tue, 6 Oct 2026 12:39:00 +0530 Subject: [PATCH] fix(tasks): Unblock & run can no longer double-spawn; card shows its session's state (v0.455.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console dispatch (guard:false) skipped the one-live-run-per-task check, so a second click on the blocked-task card — or a click while the blocking run was still up — spawned a parallel session on the same task. canDispatch now refuses a live run on every path; the route returns the live session id. The blocked card is hydrated with the task's current run (alive/ended) and offers Open session while it is live. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++ package-lock.json | 4 +- package.json | 4 +- scripts/unblock-run-dedupe-test.cjs | 115 ++++++++++++++++++++++++++++ src/edge/automations.ts | 13 +++- src/server.ts | 7 +- src/terminal.ts | 35 +++++++-- web/src/App.tsx | 31 ++++++-- web/src/lib/api.ts | 2 +- 9 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 scripts/unblock-run-dedupe-test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e1bad2..f13013a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ new version heading in the same commit. ## [Unreleased] +## [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`. +### Added +- **A blocked-task card shows whether its session is still running.** The Inbox's "Task blocked — needs you" card is hydrated with the task's current run (`args.run`: session id, alive/ended, status — `TerminalManager.taskRunStates`, one query + one cached liveness poll per feed read, only when such a card is on the page). While that run is live the card offers **Open session** instead of "Unblock & run", since the answer belongs in the session that asked. + **For users:** A "Task blocked — needs you" card now says whether the agent's session is still running or has ended, and lets you open it — so you answer in the live session instead of accidentally starting a second one. [Open Inbox](#/inbox) + ## [0.454.0] - 2026-10-05 ### Added - **The bets board, on the goal it belongs to.** A goal room gains a **Bets** tab beside Tasks (`BetsBoard` in `web/src/App.tsx`): every attempt at that goal's number, in lanes — Proposed, Running, Judged, Closed. A card carries the hypothesis, the lever, "day N of M" against its window with the judge date, the lift so far measured on the bet's OWN assets against what it predicted, the verdict pill once the arithmetic has run, the lesson, and the assets themselves as links with their value (or "unmeasured" — never a zero somebody would read as a result, and an unindexed asset says so). Owner/admin get **Judge now** on a running bet and Keep / Expand / Kill on a judged one, each requiring the lesson the server also requires. `verdict` and `observedLift` are displayed and never offered as an input, on either lane. Lanes size themselves and an empty lane is not drawn (named in one line underneath instead): the goal room's main column is ~750px on a laptop, where a fixed four-lane grid gives 170px of truncated URLs. diff --git a/package-lock.json b/package-lock.json index f4f24b7a..c3275274 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.454.0", + "version": "0.455.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.454.0", + "version": "0.455.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 80556082..11da4bbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.454.0", + "version": "0.455.0", "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/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/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/unblock-run-dedupe-test.cjs b/scripts/unblock-run-dedupe-test.cjs new file mode 100644 index 00000000..f2a6fc2b --- /dev/null +++ b/scripts/unblock-run-dedupe-test.cjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/* "Unblock & run" must never put two sessions on one task. + * (A) a human's console dispatch (guard:false) still refuses while the task's current run is live — + * the live double-click (tsk_ad11e51…: two sessions two seconds apart) and the click while the run + * that raised the block is still up. It still un-parks a `blocked` task, which is its point. + * (B) the blocked-task Inbox card carries that run's state (`args.run`: alive or ended), so the console + * can offer "Open session" instead of a second spawn. + * Isolated home; no tmux, no claude, no network — createSession and the liveness poll are stubbed. */ +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-unblock-run-test-')); +process.env.AGENT_OS_HOME = HOME; +process.env.AGENT_OS_TENANT = 'testco'; +process.env.AOS_NO_TTYD = '1'; +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 ? ' — ' + d : ''}`)); + +const { loadAgentOS } = require(path.join(ROOT, 'dist/kernel.js')); +const { TerminalManager } = require(path.join(ROOT, 'dist/terminal.js')); +const { notifyTaskEvent } = require(path.join(ROOT, 'dist/tenant-registry.js')); +const { Automations } = require(path.join(ROOT, 'dist/edge/automations.js')); + +const aos = loadAgentOS(); +aos.agents.set('engineer', { id: 'engineer', name: 'Engineer', runtime: 'claude-code', dir: HOME }); +const tm = new TerminalManager(aos, 'http://127.0.0.1:0', path.join(HOME, 'tmux.sock')); +const autos = new Automations(aos, tm); + +// The tmux server, stubbed: a Set of live pane names that a test flips by hand. +const alive = new Set(); +tm.backend.aliveNames = () => alive; +// Spawning, stubbed: write the row a real launch would, and bring its pane up. +let sn = 0; +const spawned = []; +tm.createSession = (agent, title, task, spawnedBy, headless, _s, _d, runAs) => { + const id = 'ses_' + (++sn); + aos.db.prepare('INSERT INTO term_sessions (id,agent,title,task,tmux,status,spawned_by,run_as,headless,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)') + .run(id, agent, title, task, 'aos-' + id, 'running', spawnedBy, runAs ?? null, headless ? 1 : 0, Date.now(), Date.now()); + alive.add('aos-' + id); + spawned.push(id); + return { id, tmux: 'aos-' + id }; +}; +const kill = (id, status = 'done') => { alive.delete('aos-' + id); aos.db.prepare('UPDATE term_sessions SET status=? WHERE id=?').run(status, id); }; + +const { member } = aos.team.invite({ email: 'owner@testco.dev', role: 'owner' }); +aos.db.prepare("UPDATE members SET status='active' WHERE id=?").run(member.id); +const owner = aos.team.getMember(member.id); +const quiet = { dmUser: async () => false, userIdForEmail: async () => undefined }; + +let n = 0; +const mkTask = () => aos.tasks.create({ + tenant: aos.tenant, title: 'task ' + (++n), body: '', owner: owner.id, createdBy: owner.id, + assignee: 'agent:engineer', autoDispatch: true, +}); +const block = async (id) => { + aos.tasks.update(id, { status: 'blocked', blockedOn: 'human', note: 'merge PR #883?', by: 'agent:engineer' }); + await notifyTaskEvent(aos, tm, quiet, quiet, 'https://console.example.com', { task: aos.tasks.get(id), kind: 'status', by: 'agent:engineer', detail: 'doing→blocked' }); +}; +// "Unblock & run" from the card = PATCH status→todo, then POST /dispatch (guard:false). +const unblockAndRun = (id) => { + aos.tasks.update(id, { status: 'todo', by: owner.id }); + return autos.dispatchTask(id, { guard: false, by: owner.email }); +}; +const card = (taskId) => tm.listMessages(owner, 'all').find((m) => m.type === 'task' && m.args?.taskId === taskId && m.args?.event === 'blocked'); + +(async () => { + console.log('\n\x1b[1mA. one live run per task, on the human path too\x1b[0m'); + const t = mkTask(); + const first = autos.dispatchTask(t.id, { guard: true }); + assert(first.ok, 'the first dispatch runs'); + await block(t.id); + + // The run that raised the block is still up → a human dispatch must not stack a second session. + const whileLive = unblockAndRun(t.id); + assert(!whileLive.ok && /already working/.test(whileLive.reason || ''), 'unblock & run while the blocking run is live is refused', JSON.stringify(whileLive)); + assert(spawned.length === 1, 'no second session was spawned', 'spawned=' + spawned.length); + assert(tm.liveTaskRuns([t.id])[t.id]?.sessionId === first.sessionId, 'the live run is the one the route hands back'); + + // That run ends → the human dispatch goes through (and un-parks, which is what it is for). + kill(first.sessionId); + await block(t.id); + const afterEnd = unblockAndRun(t.id); + assert(afterEnd.ok, 'once the run has ended, unblock & run dispatches', JSON.stringify(afterEnd)); + // The live double-click: the same action two seconds later. + const second = unblockAndRun(t.id); + assert(!second.ok, 'a second click on the same card does not spawn again', JSON.stringify(second)); + assert(spawned.length === 2, 'exactly one session per click-that-should-run', 'spawned=' + spawned.length); + + // guard:false still un-parks a blocked task (the scheduler's guard:true does not). + kill(afterEnd.sessionId); + aos.tasks.update(t.id, { status: 'blocked', by: 'agent:engineer' }); + assert(!autos.dispatchTask(t.id, { guard: true }).ok, 'the scheduler still respects a blocked park'); + assert(autos.dispatchTask(t.id, { guard: false, by: owner.email }).ok, 'a human dispatch still un-parks it'); + + console.log('\n\x1b[1mB. the blocked card says whether that run is still up\x1b[0m'); + const u = mkTask(); + const run = autos.dispatchTask(u.id, { guard: true }); + await block(u.id); + let c = card(u.id); + assert(c?.args?.run?.sessionId === run.sessionId && c.args.run.alive === true, 'live run → args.run.alive = true', JSON.stringify(c?.args?.run)); + kill(run.sessionId, 'crashed'); + c = card(u.id); + assert(c?.args?.run?.alive === false && c.args.run.status === 'crashed', 'ended run → alive = false, with its status', JSON.stringify(c?.args?.run)); + const v = mkTask(); + await block(v.id); + assert(card(v.id) && card(v.id).args.run === undefined, 'a task that never ran carries no run'); + + console.log(`\n${pass} passed, ${fail} failed`); + 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/edge/automations.ts b/src/edge/automations.ts index eed79d86..cf01b4e1 100644 --- a/src/edge/automations.ts +++ b/src/edge/automations.ts @@ -824,8 +824,8 @@ export class Automations { * enforces, so there is exactly one cascade and both callers read it. * * Pure — no writes, safe to call per row on every render. `guard` mirrors dispatchTask: `true` is the - * scheduler's cautious mode (respects a deliberate `blocked` park, the one-live-run pile-up rule, and a - * dry runtime pool), `false` is a human forcing it from the console. + * scheduler's cautious mode (respects a deliberate `blocked` park and a dry runtime pool), `false` is a + * human forcing it from the console. The one-live-run-per-task rule holds in BOTH modes. */ canDispatch(id: string, opts: { guard?: boolean } = {}): { ok: boolean; reason?: string; code?: TaskDispatchBlock } { const guard = opts.guard ?? true; @@ -844,7 +844,14 @@ export class Automations { const agentId = (t.assignee || '').startsWith('agent:') ? t.assignee!.slice('agent:'.length) : ''; if (!agentId) return no('unassigned', 'task has no agent assignee'); if (!this.os.agents.has(agentId)) return no('unknown-agent', `unknown agent: ${agentId}`); - if (guard && t.lastSessionId && this.tm.reachable(t.lastSessionId)) { + // One live run per task, on EVERY path — a human's console dispatch included. `guard:false` used to + // skip this ("a human forcing it"), which made the Inbox's "Unblock & run" a double-spawn: a second + // click two seconds after the first (live: tsk_ad11e51…, two sessions on one task), or a click while + // the run that raised the block was still up, each launched a parallel session on the same work. What + // a human forces past is the PARK (`blocked`) and the scheduler's caution (pool, per-agent busy) — + // never "two agents editing the same branch". The dispatch route answers this with the live session + // id so the console can offer it instead. + if (t.lastSessionId && this.tm.reachable(t.lastSessionId)) { return no('live', 'a session is already working this task'); } // Defer a guarded (scheduler-driven) dispatch when the agent's runtime pool is exhausted — retried next diff --git a/src/server.ts b/src/server.ts index 47d71598..c933d0c6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4480,8 +4480,11 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const agentId = (task.assignee || '').startsWith('agent:') ? task.assignee!.slice('agent:'.length) : ''; if (!agentId) return sendJson(res, 400, { error: 'assign an agent before dispatching' }); if (!os.team.canRun(me, agentId)) return sendJson(res, 403, { error: `you are not assigned to run "${agentId}"` }); - const r = autos.dispatchTask(task.id, { guard: false, by: me.email }); // explicit human action — no pile-up guard - return sendJson(res, r.ok ? 200 : 409, r.ok ? { ok: true, sessionId: r.sessionId } : { ok: false, error: r.reason }); + const r = autos.dispatchTask(task.id, { guard: false, by: me.email }); // explicit human action — un-parks `blocked` + // Refused because a run is already live (a second click, or the run that raised the block is still up): + // hand back THAT session so the console can offer "open it" instead of a dead-end error. + const live = r.ok ? undefined : tm.liveTaskRuns([task.id])[task.id]; + return sendJson(res, r.ok ? 200 : 409, r.ok ? { ok: true, sessionId: r.sessionId } : { ok: false, error: r.reason, ...(live ? { live: true, sessionId: live.sessionId } : {}) }); } // Delete. Owner/admin may remove any task; ANY member may remove a DRAFT of their own — a task they // filed that has never been dispatched and no session has ever touched (`isDraftTask`). A never-run diff --git a/src/terminal.ts b/src/terminal.ts index 73dc12d7..bf402aa6 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -2505,21 +2505,36 @@ export class TerminalManager { */ liveTaskRuns(taskIds: string[]): Record { const out: Record = {}; + for (const [id, r] of Object.entries(this.taskRunStates(taskIds))) { + if (r.alive) out[id] = { sessionId: r.sessionId, agent: r.agent, since: r.since }; + } + return out; + } + + /** + * The CURRENT run (`last_session_id`) of each task, live OR ended — {@link liveTaskRuns} without the + * filter, plus the row's status. A blocked-task Inbox card shows this, because "Unblock & run" means two + * different things depending on it: when the run that raised the block is still up, the answer belongs + * IN that session (and the dispatch route refuses a second one); when it has ended, a fresh dispatch is + * the way on. Same liveness predicate as the dispatch guard, one query + ONE poll for the whole set. + */ + taskRunStates(taskIds: string[]): Record { + const out: Record = {}; const ids = taskIds.filter(Boolean); if (!ids.length) return out; const rows = this.db .prepare(`SELECT t.id AS task_id, s.id AS id, s.agent AS agent, s.tmux AS tmux, s.status AS status, - s.created_at AS created_at + s.created_at AS created_at, s.updated_at AS updated_at FROM tasks t JOIN term_sessions s ON s.id = t.last_session_id WHERE t.id IN (${ids.map(() => '?').join(',')})`) - .all<{ task_id: string; id: string; agent: string; tmux: string; status: string; created_at: number }>(...ids); + .all<{ task_id: string; id: string; agent: string; tmux: string; status: string; created_at: number; updated_at: number }>(...ids); if (!rows.length) return out; const alive = this.backend.aliveNames(); for (const r of rows) { const live = this.launching.has(r.id) // scheduled; its pane is imminent (mirrors `reachable`) || (r.status !== 'stopped' && r.status !== 'crashed' && r.status !== 'paused' && (alive ? alive.has(r.tmux) : r.status === 'running')); // no poll possible → trust the row - if (live) out[r.task_id] = { sessionId: r.id, agent: r.agent, since: r.created_at }; + out[r.task_id] = { sessionId: r.id, agent: r.agent, since: r.created_at, alive: live, status: r.status, updatedAt: r.updated_at }; } return out; } @@ -2780,7 +2795,13 @@ export class TerminalManager { // aren't flooded by every session's cards; `all` is the explicit oversight view (owner/admin only — // a member's `all` and `mine` are identical since they only ever see their own). if (viewer && scope === 'mine') visible = visible.filter((r) => this.isAddressedTo(r, viewer)); - return visible.map(toMessage).map((m) => (m.type === 'task.proposed' ? this.hydrateTaskProposalCard(m) : m.type === 'task' ? this.hydrateTaskCard(m) : m)); + const msgs = visible.map(toMessage); + // The run behind each BLOCKED-task card, batched (one query + one liveness poll per feed read, and + // only when such a card is in the page) — see hydrateTaskCard. + const blockedIds = msgs.filter((m) => m.type === 'task' && (m.args as { event?: string } | undefined)?.event === 'blocked') + .map((m) => (m.args as { taskId?: string }).taskId ?? '').filter(Boolean); + const runs = blockedIds.length ? this.taskRunStates(blockedIds) : {}; + return msgs.map((m) => (m.type === 'task.proposed' ? this.hydrateTaskProposalCard(m) : m.type === 'task' ? this.hydrateTaskCard(m, runs) : m)); } /** Mark one message read for a member (per-member; idempotent upsert). Visibility-guarded like the @@ -6645,10 +6666,13 @@ export class TerminalManager { * {@link hydrateTaskProposalCard}, and for the same reason: the card is acted on (unblock / reassign / * cancel), so deciding on a snapshot means deciding on whoever the assignee was an hour ago. `status` * also tells the client a `blocked` card has already been resolved elsewhere. */ - private hydrateTaskCard(m: FeedMessage): FeedMessage { + private hydrateTaskCard(m: FeedMessage, runs: ReturnType = {}): FeedMessage { const args = (m.args ?? {}) as { taskId?: string; event?: string; reason?: string }; if (!args.taskId) return m; const live = this.os.tasks.get(args.taskId); + // `run` — is the session that raised the block still up? Stamped only for `blocked` cards (the only + // ones `runs` was computed for), and only for the task's CURRENT run. + const run = runs[args.taskId]; return { ...m, args: { @@ -6658,6 +6682,7 @@ export class TerminalManager { taskTitle: live.title, taskStatus: live.status, autoDispatch: live.autoDispatch, ...(live.assignee ? { assignee: live.assignee } : {}), ...(live.blockedOn ? { blockedOn: live.blockedOn } : {}), + ...(run ? { run: { sessionId: run.sessionId, alive: run.alive, status: run.status, at: run.alive ? run.since : run.updatedAt } } : {}), } : { taskStatus: 'deleted' }), }, diff --git a/web/src/App.tsx b/web/src/App.tsx index ada7f590..03eacd22 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5255,6 +5255,8 @@ type BlockedTaskArgs = { taskTitle?: string; taskStatus?: TaskStatus | 'deleted'; assignee?: string; blockedOn?: string; autoDispatch?: boolean /** The ask's multiple-choice options, when the agent offered them — one-click unblock. */ options?: string[] + /** The task's current run — is the session that raised the block still up? (`at` = started if alive, else last update.) */ + run?: { sessionId: string; alive: boolean; status: string; at: number } } /** An agent flagged a progress update as a key milestone / heads-up (carried in `args.important`). */ @@ -7054,16 +7056,22 @@ function ActionItem({ m, me, members, agents, onOpen, onDismiss }: { m: Msg; me: const agentId = assignee?.startsWith('agent:') ? assignee.slice('agent:'.length) : '' const onHuman = a.blockedOn === 'human' const runnable = agents.filter((ag) => ag.runtime === 'claude-code') + // The run that raised the block. While it's still up, "Unblock & run" would only be refused (one live + // run per task) — the answer belongs IN that session, so the card offers it instead. + const run = a.run + const openRun = (sid: string) => onOpen('aos-' + sid, (agentId || m.agent) + ' · ' + sid) + // `busy` stays set once an action lands: the card lingers until the next feed poll drops it, and a + // re-enabled "Unblock & run" in that window is exactly how one task got two sessions. const act = async (next: 'todo' | 'cancelled', run = false, pick?: string) => { setBusy(true); setHint('') const note = pick ?? answer.trim() const r = await api.patchTask(taskId, { status: next, ...(note ? { note } : {}) }) if (r.error) { setBusy(false); return setHint('⚠ ' + r.error) } setAnswer('') - if (next === 'cancelled') { setBusy(false); return setHint('cancelled') } - if (!run) { setBusy(false); return setHint('back on the board') } + if (next === 'cancelled') return setHint('cancelled') + if (!run) return setHint('back on the board') const d = await api.dispatchTask(taskId) - setBusy(false) + if (d.live && d.sessionId) return setHint('unblocked — its session is still running; open it to continue') setHint(d.error ? '⚠ unblocked, but ' + d.error : 'unblocked — a session is running it') } const reassign = async (to: string | null) => { @@ -7081,6 +7089,15 @@ function ActionItem({ m, me, members, agents, onOpen, onDismiss }: { m: Msg; me: {onHuman ? 'blocked on you' : a.blockedOn === 'agent' ? 'waiting on an agent' : 'blocked'} {title} + {run ? ( +
+ + {run.alive + ? session still running · started {timeAgo(run.at)} ago + : session ended · {run.status} {timeAgo(run.at)} ago} + +
+ ) : null} {a.reason ?
why:
:
The agent left no reason — open the task to see what it was doing.
} @@ -7111,9 +7128,11 @@ function ActionItem({ m, me, members, agents, onOpen, onDismiss }: { m: Msg; me: className="mt-1.5 w-full resize-y rounded-md border bg-background px-2 py-1.5 text-xs" />
- {agentId - ? - : null} + {run?.alive + ? + : agentId + ? + : null} {hint && {hint}} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f9578b47..59491bbd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -2231,7 +2231,7 @@ export const api = { addTask: (b: AddTaskReq) => call<{ ok: boolean; task?: Task; error?: string }>('POST', '/api/tasks', b), patchTask: (id: string, b: { title?: string; body?: string; status?: TaskStatus; assignee?: string | null; priority?: number; labels?: string[]; mode?: 'headless' | 'interactive'; goalId?: string | null; criteria?: string | null; dependsOn?: string[]; dueAt?: number | null; note?: string }) => call<{ ok: boolean; task?: Task; error?: string }>('PATCH', `/api/tasks/${id}`, b), commentTask: (id: string, body: string) => call<{ ok: boolean; task?: Task; error?: string }>('POST', `/api/tasks/${id}/comment`, { body }), - dispatchTask: (id: string) => call<{ ok: boolean; sessionId?: string; error?: string }>('POST', `/api/tasks/${id}/dispatch`), + dispatchTask: (id: string) => call<{ ok: boolean; sessionId?: string; error?: string; /** refused: this session is already working the task */ live?: boolean }>('POST', `/api/tasks/${id}/dispatch`), /** Accept (→ todo) or dismiss (→ cancelled) agent-proposed tasks: by `ids`, or every task on one Inbox card (`messageId`). */ decideTaskProposals: (b: { ids?: string[]; messageId?: string; action: 'accept' | 'dismiss' | 'assign'; assignee?: string | null; run?: boolean }) => call<{ ok: boolean; decided?: string[]; denied?: number; dispatched?: Array<{ id: string; sessionId?: string; error?: string }>; error?: string }>('POST', '/api/tasks/proposals/decide', b), taskProposalsSetting: () => call<{ enabled: boolean; error?: string }>('GET', '/api/settings/task-proposals'),