From 6da2710012c769258c214d214ff5118f343996e0 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Mon, 12 Oct 2026 13:03:49 +0530 Subject: [PATCH] feat(inbox): picking an assignee on a proposal card accepts it (v0.459.0) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++++++ docs/tasks-plan.md | 9 +++++---- package-lock.json | 4 ++-- package.json | 2 +- scripts/task-proposals-test.cjs | 17 +++++++++++------ src/server.ts | 13 +++++++++++-- web/src/App.tsx | 3 ++- 7 files changed, 40 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 876f2561..b8016ce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ new version heading in the same commit. ## [Unreleased] +## [0.459.0] - 2026-10-12 +### Changed +- **Picking an assignee on an Inbox proposal card accepts it.** The card's assignee picker now moves an + agent-proposed task to `todo` with that person or agent assigned, the same as assigning it on the board + (one edit, so the assignee gets one notice; audited `task.proposal.accepted` with `via: 'assign'`). + Clearing the pick still leaves it proposed. + **For users:** Choosing who should work an agent's proposed task in the Inbox now puts it on the board for them — no separate Accept click. [Open Tasks](#/tasks) + ## [0.458.0] - 2026-10-12 ### Added - **DataForSEO stops "disconnecting": a local, API-key connector, and the gate now notices when any remote connector loses its login.** Every tenant's DataForSEO connector pointed at the hosted `mcp.dataforseo.com` URL with no headers, which authorizes with an OAuth grant kept in Claude Code's own config dir. The grant expires, isn't there at all on a pooled runtime account, and when it's missing Claude Code silently swaps every tool for `authenticate`/`complete_authentication` stubs an unattended run can't complete (20 stub calls in one tenant's transcripts). initech's `composio-company` has failed the same way since 2026-09-08, taking GSC/GA4/DataForSEO/Stripe/Sheets with it, and nobody was told. Two catalog entries, **DataForSEO** (`dataforseo-mcp-server@3.1.3`: `api_request` + docs tools) and **DataForSEO (v2 tools)** (`@2.9.13`: the 89 per-endpoint tools existing prompts name), run the official server over stdio with `secret:DATAFORSEO_LOGIN`/`secret:DATAFORSEO_PASSWORD`. Each call is plain Basic auth, with nothing to expire. Versions are pinned because v2→v3 renamed every tool. A cold `npx` install measured 21–35s, close to the MCP startup timeout, so enabled `npx` connectors are pre-fetched at boot and on add/enable (`src/connectors/npx-warm.ts`, audited `connector.warmed`). And a call to any connector's OAuth stub now reaches the gate as what it is: audited `connector.auth.lost`, one admin card per connector per 6h naming the fix, and on an unattended run a refusal telling the agent to carry on without it instead of hunting for a login (`src/connectors/mcp-auth-stub.ts`). Runbook `docs/connectors/dataforseo.md`; pinned by `scripts/connector-auth-stub-test.cjs`. diff --git a/docs/tasks-plan.md b/docs/tasks-plan.md index a0168fcc..d4913dbb 100644 --- a/docs/tasks-plan.md +++ b/docs/tasks-plan.md @@ -809,10 +809,11 @@ status puts a human's accept between "an agent thought this is work" and "it is nobody is told "assigned to you" for work no human agreed to, and the agent's `task_update({ assignee })` on its proposal only revises the pick. Accepting with no assignee set adopts it (`TaskStore.update`, so the Inbox Accept, Accept & run, and the board status picker all agree); dismissing doesn't. -- **Assigning IS accepting on the board.** A board `PATCH` that sets an assignee on a proposal (no status - in the same edit) moves it to `todo` — picking who works it is the reviewer saying it is work. Same - gate as the accept button, audited `task.proposal.accepted` with `via: 'assign'`. The Inbox card's - re-point (`action: 'assign'`) still leaves it proposed: there Accept / Accept & run sit right beside it. +- **Assigning IS accepting.** A board `PATCH` that sets an assignee on a proposal (no status in the same + edit) moves it to `todo` — picking who works it is the reviewer saying it is work — and so does the + Inbox card's re-point (`action: 'assign'` with an assignee). Same gate as the accept button, audited + `task.proposal.accepted` with `via: 'assign'`. Only clearing the pick (`assignee: null`) leaves it + proposed. To run it straight away, Accept & run still re-points and dispatches in one click. - **Not held:** an auto-dispatch hand-off (a `task_wait` caller would hang on a click — bound those with a delegation budget instead), a `goal:`-provenance run (a human asked for that plan), a human's task. - **Bounds:** 25 open proposals per agent (`MAX_OPEN_TASK_PROPOSALS`), then `task_create` refuses. The diff --git a/package-lock.json b/package-lock.json index 41463640..d43843a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.458.0", + "version": "0.459.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.458.0", + "version": "0.459.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index deedbf85..207ba7f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.458.0", + "version": "0.459.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/scripts/task-proposals-test.cjs b/scripts/task-proposals-test.cjs index 68b81095..1f5a6300 100644 --- a/scripts/task-proposals-test.cjs +++ b/scripts/task-proposals-test.cjs @@ -188,12 +188,17 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); assert(bad.status === 400, 'an unknown assignee is refused', bad); const byBob = await member(bob, 'POST', '/api/tasks/proposals/decide', { ids: [p1.id], action: 'assign', assignee: bob.id }); assert(byBob.status === 403 && !aos.tasks.get(p1.id).assignee, 'a bystander cannot re-point it', byBob); - const toBob = await member(alice, 'POST', '/api/tasks/proposals/decide', { ids: [p1.id], action: 'assign', assignee: bob.id }); - const t1 = aos.tasks.get(p1.id); - assert(toBob.status === 200 && t1.assignee === bob.id && t1.status === 'proposed', 'assign changes the assignee and leaves it proposed', t1); - assert(taskCards(p1.id).length === 0, 'and the new human assignee hears nothing until it is accepted', taskCards(p1.id).length); const cleared = await member(alice, 'POST', '/api/tasks/proposals/decide', { ids: [p1.id], action: 'assign', assignee: null }); - assert(cleared.status === 200 && !aos.tasks.get(p1.id).assignee, 'null clears it', aos.tasks.get(p1.id).assignee); + assert(cleared.status === 200 && !aos.tasks.get(p1.id).assignee && aos.tasks.get(p1.id).status === 'proposed', 'null clears the pick and leaves it proposed', aos.tasks.get(p1.id)); + assert(taskCards(p1.id).length === 0, 'and nobody hears about work no one accepted', taskCards(p1.id).length); + + const p0 = await create('ses_r', { title: 'assign from the card', suggestedAssignee: 'agent:qa' }); + const toBob = await member(alice, 'POST', '/api/tasks/proposals/decide', { ids: [p0.id], action: 'assign', assignee: bob.id }); + const t0 = aos.tasks.get(p0.id); + assert(toBob.status === 200 && toBob.body.decided.includes(p0.id) && t0.assignee === bob.id && t0.status === 'todo', 'picking an assignee on the card accepts it onto the board', t0); + assert(taskCards(p0.id).length === 1, 'and the new assignee hears about it exactly once', taskCards(p0.id).length); + const viaCard = aos.db.prepare("SELECT data FROM audit_events WHERE type = 'task.proposal.accepted' ORDER BY rowid DESC LIMIT 1").get(); + assert(JSON.parse(viaCard.data).via === 'assign' && JSON.parse(viaCard.data).id === p0.id, 'audited as an accept via assign', viaCard.data); const noAgent = await member(owner, 'POST', '/api/tasks/proposals/decide', { ids: [p1.id], action: 'accept', run: true }); assert(noAgent.body.error && aos.tasks.get(p1.id).status === 'proposed', 'Accept & run without an agent assignee accepts nothing', noAgent.body); @@ -206,7 +211,7 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); assert(go.status === 200 && go.body.decided.includes(p1.id) && aos.tasks.get(p1.id).status === 'todo', 'Accept & run accepts it', go.body); assert(dispatched.includes(p1.id) && go.body.dispatched.length === 1, 'and dispatches it in the same click', go.body); const rev = aos.db.prepare("SELECT count(*) n FROM audit_events WHERE type = 'task.proposal.reassigned'").get().n; - assert(rev === 3, 'each re-assignment is audited', rev); + assert(rev === 2, 'each re-assignment is audited (clearing an already-empty pick is not one)', rev); } console.log('\n\x1b[1m9) a proposal SUGGESTS who should work it; assigning on the board accepts it\x1b[0m'); diff --git a/src/server.ts b/src/server.ts index 4f1286d5..92e3f4ef 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4395,8 +4395,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: ? (tm.taskProposalCardTasks(b.messageId) ?? []) : Array.isArray(b.ids) ? b.ids.map(String) : []; if (!ids.length) return sendJson(res, 400, { error: 'no tasks named' }); - // The reviewer can re-point a proposal at the right person/agent from the card itself — alone - // (`assign`, the task stays proposed) or in the same click as accepting it. `null` clears it. + // The reviewer can re-point a proposal at the right person/agent from the card itself. Picking someone + // (`assign`) IS accepting it — the same rule as assigning on the board: choosing who works it is the + // reviewer saying it is work. Only `null` (clearing the pick) leaves it proposed. const hasAssignee = 'assignee' in b; const assignee = hasAssignee ? (typeof b.assignee === 'string' && b.assignee ? b.assignee : null) : undefined; if (action === 'assign' && !hasAssignee) return sendJson(res, 400, { error: 'assign needs an assignee (or null to clear it)' }); @@ -4414,6 +4415,14 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const t = os.tasks.get(id); if (!t || t.status !== 'proposed') continue; if (!mayDecideProposal(me, t)) { denied++; continue; } + if (action === 'assign' && assignee) { + // One edit, so the new assignee gets one "assigned" notice for accepted work, not two. + if (!os.tasks.update(id, { assignee, status: 'todo', note: 'accepted onto the board', by: me.id })) continue; + if ((t.assignee ?? null) !== assignee) os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'task.proposal.reassigned', data: { id, from: t.assignee ?? null, to: assignee } }); + os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'task.proposal.accepted', data: { id, title: t.title, by: t.createdBy, via: 'assign' } }); + decided.push(id); + continue; + } if (hasAssignee && (t.assignee ?? null) !== assignee) { os.tasks.update(id, { assignee, by: me.id }); os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'task.proposal.reassigned', data: { id, from: t.assignee ?? null, to: assignee } }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 52d9775a..d6d5a90d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6939,7 +6939,7 @@ function ProposedTaskItem({ t, members, agents, busy, onDecide }: { t: ProposedT {due && {due.label}} {pending ? (