diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f9b606..9450ac7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ new version heading in the same commit. ## [Unreleased] +## [0.457.0] - 2026-10-09 +### Added +- **A proposed task names who should work it, and assigning it on the board accepts it.** Agents filed proposals with no owner a quarter of the time, and to propose work for ANOTHER agent they had to fight `task_create`'s agent→agent default (poke-on-done implies auto-dispatch, so naming `agent:qa` handed it off instead of proposing it). `task_create` gains `suggest` (`agent:` / `me` / a member id), required whenever the task lands `proposed` (refused with the roster otherwise). The pick is stored as `tasks.suggested_assignee`, not the assignee — advice the reviewer weighs; the agent re-pointing it with `task_update` only revises it. Accepting a proposal with no assignee adopts the suggestion (Inbox Accept, Accept & run, and moving it to `todo` on the board all agree); dismissing doesn't. On the board, assigning someone to a proposal moves it straight to `todo`, gated like the accept button and audited `task.proposal.accepted` (`via: 'assign'`); a board accept/dismiss is now audited at all. The Inbox card and the board's proposals strip show the pick marked "suggested", and the task page offers **Assign & accept**. Pinned by `scripts/task-proposals-test.cjs` §9. + **For users:** Agents now suggest who should take each task they propose. Accept it as-is, or just assign someone on the task page — that moves it straight to To do. [Open Tasks](#/tasks) + ## [0.456.2] - 2026-10-08 ### Fixed - **A resumed session was cut again within minutes, mid-work.** The interactive LIFETIME ceiling (`interactiveMaxHours`, default 168 h) measured age from `created_at`, and nothing a human did reset it — so any session older than a week was "over lifetime" forever, and every resume was reaped on the next sweep the moment no browser terminal happened to be attached. Live instawp: a `qa` session created 13 days earlier was resumed by its owner five times in two days and reaped 1–30 minutes after each one, once with its agent mid-turn. A deliberate re-open (attach-resume `markResumed`, unpause, a crashed pane restored under an attached human) now stamps a new `term_sessions.opened_at`, and the ceiling measures from `COALESCE(opened_at, created_at)`. Sessions that were never re-opened — the 42-day zombies the ceiling exists for — age out exactly as before; restore-on-agent-activity deliberately does not restart the clock. The `session.reaped` audit on a lifetime reap also named the wrong knob (`idleHours: 72`, the idle setting); it now records `lifetimeHours` + `openedAt`. Pinned by `scripts/idle-reaper-test.cjs`. diff --git a/docs/agent-mcp-tools.md b/docs/agent-mcp-tools.md index d06c1f3f..9573d797 100644 --- a/docs/agent-mcp-tools.md +++ b/docs/agent-mcp-tools.md @@ -88,7 +88,7 @@ scope/parity properties, but not the cost. | `policy_check` | `POST /api/agent/policy/check` | policy preview | R | dry-run, no side effects | | `directory_lookup` | `GET /api/agent/directory` | `TeamStore` | R | people + their chat identities | | `list_agents` | `GET /api/agent/roster` | `os.agents` (peer claude-code agents) | R | the fleet roster — every OTHER agent you can hand work to (id + description + category), so you pick the right specialist instead of guessing an id (a task/ask to a non-existent agent never runs). The agent-side companion to `directory_lookup` (people); pair it with `ask_agent` (sync Q&A) or `task_create({ assignee:"agent:" })` (durable hand-off) | -| `task_create` | `POST /api/tasks/create` | `TaskStore.create` | W | files a unit of work; author `agent:`; owner = run-as (delegation passthrough); `mode` headless/interactive for the dispatched run; optional `model` + `effort` (low…max) that PIN the dispatched session's runtime tuning — highest-priority override over the assignee agent's manifest + the workspace default (`resolveRuntimeTuning(agent, defaults, taskOverride)`), validated at the edge with `sanitizeRuntimeTuning`; useful to delegate background work at a cheaper/stronger tier than your own; optional `due` (ISO date) soft deadline; optional `goalId` (link to a strategic goal; a sub-task inherits its parent's `goalId` when omitted) + single-line `goal`/`criteria` (synonyms — the objective that drives a headless dispatch under a `/goal` convergence condition — Slice 2) + `dependsOn` (task ids this is blocked by — won't dispatch until they're done; cycle/self/missing rejected). **Async poke-back**: `poke_on_done:true` (agent→agent hand-off only) stamps the caller's agent id + pinned claude transcript on the task (`caller_agent`/`caller_claude_id`); on the delegate closing the loop (done/blocked) the task notifier hands the outcome to the **wake queue** (`Automations.pokeCaller` → `edge/wakeups.ts`), which delivers it into the caller's own live pane, else another live pane of that agent, else a `--resume` run (provenance `poke:`; audited `agent.poked` with the lane in `via`) and retries anything undeliverable — the fire-and-forget counterpart to `wait` (which blocks). **The resume lane is priced by what the wake is FOR** (`kind`, docs/tasks-plan.md §3.8): a `done` completion injects into a live caller but is DROPPED at a cold one (audited `agent.poke.skipped`, `reason: 'done-cold-caller'`) — nobody is stuck, the result is on the task and the owner already has the card; a `blocked` hand-back or a delegate whose run died still resumes, since only the caller can move those. Implies `autoDispatch`. **Self-dispatch is refused**: assigning a task to YOURSELF with `autoDispatch` ends your turn and respawns you with an empty context to do work the session you are in already has loaded — the server rejects it (audited `task.self_dispatch.refused`) and tells you to do it in this turn, `schedule` it for genuinely later, or file it `autoDispatch:false` for the board. Measured on the live fleet before the guard: 104 such tasks in 7 days, 70 dispatched within 2 minutes, $1,330 of pure context reload. A goal-plan run is exempt. **A task filed WITHOUT dispatching it is a PROPOSAL** (`status: proposed`, response `proposed:true`): it sits on one Inbox card per run until the run-as human or an admin accepts it onto the board — the agent isn't blocked, and can't accept its own (docs/tasks-plan.md §Proposed tasks; off via the `task_proposals` setting) | +| `task_create` | `POST /api/tasks/create` | `TaskStore.create` | W | files a unit of work; author `agent:`; owner = run-as (delegation passthrough); `mode` headless/interactive for the dispatched run; optional `model` + `effort` (low…max) that PIN the dispatched session's runtime tuning — highest-priority override over the assignee agent's manifest + the workspace default (`resolveRuntimeTuning(agent, defaults, taskOverride)`), validated at the edge with `sanitizeRuntimeTuning`; useful to delegate background work at a cheaper/stronger tier than your own; optional `due` (ISO date) soft deadline; optional `goalId` (link to a strategic goal; a sub-task inherits its parent's `goalId` when omitted) + single-line `goal`/`criteria` (synonyms — the objective that drives a headless dispatch under a `/goal` convergence condition — Slice 2) + `dependsOn` (task ids this is blocked by — won't dispatch until they're done; cycle/self/missing rejected). **Async poke-back**: `poke_on_done:true` (agent→agent hand-off only) stamps the caller's agent id + pinned claude transcript on the task (`caller_agent`/`caller_claude_id`); on the delegate closing the loop (done/blocked) the task notifier hands the outcome to the **wake queue** (`Automations.pokeCaller` → `edge/wakeups.ts`), which delivers it into the caller's own live pane, else another live pane of that agent, else a `--resume` run (provenance `poke:`; audited `agent.poked` with the lane in `via`) and retries anything undeliverable — the fire-and-forget counterpart to `wait` (which blocks). **The resume lane is priced by what the wake is FOR** (`kind`, docs/tasks-plan.md §3.8): a `done` completion injects into a live caller but is DROPPED at a cold one (audited `agent.poke.skipped`, `reason: 'done-cold-caller'`) — nobody is stuck, the result is on the task and the owner already has the card; a `blocked` hand-back or a delegate whose run died still resumes, since only the caller can move those. Implies `autoDispatch`. **Self-dispatch is refused**: assigning a task to YOURSELF with `autoDispatch` ends your turn and respawns you with an empty context to do work the session you are in already has loaded — the server rejects it (audited `task.self_dispatch.refused`) and tells you to do it in this turn, `schedule` it for genuinely later, or file it `autoDispatch:false` for the board. Measured on the live fleet before the guard: 104 such tasks in 7 days, 70 dispatched within 2 minutes, $1,330 of pure context reload. A goal-plan run is exempt. **A task filed WITHOUT dispatching it is a PROPOSAL** (`status: proposed`, response `proposed:true`): it sits on one Inbox card per run until the run-as human or an admin accepts it onto the board — the agent isn't blocked, and can't accept its own (docs/tasks-plan.md §Proposed tasks; off via the `task_proposals` setting). A proposal must name who should work it via `suggest` (`agent:` / `me` / a member id; an `assignee` on a proposal is read the same way) — stored as `suggested_assignee`, not an assignment, refused when missing; accepting adopts it, and the agent's later `task_update({ assignee })` on a proposal only revises it | | `task_list` | `GET /api/tasks/list` | `TaskStore.list` | R | `assignee:"me"` → self; board query/FTS | | `task_get` | `GET /api/tasks/get` | `TaskStore.withEvents` | R | task + full activity timeline | | `task_claim` | `POST /api/tasks/claim` | `TaskStore.claim` | W | atomic take (→ doing); loses if already claimed | diff --git a/docs/tasks-plan.md b/docs/tasks-plan.md index 11889398..a0168fcc 100644 --- a/docs/tasks-plan.md +++ b/docs/tasks-plan.md @@ -803,6 +803,16 @@ status puts a human's accept between "an agent thought this is work" and "it is owner/admin; the board PATCH out of `proposed` is gated identically, and nothing can move INTO it. An agent may withdraw its own (→ `cancelled`) or refine the text, never accept it. Audited `task.proposed` / `task.proposal.accepted` / `task.proposal.dismissed`. +- **Suggested assignee.** Every proposal names who the agent thinks should work it — `task_create({ suggest })` + (an `assignee` on a proposal is read the same way), refused without one so the reviewer never has to + go find an owner after accepting. Stored in `tasks.suggested_assignee`, deliberately NOT `assignee`: + 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. - **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 68082194..4d6f4d19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.456.2", + "version": "0.457.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.456.2", + "version": "0.457.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index c43435d3..022261f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.456.2", + "version": "0.457.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 5cfd39ab..68b81095 100644 --- a/scripts/task-proposals-test.cjs +++ b/scripts/task-proposals-test.cjs @@ -71,7 +71,7 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); const taskCards = (taskId) => aos.db.prepare("SELECT * FROM messages WHERE type = 'task' AND session_id = ?").all(`task:${taskId}`); console.log('\n\x1b[1m1) an agent board item lands as a PROPOSAL, not work\x1b[0m'); - const r1 = await create('ses_a', { title: 'GET /api/pods caps at 20', body: 'found while fixing X' }); + const r1 = await create('ses_a', { title: 'GET /api/pods caps at 20', body: 'found while fixing X', suggestedAssignee: 'me' }); { assert(r1.ok === true && r1.proposed === true, 'accepted by the route, flagged proposed', r1); const t = aos.tasks.get(r1.id); @@ -93,7 +93,7 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); const args = JSON.parse(c[0].args); assert(args.tasks.length === 3, 'the card lists all three', args.tasks.map((x) => x.title)); assert(/3 tasks/.test(c[0].title), 'and its title counts them', c[0].title); - const rb = await create('ses_b', { title: 'cron finding' }); + const rb = await create('ses_b', { title: 'cron finding', suggestedAssignee: 'agent:qa' }); const cb = cards('ses_b'); assert(cb.length === 1 && cb[0].audience_kind === 'admins', 'a company-identity run gets its own card, addressed to admins', cb[0]); assert(rb.proposed === true, 'and is proposed too', rb); @@ -147,7 +147,7 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); const row1 = listed.args.tasks.find((x) => x.id === r1.id); assert(row1.body === 'found while fixing X' && row1.priority === 2 && typeof row1.createdAt === 'number', 'a row carries the live body, priority and filing time', row1); const due = Date.now() + 2 * 3600_000; - const rich = await create('ses_a', { title: 'SLA follow-up', body: 'x'.repeat(5000), priority: 0, dueAt: due, labels: ['sla'], criteria: 'customer replied to' }); + const rich = await create('ses_a', { title: 'SLA follow-up', suggestedAssignee: 'me', body: 'x'.repeat(5000), priority: 0, dueAt: due, labels: ['sla'], criteria: 'customer replied to' }); const richRow = tm.listMessages(alice, 'mine').find((m) => m.type === 'task.proposed').args.tasks.find((x) => x.id === rich.id); assert(richRow.dueAt === due && richRow.priority === 0 && richRow.labels[0] === 'sla' && richRow.criteria === 'customer replied to', 'deadline, labels and definition of done ride along', richRow); assert(richRow.body.length < 1300 && richRow.body.endsWith('…'), 'a long body is capped for the card', richRow.body.length); @@ -163,7 +163,7 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); assert(viaBoard.status === 200, 'an admin can accept from the board', viaBoard); assert(cards('ses_a')[0].status === 'approved', 'every task decided → the card closes, however they were decided', cards('ses_a')[0].status); const acc = aos.db.prepare("SELECT count(*) n FROM audit_events WHERE type IN ('task.proposal.accepted','task.proposal.dismissed')").get().n; - assert(acc === 2, 'card decisions are audited', acc); + assert(acc === 3, 'every decision is audited — card and board alike', acc); } console.log('\n\x1b[1m6) "dismiss all" on a card, and a deleted proposal closes its card\x1b[0m'); @@ -174,7 +174,7 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); assert(cards('ses_b')[0].status === 'rejected', 'all dismissed → the card reads rejected', cards('ses_b')[0].status); addSession('ses_d', 'sec-d', alice.id, alice.id); SECRETS.ses_d = 'sec-d'; - const d = await create('ses_d', { title: 'to be deleted' }); + const d = await create('ses_d', { title: 'to be deleted', suggestedAssignee: 'me' }); const del = await member(owner, 'DELETE', `/api/tasks/${d.id}`); assert(del.status === 200, 'an admin deletes the proposal', del); assert(cards('ses_d')[0].status === 'rejected', 'and its card closes rather than asking about a task that is gone', cards('ses_d')[0].status); @@ -183,7 +183,7 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); console.log('\n\x1b[1m6b) re-assign from the card, and "Accept & run"\x1b[0m'); { addSession('ses_r', 'sec-r', alice.id, alice.id); SECRETS.ses_r = 'sec-r'; - const p1 = await create('ses_r', { title: 'reassign me' }); + const p1 = await create('ses_r', { title: 'reassign me', suggestedAssignee: bob.id }); const bad = await member(alice, 'POST', '/api/tasks/proposals/decide', { ids: [p1.id], action: 'assign', assignee: 'agent:nope' }); 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 }); @@ -209,6 +209,53 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); assert(rev === 3, 'each re-assignment is audited', rev); } + console.log('\n\x1b[1m9) a proposal SUGGESTS who should work it; assigning on the board accepts it\x1b[0m'); + { + addSession('ses_s', 'sec-s', alice.id, alice.id); SECRETS.ses_s = 'sec-s'; + const none = await create('ses_s', { title: 'no pick' }); + assert(none.ok === false && /suggest/.test(none.error || '') && /engineer/.test(none.error || ''), 'a proposal with no suggestion is refused, with the roster', none); + const ghost = await create('ses_s', { title: 'ghost pick', suggestedAssignee: 'agent:nope' }); + assert(ghost.ok === false && /no agent "nope"/.test(ghost.error || ''), 'an unknown suggested agent is refused', ghost); + + const s1 = await create('ses_s', { title: 'qa should look', suggestedAssignee: 'agent:qa' }); + const t1 = aos.tasks.get(s1.id); + assert(s1.ok && s1.proposed && s1.suggestedAssignee === 'agent:qa', 'filed as a proposal, the pick echoed back', s1); + assert(!t1.assignee && t1.suggestedAssignee === 'agent:qa', 'recorded as a SUGGESTION, not an assignee', t1); + const viaAssignee = await create('ses_s', { title: 'legacy arg', assignee: 'agent:qa' }); + const t2 = aos.tasks.get(viaAssignee.id); + assert(!t2.assignee && t2.suggestedAssignee === 'agent:qa', 'an assignee on a proposal is read as the suggestion too', t2); + const listed = tm.listMessages(alice, 'mine').find((m) => m.type === 'task.proposed' && m.sessionId === 'ses_s'); + assert(listed.args.tasks.find((x) => x.id === s1.id).suggestedAssignee === 'agent:qa', 'the Inbox card carries the suggestion'); + + const rev = await agentPost('/api/tasks/update', 'ses_s', { id: s1.id, assignee: 'agent:engineer' }); + const t1b = aos.tasks.get(s1.id); + assert(rev.ok && !t1b.assignee && t1b.suggestedAssignee === 'agent:engineer' && t1b.status === 'proposed', 'the agent re-pointing it only revises its suggestion', t1b); + + const acc = await member(alice, 'POST', '/api/tasks/proposals/decide', { ids: [viaAssignee.id], action: 'accept' }); + const t2b = aos.tasks.get(viaAssignee.id); + assert(acc.status === 200 && t2b.status === 'todo' && t2b.assignee === 'agent:qa', 'accepting adopts the suggestion as the assignee', t2b); + + const nosy = await member(bob, 'PATCH', `/api/tasks/${s1.id}`, { assignee: bob.id }); + assert(nosy.status === 403 && aos.tasks.get(s1.id).status === 'proposed' && !aos.tasks.get(s1.id).assignee, 'a bystander cannot accept it by assigning', nosy); + const pick = await member(alice, 'PATCH', `/api/tasks/${s1.id}`, { assignee: 'agent:qa' }); + const t1c = aos.tasks.get(s1.id); + assert(pick.status === 200 && t1c.status === 'todo' && t1c.assignee === 'agent:qa', 'assigning on the board moves the proposal to todo', t1c); + const ev = aos.db.prepare("SELECT data FROM audit_events WHERE type = 'task.proposal.accepted' AND data LIKE ?").all(`%${s1.id}%`); + assert(ev.length === 1 && /"via":"assign"/.test(ev[0].data), 'audited as an accept, via assign', ev); + + const s3 = await create('ses_s', { title: 'run it', suggestedAssignee: 'agent:qa' }); + dispatched.length = 0; + const go = await member(owner, 'POST', '/api/tasks/proposals/decide', { ids: [s3.id], action: 'accept', run: true }); + assert(go.status === 200 && dispatched.includes(s3.id) && aos.tasks.get(s3.id).assignee === 'agent:qa', 'Accept & run dispatches to the suggested agent', go.body); + + const s4 = await create('ses_s', { title: 'dismiss me', suggestedAssignee: 'agent:qa' }); + await member(alice, 'PATCH', `/api/tasks/${s4.id}`, { status: 'cancelled' }); + assert(!aos.tasks.get(s4.id).assignee, 'dismissing does not adopt the suggestion', aos.tasks.get(s4.id)); + const s5 = await create('ses_s', { title: 'status accept', suggestedAssignee: 'agent:qa' }); + await member(alice, 'PATCH', `/api/tasks/${s5.id}`, { status: 'todo' }); + assert(aos.tasks.get(s5.id).assignee === 'agent:qa', 'moving it to todo from the status picker adopts it too', aos.tasks.get(s5.id)); + } + console.log('\n\x1b[1m7) nothing moves INTO proposed; goals ignore it; the queue is capped\x1b[0m'); { const td = aos.tasks.create({ tenant: 'testco', title: 'plain', createdBy: owner.id }); @@ -225,7 +272,7 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); addSession('ses_c', 'sec-c', alice.id, alice.id); SECRETS.ses_c = 'sec-c'; let last; - for (let i = 0; i < 30; i++) { last = await create('ses_c', { title: `flood ${i}` }); if (!last.ok) break; } + for (let i = 0; i < 30; i++) { last = await create('ses_c', { title: `flood ${i}`, suggestedAssignee: 'me' }); if (!last.ok) break; } assert(last.ok === false && /waiting for a human/.test(last.error || ''), 'past 25 open proposals the agent is told to stop', last); const open = aos.tasks.openProposals('testco', 'agent:engineer'); assert(open <= 26, 'the queue stays bounded', open); diff --git a/src/memory/memory-mcp.ts b/src/memory/memory-mcp.ts index eadce853..d09e5453 100644 --- a/src/memory/memory-mcp.ts +++ b/src/memory/memory-mcp.ts @@ -1098,7 +1098,10 @@ const TOOLS = [ 'its outcome. Distinct from `remember` (your private note) and `kb_write` (shared reference knowledge): ' + 'a Task is WORK someone must do. Use sub-tasks (`parentId`) to break big work down. Give time-sensitive ' + 'work a `due` date (ISO) — the owner is DMed once if it slips past the deadline. A task you file WITHOUT ' + - 'autoDispatch is a PROPOSAL: it waits in a human\'s Inbox to be accepted onto the board, and nobody works it until then.', + 'autoDispatch is a PROPOSAL: it waits in a human\'s Inbox to be accepted onto the board, and nobody works it until then. ' + + 'A proposal must name who should work it: pass `suggest` (e.g. `suggest:"agent:engineer"`) — the reviewer sees ' + + 'your pick, and accepting assigns it. Use `suggest`, not `assignee`, to propose work for ANOTHER agent: an ' + + 'agent `assignee` is a hand-off and dispatches at once.', inputSchema: { type: 'object', additionalProperties: false, @@ -1106,6 +1109,7 @@ const TOOLS = [ title: { type: 'string', description: 'A short imperative title, e.g. "Fix null-deref in billing.ts".' }, body: { type: 'string', description: 'Markdown detail / acceptance criteria — enough for whoever works it. For a PROPOSAL (no autoDispatch) this is what the human reads to accept or dismiss it: lead with WHY it matters and why now, and link the source record (ticket/conversation/PR). Put any deadline in `due`, not only in the title.' }, assignee: { type: 'string', description: 'Who works it: "agent:" to hand off to another agent, a member id, or "me" for yourself. Omit to leave it open for anyone to claim.' }, + suggest: { type: 'string', description: 'For a PROPOSAL (work a human should approve first): who you think should work it — "agent:" (the agent best placed to do it; see the fleet roster), "me", or a member id when only a person can. Recorded as a suggestion, not an assignment: nothing dispatches, and the reviewer accepts or re-points it. Required when the task is proposed.' }, priority: { type: 'number', minimum: 0, maximum: 3, description: '0 urgent … 3 low (default 2).' }, labels: { type: 'array', items: { type: 'string' }, description: 'Optional freeform labels.' }, parentId: { type: 'string', description: 'Parent task id, to file this as a sub-task.' }, @@ -2785,6 +2789,7 @@ async function taskCreate(args: Record): Promise { session: SESSION, title, body: args.body !== undefined ? String(args.body) : undefined, assignee: args.assignee !== undefined ? String(args.assignee) : undefined, + suggestedAssignee: typeof args.suggest === 'string' && args.suggest.trim() ? args.suggest.trim() : undefined, priority: typeof args.priority === 'number' ? args.priority : undefined, labels: Array.isArray(args.labels) ? args.labels.map(String) : undefined, parentId: args.parentId !== undefined ? String(args.parentId) : undefined, @@ -2803,12 +2808,13 @@ async function taskCreate(args: Record): Promise { dueAt: parseDue(args.due), }), }); - const d = (await res.json()) as { ok?: boolean; id?: string; error?: string; proposed?: boolean }; + const d = (await res.json()) as { ok?: boolean; id?: string; error?: string; proposed?: boolean; suggestedAssignee?: string }; if (!d.ok) return `Could not create the task: ${d.error ?? 'unknown error'}`; // Not on the board yet: an agent-filed task that doesn't dispatch waits for a human to accept it. Say so // plainly, or the agent reads its own proposal as committed work and waits on it (or files it again). if (d.proposed) { - return `Proposed task ${d.id}: "${title}". It is in a human's Inbox for review — NOT on the board and not ` + + const pick = d.suggestedAssignee ? ` with ${d.suggestedAssignee} suggested to work it` : ''; + return `Proposed task ${d.id}: "${title}"${pick}. It is in a human's Inbox for review — NOT on the board and not ` + 'being worked until they accept it. Do not re-file it or wait on it; carry on with your own work.'; } const who = args.assignee ? ` (assigned to ${String(args.assignee)})` : ' (open — anyone can claim it)'; diff --git a/src/server.ts b/src/server.ts index c933d0c6..32ea6c44 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1919,9 +1919,14 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: // Resolve the assignee, then validate an `agent:` target actually exists — a task assigned to a // non-existent agent silently never dispatches (it just rots on the board), so reject it up front // with the valid roster instead of accepting an inert hand-off. - const assignee = b.assignee === 'me' ? `agent:${agent}` : (typeof b.assignee === 'string' && b.assignee ? b.assignee : undefined); - if (assignee && assignee.startsWith('agent:')) { - const targetId = assignee.slice('agent:'.length); + // `suggestedAssignee` is the proposal-lane pick (the `suggest` tool arg): who the agent thinks should work + // a task a human will review — validated exactly like an assignee, since accepting turns it into one. + const resolvePick = (v: unknown): string | undefined => v === 'me' ? `agent:${agent}` : (typeof v === 'string' && v ? v : undefined); + const assignee = resolvePick(b.assignee); + const suggestion = resolvePick(b.suggestedAssignee); + for (const pick of [assignee, suggestion]) { + if (!pick || !pick.startsWith('agent:')) continue; + const targetId = pick.slice('agent:'.length); if (!os.agents.get(targetId)) { const valid = [...os.agents.values()].filter((a) => isCodingRuntime(a.runtime)).map((a) => a.id).join(', '); return sendJson(res, 200, { ok: false, error: `no agent "${targetId}" — assign to one of: ${valid} (call list_agents to see the roster)` }); @@ -1940,12 +1945,13 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: // `Agent(subagent_type: code-reviewer)` and still took 12 task hand-offs in 7 days → 8 sessions, // $111, for reviews the sub-agent path performs for a fraction of it. // Refused on the agent lane only; a human dispatching from the console is a considered act. - if (assignee && assignee.startsWith('agent:')) { - const target = os.agents.get(assignee.slice('agent:'.length)); + for (const pick of [assignee, suggestion]) { + if (!pick || !pick.startsWith('agent:')) continue; + const target = os.agents.get(pick.slice('agent:'.length)); if (target?.subagentOnly) { os.audit.append({ ts: Date.now(), runId: session, tenant: os.tenant, principal: `agent:${agent}`, - type: 'task.subagent_only.refused', data: { title, assignee }, + type: 'task.subagent_only.refused', data: { title, assignee: pick }, }); return sendJson(res, 200, { ok: false, @@ -2008,6 +2014,21 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: 'or mention it in your report instead.', }); } + // A proposal names who should work it. The reviewer decides on "is this work, and is that the right + // hand for it" in one look, and accepting adopts the pick — without one, every accept is followed by a + // second trip to the board to find an assignee. Recorded as a SUGGESTION, not an assignment: nobody is + // told "assigned to you" for work no human has agreed to, and assigning someone on the board is the + // reviewer's act that accepts it. + const proposedPick = suggestion ?? assignee; + if (proposed && !proposedPick) { + const roster = [...os.agents.values()].filter((a) => isCodingRuntime(a.runtime) && !a.subagentOnly).map((a) => a.id).join(', '); + return sendJson(res, 200, { + ok: false, + error: 'this task will be PROPOSED for a human to accept, so suggest who should work it: pass suggest with the ' + + `agent best placed to do it ("agent:" — one of: ${roster}), "me" if it is yours, or a member id ` + + 'if only a person can. It is recorded as a suggestion the reviewer can change.', + }); + } // 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 @@ -2027,7 +2048,8 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: // agent acting as Alice dispatches (later) as Alice too, so accountability ladders to the person. const task = os.tasks.create({ tenant: os.tenant, title, body: b.body !== undefined ? String(b.body) : '', - assignee, + assignee: proposed ? undefined : (assignee ?? suggestion), + suggestedAssignee: proposed ? proposedPick : undefined, owner: tm.sessionRunAs(session), priority: typeof b.priority === 'number' ? b.priority : undefined, labels: Array.isArray(b.labels) ? b.labels.map(String) : undefined, @@ -2046,10 +2068,10 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: createdBy: `agent:${agent}`, status: proposed ? 'proposed' : undefined, }); - os.audit.append({ ts: Date.now(), runId: session, tenant: os.tenant, principal: `agent:${agent}`, type: proposed ? 'task.proposed' : 'task.created', data: { id: task.id, title: task.title, assignee: task.assignee ?? null } }); + os.audit.append({ ts: Date.now(), runId: session, tenant: os.tenant, principal: `agent:${agent}`, type: proposed ? 'task.proposed' : 'task.created', data: { id: task.id, title: task.title, assignee: task.assignee ?? null, ...(task.suggestedAssignee ? { suggested: task.suggestedAssignee } : {}) } }); if (proposed) { tm.recordTaskProposal(session, agent, task); - return sendJson(res, 200, { ok: true, id: task.id, proposed: true }); + return sendJson(res, 200, { ok: true, id: task.id, proposed: true, suggestedAssignee: task.suggestedAssignee }); } // Immediate dispatch (parity with the console route above): an agent-assigned auto-dispatch hand-off // starts NOW rather than waiting for the next ~20s scheduler tick, so a delegated task begins the @@ -2121,7 +2143,8 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const id = String(b.id || ''); // A proposal is a human's to accept. The agent may withdraw its own (→ cancelled) or refine the text, // but moving it onto the board — or parking a real task back in `proposed` — is refused. - const curStatus = typeof b.status === 'string' ? os.tasks.get(id)?.status : undefined; + const cur = os.tasks.get(id); + const curStatus = typeof b.status === 'string' ? cur?.status : undefined; if (b.status === 'proposed' && curStatus !== 'proposed') { return sendJson(res, 200, { ok: false, error: '"proposed" is only for tasks you file — it is not a status you can move a task back into.' }); } @@ -2140,9 +2163,14 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: error: 'Say what this is blocked on: call task_update again with blockedOn:"human" (only a person can decide or approve), "agent" (another agent owes you something) or "external" (a third party, build or vendor). It decides who gets woken — "human" alerts the task owner instead of resuming the agent that handed you this.', }); } + // On a proposal the agent's assignee is still only a suggestion — re-pointing it revises the pick, it + // doesn't assign anyone (that, and the accept it implies, is the reviewer's). + const nextAssignee = b.assignee === null ? null : (b.assignee === 'me' ? `agent:${agent}` : (typeof b.assignee === 'string' && b.assignee ? b.assignee : undefined)); + const suggesting = cur?.status === 'proposed' && nextAssignee !== undefined; const task = requestMetrics.phase('task:update.store', () => os.tasks.update(id, { status: typeof b.status === 'string' ? (b.status as TaskStatus) : undefined, - assignee: b.assignee === null ? null : (b.assignee === 'me' ? `agent:${agent}` : (typeof b.assignee === 'string' ? b.assignee : undefined)), + assignee: suggesting ? undefined : nextAssignee, + suggestedAssignee: suggesting ? nextAssignee : undefined, priority: typeof b.priority === 'number' ? b.priority : undefined, labels: Array.isArray(b.labels) ? b.labels.map(String) : undefined, mode: b.mode === 'headless' || b.mode === 'interactive' ? b.mode : undefined, @@ -4390,7 +4418,8 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: 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 } }); } if (action === 'assign') { decided.push(id); continue; } - const target = os.tasks.get(id)?.assignee ?? ''; + const now = os.tasks.get(id); + const target = now?.assignee ?? now?.suggestedAssignee ?? ''; // accepting adopts the agent's suggestion const agentId = target.startsWith('agent:') ? target.slice('agent:'.length) : ''; if (run && !agentId) { dispatched.push({ id, error: 'assign an agent to run it' }); continue; } if (run && !os.team.canRun(me, agentId)) { dispatched.push({ id, error: `you are not assigned to run "${agentId}"` }); continue; } @@ -4442,15 +4471,21 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const b = await readBody(req); // Moving a task OUT of `proposed` is accepting/dismissing it — the same gate as the Inbox buttons. // And nothing is ever moved INTO it: `proposed` means an agent filed it, which a human edit can't make true. - const cur = typeof b.status === 'string' ? os.tasks.get(taskId[1]) : undefined; - if (cur && b.status === 'proposed' && cur.status !== 'proposed') return sendJson(res, 400, { error: 'a task can only be proposed by the agent that files it' }); - if (cur && cur.status === 'proposed' && b.status !== 'proposed' && !mayDecideProposal(me, cur)) { + // Assigning someone to a proposal (with no status in the same edit) IS accepting it: picking who works + // it is the reviewer saying it is work. So it moves to `todo` under the same gate as the accept button. + const prior = os.tasks.get(taskId[1]); + const acceptByAssign = !!prior && prior.status === 'proposed' && b.status === undefined + && typeof b.assignee === 'string' && !!b.assignee; + const status = acceptByAssign ? 'todo' : typeof b.status === 'string' ? (b.status as TaskStatus) : undefined; + const cur = status !== undefined ? prior : undefined; + if (cur && status === 'proposed' && cur.status !== 'proposed') return sendJson(res, 400, { error: 'a task can only be proposed by the agent that files it' }); + if (cur && cur.status === 'proposed' && status !== 'proposed' && !mayDecideProposal(me, cur)) { return sendJson(res, 403, { error: 'only the person this run acted for, or an owner/admin, can accept or dismiss this proposal' }); } const task = os.tasks.update(taskId[1], { title: typeof b.title === 'string' ? b.title : undefined, body: typeof b.body === 'string' ? b.body : undefined, - status: typeof b.status === 'string' ? (b.status as TaskStatus) : undefined, + status, assignee: b.assignee === null ? null : (typeof b.assignee === 'string' ? b.assignee : undefined), priority: typeof b.priority === 'number' ? b.priority : undefined, labels: Array.isArray(b.labels) ? b.labels.map(String) : undefined, @@ -4463,6 +4498,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: by: me.id, }); if (!task) return sendJson(res, 404, { error: 'task not found' }); + if (cur?.status === 'proposed' && task.status !== 'proposed') { + os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: task.status === 'cancelled' ? 'task.proposal.dismissed' : 'task.proposal.accepted', data: { id: task.id, title: task.title, by: cur.createdBy, ...(acceptByAssign ? { via: 'assign', assignee: task.assignee } : {}) } }); + } os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: task.status === 'done' ? 'task.completed' : 'task.updated', data: { id: task.id, status: task.status } }); return sendJson(res, 200, { ok: true, task }); } diff --git a/src/state/db.ts b/src/state/db.ts index e642dd5b..08b71b6b 100644 --- a/src/state/db.ts +++ b/src/state/db.ts @@ -1435,6 +1435,12 @@ function migrate(db: Db): void { // on the next sweep after EVERY resume, so a long-lived conversation a human kept coming back to could // never be used for more than ~30 minutes at a time. addColumn(db, 'term_sessions', 'opened_at', 'INTEGER'); + + // SUGGESTED_ASSIGNEE: who an agent thinks should work the task it PROPOSED ('agent:' | member id). + // Kept apart from `assignee` on purpose: a proposal is not work yet, so the agent's pick is advice the + // reviewer weighs, and assigning someone on the board is the human's act that accepts it (→ todo). + // Adopted as the assignee when a proposal is accepted without one. NULL on every human-filed task. + addColumn(db, 'tasks', 'suggested_assignee', 'TEXT'); } /** Add a column only if it isn't already present (SQLite has no ADD COLUMN IF NOT EXISTS). */ diff --git a/src/state/tasks.ts b/src/state/tasks.ts index 4f2eca67..a4583931 100644 --- a/src/state/tasks.ts +++ b/src/state/tasks.ts @@ -24,7 +24,7 @@ import { TASK_BLOCKED_ON, Task, TaskAttachment, TaskCreateInput, TaskEvent, Task interface TaskRow { id: string; tenant: string; title: string; body: string; status: string; priority: number; - labels: string; assignee: string | null; owner: string | null; parent_id: string | null; + labels: string; assignee: string | null; suggested_assignee?: string | null; owner: string | null; parent_id: string | null; mode: string; model: string | null; effort: string | null; auto_dispatch: number; goal_id: string | null; criteria: string | null; caller_agent: string | null; caller_claude_id: string | null; poke_on_done: number; blocked_on: string | null; @@ -107,14 +107,15 @@ export class TaskStore { .prepare(`INSERT INTO tasks (id, tenant, title, body, status, priority, labels, assignee, owner, parent_id, mode, model, effort, auto_dispatch, goal_id, criteria, caller_agent, caller_claude_id, poke_on_done, due_at, attempts, last_session_id, - created_by, created_at, updated_at, updated_by, external_key) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?)`) + created_by, created_at, updated_at, updated_by, external_key, suggested_assignee) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?, ?)`) .run( id, input.tenant, input.title.trim() || 'Untitled task', input.body ?? '', status, priority, JSON.stringify(labels), input.assignee ?? null, input.owner ?? null, input.parentId ?? null, mode, model, effort, input.autoDispatch ? 1 : 0, goalId, oneLine(input.criteria), input.callerAgent ?? null, input.callerClaudeId ?? null, input.pokeOnDone ? 1 : 0, input.dueAt ?? null, input.createdBy, now, now, input.createdBy, input.externalKey ?? null, + input.suggestedAssignee ?? null, ); if (goalId && this.db.prepare('SELECT 1 FROM goals WHERE id = ?').get(goalId)) { this.addEvent(id, 'link', `goal:${goalId}`, input.createdBy); @@ -250,11 +251,20 @@ export class TaskStore { const v = input.blockedOn && (TASK_BLOCKED_ON as readonly string[]).includes(input.blockedOn) ? input.blockedOn : null; if (v !== (t.blockedOn ?? null)) { sets.push('blocked_on = ?'); vals.push(v); } } + // Accepting a proposal that has no assignee yet takes the proposing agent's suggestion — "accept" means + // accept it as proposed. An explicit assignee in the same edit (the reviewer's own pick) wins. + let assignee = input.assignee; + if (assignee === undefined && t.status === 'proposed' && nextStatus !== 'proposed' && nextStatus !== 'cancelled' + && !t.assignee && t.suggestedAssignee) assignee = t.suggestedAssignee; let reassigned = false; - if (input.assignee !== undefined && (input.assignee ?? null) !== (t.assignee ?? null)) { - sets.push('assignee = ?'); vals.push(input.assignee ?? null); + if (assignee !== undefined && (assignee ?? null) !== (t.assignee ?? null)) { + sets.push('assignee = ?'); vals.push(assignee ?? null); reassigned = true; - this.addEvent(id, 'assign', input.assignee ? `→${input.assignee}` : '→unassigned', input.by); + this.addEvent(id, 'assign', assignee ? `→${assignee}` : '→unassigned', input.by); + } + if (input.suggestedAssignee !== undefined && (input.suggestedAssignee ?? null) !== (t.suggestedAssignee ?? null)) { + sets.push('suggested_assignee = ?'); vals.push(input.suggestedAssignee ?? null); + this.addEvent(id, 'assign', input.suggestedAssignee ? `suggested ${input.suggestedAssignee}` : 'suggestion withdrawn', input.by); } if (input.priority !== undefined) { sets.push('priority = ?'); vals.push(clampPriority(input.priority)); } if (input.mode !== undefined) { sets.push('mode = ?'); vals.push(input.mode === 'interactive' ? 'interactive' : 'headless'); } @@ -275,7 +285,9 @@ export class TaskStore { // Fire the notices AFTER the write so the snapshot reflects the change. Reassignment first (the new // 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 }); + // …except when the same edit takes it OUT of `proposed`: the accept's status notice is what tells the + // assignee (the "assigned to you" a proposal holds back), so a second one here would double it. + if (reassigned && !statusChange?.startsWith('proposed→')) this.notify({ task, kind: 'assigned', by: input.by }); if (statusChange) this.notify({ task, kind: 'status', by: input.by, detail: statusChange, ...(input.note?.trim() ? { note: input.note.trim() } : {}) }); return task; } @@ -686,6 +698,7 @@ function toTask(r: TaskRow): Task { id: r.id, tenant: r.tenant, title: r.title, body: r.body, status: r.status as TaskStatus, blockedOn: (TASK_BLOCKED_ON as readonly string[]).includes(r.blocked_on ?? '') ? (r.blocked_on as Task['blockedOn']) : undefined, priority: r.priority, labels: JSON.parse(r.labels) as string[], assignee: r.assignee ?? undefined, + suggestedAssignee: r.suggested_assignee ?? undefined, owner: r.owner ?? undefined, parentId: r.parent_id ?? undefined, mode: r.mode === 'interactive' ? 'interactive' : 'headless', model: r.model ?? undefined, effort: (r.effort ?? undefined) as Task['effort'], diff --git a/src/terminal.ts b/src/terminal.ts index fcdacebe..9ed941cb 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -6614,8 +6614,8 @@ export class TerminalManager { * growth is noticed. Addressed to the run's accountable human (run-as), else the admin tier — the same * person a dispatched run of the task would act as. Quiet: no DM, because nothing is blocked on it. */ - recordTaskProposal(sessionId: string, agent: string, task: { id: string; title: string; assignee?: string }): string { - const entry: ProposedTaskRef = { id: task.id, title: task.title, ...(task.assignee ? { assignee: task.assignee } : {}) }; + recordTaskProposal(sessionId: string, agent: string, task: { id: string; title: string; assignee?: string; suggestedAssignee?: string }): string { + const entry: ProposedTaskRef = { id: task.id, title: task.title, ...(task.assignee ? { assignee: task.assignee } : {}), ...(task.suggestedAssignee ? { suggestedAssignee: task.suggestedAssignee } : {}) }; const open = this.db .prepare(`SELECT id, args FROM messages WHERE type = 'task.proposed' AND status = 'open' AND session_id = ? ORDER BY created_at DESC LIMIT 1`) .get<{ id: string; args: string | null }>(sessionId); @@ -6668,7 +6668,7 @@ export class TerminalManager { if (!live) return { ...t, status: 'deleted' as const }; const body = live.body.trim(); return { - ...t, title: live.title, ...(live.assignee ? { assignee: live.assignee } : {}), status: live.status, + ...t, title: live.title, assignee: live.assignee, suggestedAssignee: live.suggestedAssignee, status: live.status, priority: live.priority, createdAt: live.createdAt, ...(body ? { body: body.length > PROPOSAL_BODY_MAX ? body.slice(0, PROPOSAL_BODY_MAX).trimEnd() + '…' : body } : {}), ...(live.dueAt ? { dueAt: live.dueAt } : {}), @@ -10267,7 +10267,7 @@ function toSession(r: SessionRow): Session { } /** One task on a `task.proposed` card. Title/assignee are snapshots from filing; status is hydrated live. */ -interface ProposedTaskRef { id: string; title: string; assignee?: string } +interface ProposedTaskRef { id: string; title: string; assignee?: string; suggestedAssignee?: string } function proposedTaskRefs(args: string | null): ProposedTaskRef[] { try { @@ -10281,7 +10281,8 @@ const PROPOSAL_BODY_MAX = 1200; const taskProposalTitle = (n: number): string => (n === 1 ? 'Proposed a task' : `Proposed ${n} tasks`); const taskProposalBody = (tasks: ProposedTaskRef[]): string => - tasks.map((t) => `• ${t.title}${t.assignee ? ` → ${t.assignee.replace(/^agent:/, '')}` : ''}`).join('\n'); + tasks.map((t) => `• ${t.title}${t.assignee ? ` → ${t.assignee.replace(/^agent:/, '')}` + : t.suggestedAssignee ? ` → ${t.suggestedAssignee.replace(/^agent:/, '')} (suggested)` : ''}`).join('\n'); function toMessage(r: MessageRow): FeedMessage { // Approval/question rows reflect their live status from the joined table; others keep their own. diff --git a/src/types.ts b/src/types.ts index 6be52aa9..6a1fb4ca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -877,6 +877,9 @@ export interface Task { priority: number; // 0 urgent … 3 low labels: string[]; assignee?: string; // member id | 'agent:' + /** A proposing agent's pick for who should work it (member id | 'agent:') — advice, not an + * assignment. Becomes the assignee when the proposal is accepted without one. */ + suggestedAssignee?: string; owner?: string; // member id → run_as of the dispatched session; undefined → company identity parentId?: string; mode: 'headless' | 'interactive'; // how a dispatched session runs (default headless: work-to-completion) @@ -1037,6 +1040,7 @@ export interface TaskCreateInput { title: string; body?: string; assignee?: string; + suggestedAssignee?: string; // a proposing agent's pick — see Task.suggestedAssignee owner?: string; priority?: number; labels?: string[]; @@ -1079,6 +1083,7 @@ export interface TaskUpdateInput { body?: string; status?: TaskStatus; assignee?: string | null; // null clears the assignee + suggestedAssignee?: string | null; // a proposing agent revising its pick; null clears it priority?: number; labels?: string[]; mode?: 'headless' | 'interactive'; diff --git a/web/src/App.tsx b/web/src/App.tsx index f72e4980..52d9775a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6905,7 +6905,7 @@ function ApprovalBrief({ m }: { m: Msg }) { /** One task on a `task.proposed` Inbox card — its live state is stamped server-side at read time. */ type ProposedTaskRow = { - id: string; title: string; assignee?: string; status: TaskStatus | 'deleted' + id: string; title: string; assignee?: string; suggestedAssignee?: string; status: TaskStatus | 'deleted' body?: string; priority?: number; dueAt?: number; labels?: string[]; criteria?: string; createdAt?: number } @@ -6925,7 +6925,10 @@ function ProposedTaskItem({ t, members, agents, busy, onDecide }: { t: ProposedT const long = (t.body?.length ?? 0) > 180 || (t.body?.split('\n').length ?? 0) > 2 || !!t.criteria // Only a claude-code agent can be dispatched from a task, so those are the agents offered — same list as the board. const runnable = agents.filter((a) => a.runtime === 'claude-code') - const toAgent = (t.assignee ?? '').startsWith('agent:') + // The agent's suggestion stands in for the assignee until someone picks one — accepting adopts it. + const pick = t.assignee ?? t.suggestedAssignee + const suggested = !t.assignee && !!t.suggestedAssignee + const toAgent = (pick ?? '').startsWith('agent:') return (
  • @@ -6935,9 +6938,9 @@ function ProposedTaskItem({ t, members, agents, busy, onDecide }: { t: ProposedT {t.priority !== undefined && {PRIORITY_LABEL[t.priority]}} {due && {due.label}} {pending ? ( - { const next = !v || v === 'none' ? null : v; if (next !== (t.assignee ?? null)) onDecide('assign', [t.id], { assignee: next }) }}> + + {(v) => '→ ' + (!v || v === 'none' ? 'Unassigned' : principalLabel(v as string, members)) + (suggested && v === pick ? ' (suggested)' : '')} Unassigned @@ -6953,7 +6956,7 @@ function ProposedTaskItem({ t, members, agents, busy, onDecide }: { t: ProposedT {pending ? (
    - {toAgent && } + {toAgent && }
    ) : ( @@ -11785,7 +11788,7 @@ function TasksPage({ me, agents, taskId, onOpen, nav, backTo, sessionActions }: if (r.error) alert(r.error) await load(); setBusy(false) } - const patch = async (id: string, b: Parameters[1]) => { setBusy(true); await api.patchTask(id, b); await load(); if (b.goalId !== undefined || b.status) await loadGoals(); setBusy(false) } + const patch = async (id: string, b: Parameters[1]) => { setBusy(true); const r = await api.patchTask(id, b); if (r.error) alert(r.error); await load(); if (b.goalId !== undefined || b.status || b.assignee) await loadGoals(); setBusy(false) } // Dispatch a task. A HEADLESS run is background work — it drives itself to completion and exits — so // opening the terminal on it yanks you out of the board you were working to watch a pane you can't // usefully drive; the card's own live tape (and the run history) is how you follow it. An INTERACTIVE @@ -12019,14 +12022,25 @@ function TasksPage({ me, agents, taskId, onOpen, nav, backTo, sessionActions }:
    + {/* On a proposal, assigning someone accepts it onto the board (→ todo) — the server does the move. */} + {detail.task.status === 'proposed' && ( +
    + {!detail.task.assignee && detail.task.suggestedAssignee ? ( + <> + Suggested: {nameOf(detail.task.suggestedAssignee)} + + + ) : Assigning someone accepts this proposal onto the board.} +
    + )}
    patch(detail.task.id, { dueAt: fromDateInput(e.target.value) })} className="h-8" /> @@ -12536,7 +12550,7 @@ function TasksPage({ me, agents, taskId, onOpen, nav, backTo, sessionActions }: {proposals.map((t) => (
  • openTask(t.id))} className="min-w-0 flex-1 truncate text-foreground no-underline hover:underline">{t.title} - {assigneeChip(t.createdBy)}{t.assignee && <>→ {assigneeChip(t.assignee)}} + {assigneeChip(t.createdBy)}{t.assignee ? <>→ {assigneeChip(t.assignee)} : t.suggestedAssignee && → {assigneeChip(t.suggestedAssignee)}suggested} {isAdmin || t.owner === me.id ? ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 59491bbd..23251f1e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -759,6 +759,8 @@ export interface Task { priority: number labels: string[] assignee?: string + /** A proposing agent's pick for who should work it — advice until a human accepts the proposal. */ + suggestedAssignee?: string owner?: string parentId?: string goalId?: string