Skip to content

feat(task): add worker-session link + queue statuses (schema + mutators, inert) - #1935

Open
wqymi wants to merge 1 commit into
mainfrom
feat/task-queue-schema
Open

feat(task): add worker-session link + queue statuses (schema + mutators, inert)#1935
wqymi wants to merge 1 commit into
mainfrom
feat/task-queue-schema

Conversation

@wqymi

@wqymi wqymi commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

What

PR-1 of the Symphony-inspired Orchestrator task-queue design. Schema + mutators only — behavior-inert: nothing in the codebase calls the new mutators yet, and no lifecycle hook or task-tool verb is wired. Merging this changes zero runtime behavior.

The core gap this closes

Task tracking already had almost everything a queue needs (TaskTable, append-only TaskEventTable, TaskRegistry as the single mutator, task.updated bus events). What it did not have is a durable link from a task to the worker session executing it: owner is a free-text actorID, and ActorRegistryTable has no task_id. So "which peer child is working T3, and what came out of it" was only ever inferable from the model's context, never authoritative in the DB.

This PR adds that link plus the queue statuses the orchestrator needs to route work.

Schema deltas

packages/opencode/migration/20260715000000_task_worker_link/migration.sql (timestamped after the newest existing migration, auto-discovered by migrations() in src/storage/db.ts):

ALTER TABLE `task` ADD COLUMN `worker_session_id` text;
ALTER TABLE `task` ADD COLUMN `dispatched_at` integer;
ALTER TABLE `task` ADD COLUMN `result_ref` text;
CREATE INDEX IF NOT EXISTS `task_worker_idx` ON `task` (`worker_session_id`,`status`);

All three columns are nullable, exactly mirroring the 20260603000000_task_in_progress_owner precedent, so existing rows backfill to NULL.

worker_session_id is deliberately NOT a foreign key to SessionTable. session_id (the owning orchestrator session) is an FK with onDelete: cascade because a task cannot outlive its owner. A worker session is different: it is a disposable peer child that gets torn down when its job ends, but the task row must survive as durable history (including result_ref pointing at the branch it produced). An FK with cascade would delete the task; an FK with restrict would block worker cleanup. Same soft-link treatment owner already gets.

Statuses (the queues)

TaskStatus (src/task/schema.ts) and the drizzle $type<> (src/task/task.sql.ts) and the tool's separate statusSchema (src/tool/task.ts) all widen consistently. A task's status is the queue it sits in:

status queue meaning
open queued, unassigned
dispatched (new) assigned to a worker session, not yet reported running
in_progress worker is running it
blocked stalled on a dependency
human_review (new) first-class queue waiting on the USER, not on any worker
done terminal success
failed (new) terminal — a worker reported failure, distinct from operator-dropped abandoned
abandoned terminal — the operator dropped the work

TaskEventKind gains the matching kinds: dispatched, review_requested, reworked, failed. UpdatedKind in src/task/events.ts is derived via TaskEventKind.exclude(["created"]), so task.updated auto-widens with no edit there.

Terminal/non-terminal membership is now expressed once as TERMINAL_TASK_STATUSES / NON_TERMINAL_TASK_STATUSES + isTerminalTaskStatus() in src/task/schema.ts, and both the start() guard and list()'s default filter read from it instead of hardcoding a status list.

New registry mutators

All four follow the exact existing shape — validate, single UPDATE, insertEvent, publishUpdated, re-get with the shared agent-recoverable not-found message:

  • dispatch({ session_id, id, worker_session_id, event_summary? }) — sets worker_session_id + dispatched_at, status → dispatched, logs dispatched. Refuses (warn + no-op) on a terminal task, same as start().
  • requestReview({ session_id, id, event_summary?, result_ref? }) — status → human_review, logs review_requested. Does not stamp ended_at: the task is still live, just waiting on a human. Refuses on terminal.
  • rework({ session_id, id, event_summary? }) — the user rejected the result: human_reviewdispatched if the task still has a worker, else → open so the orchestrator can dispatch a fresh one. Logs reworked. No-op + warn if the task is not actually in human_review.
  • fail({ session_id, id, event_summary?, result_ref? }) — status → failed, stamps ended_at + cleanup_after like done/abandon. Logs failed.

start()'s terminal guard now covers failed too, so a failed task cannot be silently resurrected into a self-contradictory row (in_progress while carrying ended_at/cleanup_after).

Compatibility

There is no exhaustive switch on TaskStatus, and status is plain TEXT in SQLite, so old rows and old values keep working. The two render sites were checked:

  • session/checkpoint.ts statusIcon has a ?? s fallback — icons added for the three new statuses anyway.
  • server/routes/instance/session.ts taskToTodo had a pending else-branch, which would have rendered a failed (dead) task as still actionable. Rewritten as an explicit exhaustive Record<Task["status"], string>: failedcancelled (joining abandoned), while dispatched and human_review correctly stay pending (genuinely queued — on a worker / on the user).

Tests

bun test — 154 pass / 0 fail across the task, storage, tool/task, actor spawn-autostart and session-task-route suites. bun typecheck exits 0.

  • test/task/registry.test.ts: new columns default to undefined and round-trip; result_ref round-trips through fail; dispatch sets worker_session_id/dispatched_at/status and emits dispatched; dispatch refuses a terminal task and is agent-recoverable on an unknown id; requestReview enters human_review without stamping ended_at; rework returns to dispatched with a worker and open without one, and no-ops outside human_review; fail stamps ended_at/cleanup_after and start refuses to resurrect it; list treats dispatched/human_review as non-terminal and failed as terminal, and filters by the new statuses; all four mutators publish the matching task.updated kind on the bus.
  • test/storage/task-worker-migration.test.ts: real on-disk SQLite with foreign_keys = ON — applies every migration except the new one, seeds a legacy task row through raw SQL with the old column set, then applies the new migration and asserts the three columns exist and are NULL on the old row, task_worker_idx exists, and the new columns are writable.

Next

PR-2 wires the actual behavior: session-create / worker-terminal lifecycle hooks driving dispatch/fail/requestReview, and the task tool verbs (dispatch, review, rework, fail) so the orchestrator can operate the queues.

…rs, inert)

PR-1 of the Symphony-style task-queue design. Closes the core gap: a task
had no durable link to the worker session executing it (`owner` was free
text), so task state could not survive as authoritative queue state in the
DB.

Schema (all nullable, backfill-safe, mirroring the `owner` precedent):
- task.worker_session_id / dispatched_at / result_ref
- index task_worker_idx on (worker_session_id, status)
- TaskStatus gains dispatched | human_review | failed
- TaskEventKind gains dispatched | review_requested | reworked | failed

Registry gains dispatch / requestReview / rework / fail, following the
existing mutator shape (validate -> update -> insertEvent -> publishUpdated).
`failed` is terminal alongside done/abandoned; `dispatched`/`human_review`
are non-terminal for list()'s default filter.

Behavior-inert: nothing calls the new mutators yet. PR-2 wires the
lifecycle transitions and the task-tool verbs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant