Attach event stream — design
Status: accepted (2026-07-17)
Problem
The IPC drive+observe API has two halves. The drive half works: clients
enqueue, approve, pause, kill, import. The observe half is a stub. The
supervisor's HandleAttach (internal/supervisor/supervisor.go) blocks on
<-ctx.Done() and emits nothing — an Attach client connects and receives
silence until it disconnects. The IPC transport for it is fully built and
hardened (server streams emit frames until ctx cancel; #160 added write
deadlines and a request cap; #165 added the read-side disconnect watcher that
cancels the handler ctx on client EOF), but there is no producer.
Meanwhile the supervisor already writes a rich, append-only audit log. Every
load-bearing state transition emits an events row via store.Emit or an
inline INSERT INTO events inside the same transaction as the transition:
task.claimed, task.done, task.failed, task.failed_terminal,
task.released, task.blocked, task.context_requested, task.progress,
worker.verified_done, worker.verification_failed, worker.result,
worker.spend, worker.admission_refused, worker.dispatch_error,
worker.dispatch_panic, plan.imported, project.created, service.started,
tick, and more. The events table has a monotonic id INTEGER PRIMARY KEY AUTOINCREMENT.
But store.ListProjectEvents — the only read path — has no callers. The
data is written and never surfaced. TUI and GUI render by polling the store
(Status + ListPlans + ListTasks) on a timer; they never see events, and
between polls the live view is stale.
Goal
Turn the observe half on: stream the already-persisted event rows to Attach
clients as they are written, so a read-only TUI/GUI live view goes from
poll-only to push-live. Ship the smallest correct thing that makes
HandleAttach a real producer, reusing the existing events table as the single
source of truth — no new event type, no new write path, no dual-write.
Non-goals (YAGNI): no server-side filtering DSL, no per-client subscription config, no event replay UI, no retention/compaction changes, no new event kinds. Those can follow if a need surfaces.
Approach — tail the events table
The events table is append-only with a monotonic id. "Stream new events" is a
tail: repeatedly select rows with id > lastSeen, emit each, advance
lastSeen. Chosen over the two alternatives:
- In-process pub/sub bus (emitters also push to subscriber channels): needs a second delivery path parallel to the DB write, with its own ordering, backpressure, and drop semantics, and every current + future emit site must remember to publish. The table already is the ordered, durable log; a tail reuses it with zero emitter changes. Rejected as premature.
- SQLite
sqlite3_update_hook: a C-level row-change callback would avoid polling, but it is per-connection (our pool has up to 4 conns — #129), fires on the writing connection only, and couples us to driver internals. Rejected as fragile for the benefit.
The tail polls the DB on a short interval. This keeps a single ordered delivery path (the DB), survives supervisor restarts (a reconnecting client resumes from its last id), and needs no change to any of the ~20 emit sites.
Cursor semantics — client-owned cursor, no server-side "seed to max"
The cursor is entirely the client's. HandleAttach streams every event
with id > AfterID, full stop — the server never substitutes a "current max"
for a zero cursor. AfterID == 0 therefore means "from the beginning," not
"from now." This closes a lost-event race that a server-side seed-to-max would
open:
A naive fresh-attach — client reads the backlog via a one-shot request, then attaches with
AfterID=0, and the server seeds the cursor toMAX(id)at attach time — can permanently drop an event inserted between the backlog read and the attach: it is past the backlog snapshot yet at or below the seeded max, so it appears in neither.
The client instead owns a single monotonic cursor across both reads:
- Backlog hydrate (optional, for a populated initial view): the client
issues a normal request/reply that returns the recent backlog and the
max event id at read time (
ListProjectEventsalready reads newest-first; the highest id in that page, or a dedicatedMaxEventID, is the cursor). If the client wants no backlog, it usesMaxEventIDalone. - Attach with
AfterIDset to exactly that cursor. Any event inserted after the backlog snapshot hasid > cursor, so the live stream delivers it — no gap. Any event already in the backlog hasid <= cursor, so it is not re-sent — no duplicate. - Reconnect re-uses the same rule: the client passes the highest id it has
processed, and resumes with no gap and no duplicate. This is what makes
#165's reconnect loop (GUI
runAttach) correct rather than lossy.
MaxEventID and ListProjectEvents share the same connection/serialization as
the subsequent attach, and the client reads-then-attaches, so the cursor it
carries into the attach is a real observed id; there is no window where the
server invents one.
The tail loop each tick: SELECT ... WHERE id > ? ORDER BY id ASC LIMIT N,
emit each row as one frame, set cursor to the last id returned. LIMIT N
(e.g. 256) bounds a single tick's work if a burst landed; the next tick drains
the rest immediately.
Scope — project, including plan-linked events
An Attach connection is not implicitly project-scoped: the IPC connection has
no init handshake, and the supervisor is deliberately project-agnostic (it
serves all of a machine's projects on one socket). So the client passes the
project id in AttachArgs (as the drive commands already pass a project id in
their args), and HandleAttach scopes the tail to it.
Scoping cannot be a bare WHERE project_id = ?, because the headline
lifecycle events do not populate project_id. The transactional inserts in
internal/store/tasks.go (task.claimed, task.done/worker.completed,
task.failed, task.failed_terminal, task.released, task.blocked,
task.progress, …) set only plan_id/task_id. A project_id-only filter
would silently drop exactly the events a live view exists to show. The tail
therefore scopes by project through plan linkage too:
WHERE id > ?
AND ( project_id = ?
OR plan_id IN (SELECT id FROM plans WHERE project_id = ?) )plans.project_id is NOT NULL REFERENCES projects(id), so a plan-scoped event
resolves to exactly one project. Events with neither a project_id nor a
plan_id (a few service-internal kinds like tick) are not delivered to any
project client — they are noise, not observability.
Event frame schema
Each streamed frame is the JSON encoding of a stable, public event shape —
NOT the raw store.Event (which exposes DB column quirks). A new
ipc.AttachEvent:
// AttachArgs is the client's CmdAttach payload. ProjectID scopes the stream
// (the connection carries no implicit project). AfterID is the client-owned
// resume cursor: the stream carries every event with id > AfterID (0 means
// from the beginning — the client, not the server, chooses the live-tail
// cursor by first reading MaxEventID).
type AttachArgs struct {
ProjectID string `json:"project_id"`
AfterID int64 `json:"after_id,omitempty"`
}
// AttachEvent is one event streamed over an Attach connection. It is the
// public, versioned shape of an events-table row; payload is the kind's
// already-JSON payload passed through verbatim.
type AttachEvent struct {
ID int64 `json:"id"`
Kind string `json:"kind"` // e.g. "task.done"
Stream string `json:"stream,omitempty"` // "service"|"worker"|...
PlanID string `json:"plan_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
Actor string `json:"actor,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"` // kind-specific, pass-through
OccurredAt time.Time `json:"occurred_at"`
}ID lets a client persist its resume cursor. Payload is the row's
payload_json passed through as json.RawMessage — consumers that care about a
kind decode it; the stream itself stays kind-agnostic, so adding a new event
kind never requires a transport change. The frame carries AttachEvent
directly (the IPC layer already frames each emit call as one length-prefixed
JSON message).
Layering
- store (
internal/store/events.go): addEventsAfter(ctx, projectID string, afterID int64, limit int) ([]Event, error)— the tail query, scoped to a project including plan-linked events:WHERE id > ? AND (project_id = ? OR plan_id IN (SELECT id FROM plans WHERE project_id = ?)) ORDER BY id ASC LIMIT ?— andMaxEventID(ctx, projectID string) (int64, error)with the same scoping, so the client's initial cursor and the tail agree on which rows belong to the project. Leaf, no new deps. ReusesscanEvent. - supervisor (
internal/supervisor): implementHandleAttach(ctx, args, emit)as the tail loop — cursor starts atargs.AfterID(no server-side seed-to-max), then on a ticker callEventsAfter(ctx, args.ProjectID, cursor, N), map eachstore.Event→ipc.AttachEvent,emitit, advance the cursor to the last emitted id, untilctx.Done(). Reject an emptyargs.ProjectID. Interval is a small const (e.g. 250ms);emitreturning an error (client gone / write deadline) ends the loop, matching the existing contract. - ipc (
internal/ipc): addAttachArgs{ProjectID, AfterID}andAttachEvent; change theHandler.HandleAttachinterface to(ctx, args AttachArgs, emit)and parseAttachArgsfrom the request in the server's CmdAttach path (a malformed args blob is aninvalid_argsresponse).Client.Attachalready streamsjson.RawMessageframes; add a typedClient.AttachEvents(ctx, args AttachArgs, func(AttachEvent) error)convenience that sends the args and unmarshals each frame, leaving the rawAttachintact. ExposeMaxEventID/backlog over IPC (a small request/reply) so a client can obtain its initial cursor. - consumers (later tasks, incremental): TUI/GUI live view subscribes via
AttachEventsand applies deltas, falling back to the existing poll as a safety net. Kept out of the first PR to keep it reviewable; the first PR makes the producer real and testable end-to-end via a fake client.
Error handling
- A DB error inside the tail is logged and the tick is skipped (transient); the loop continues so a momentary lock (pool contention) doesn't kill a live stream. The cursor is only advanced past rows actually emitted.
emiterror (slow/vanished client) endsHandleAttachcleanly — this is the existing contract; #165's watcher and the per-frame write deadline already guarantee the loop can't wedge.- A reconnecting client with a stale
AfterIDolder than any surviving row simply gets everything fromafterIDforward; events are append-only and not pruned by this feature, so there is no "cursor too old" case to handle now.
Testing
- store:
EventsAfterreturns only rows withid > afterID, ascending, capped at limit, scoped to project — including a plan-scoped event whose row has noproject_id, only aplan_idbelonging to the project (the P1 that a bareproject_idfilter would drop), and excluding another project's events and unscoped service rows;MaxEventIDuses the same scoping and returns 0 on an empty project. - supervisor: with a fake
emit, attaching to a project with pre-existing events +AfterID=0emits nothing until a new event lands, then emits it; withAfterID=kemits exactly the rows after k in order; the loop exits when ctx is cancelled and whenemitreturns an error. - ipc:
AttachEventsround-trips anAttachEventthrough the frame codec; malformed frame surfaces a decode error to the callback path. -raceon the supervisor tail (ticker goroutine vs. ctx cancel).
Why this is the right first feature after the audit sweep
It is the highest-leverage product gap: the observe half of the headline drive+observe API is inert, the transport for it is already built and hardened, and the data already exists and is thrown away. This wires an existing producer to an existing consumer channel with one small store query and one supervisor loop — high value, contained blast radius, no schema change.
