Skip to content

The lifecycle layer ADR 007 assumed: one claim primitive, a timeout taxonomy, a progress heartbeat #17

Description

@HCHogan

ADR 007 built an orchestration layer on top of a lifecycle layer that does not exist. The plan engine assumes three things about the work it drives — a turn runs to completion, it is claimed exactly once, and a hang is noticed — and max implements none of them once, centrally. Each subsystem guesses again. That is why the leaks are not in the plan kernel (parser, validator, executor, reconciler are fine) but underneath it and around it.

This issue records the inventory and the prior art. It is deliberately not an ADR: the finding is that the next design decision belongs one layer below 007, and that claim should survive being wrong about the fix.

Note on method: grepping TODO|FIXME|XXX|HACK across src/ returns 2 hits, both XXX. This codebase does not mark debt; it writes debt as a well-argued decision in prose. Everything below was found by looking for structural repetition instead.

A. One primitive, hand-written seven times: claim/lease

Table Columns Where
episode_capture_runs lease_owner / lease_expires_at migrations/000_baseline.sql:726
message_deliveries lease_owner / lease_expires_at migrations/000_baseline.sql:1120
message_dispatches lease_owner / lease_expires_at migrations/000_baseline.sql:1160
maintenance_leases whole table migrations/000_baseline.sql:944
agent_turns recovery_owner / recovery_claimed_at migrations/071_durable_agent_turns.sql:21
monitors claim_owner / claim_expires_at migrations/073_monitors_scheduler.sql:57
plans wake_owner / wake_claim_expires_at migrations/077_plan_suspension.sql:35

The wait side was already factored out: Max.DB.Notify has a WorkChannel enum and five channels (max_dispatch_work, max_delivery_work, max_monitor_work, max_plan_work, max_timeline_work) sharing one claimOrWait. The claim side never was. The abstraction is half-built, and the half that was left out is the half with the invariants.

The cost is not aesthetic. ADR 007 shipped the seventh copy and got it wrong the same way: a two-column desired/running diff re-dispatches finished children forever, which is what reconciled_revision in migrations/078_plan_concurrency.sql exists to fix. Six prior implementations had no shape in which that lesson could be carried.

B. Timeouts exist at exactly one layer, and it is the innermost one

Layer Ceiling
A single LLM call yes — 120s/300s, per profile
The agent loop rounds only: maxTurns = 200 (src/Max/Effects/Agent.hs:173)
A front-model turn nonesrc/Max/Handler.hs:2148 calls agentTurn bare
A fork child yes — race … (threadDelay …) (src/Max/Handler.hs:1997)
A tool call each tool decides for itself
Acquiring a DB connection noneData.Pool.withResource has no acquire timeout

src/Max/Effects/Agent.hs:161 states the split outright: "timeouts are configured at the 'LLM' layer; these are loop-level." Nothing owns wall-clock for the turn. Worst case is 200 rounds × 300s.

The pool is the only genuinely unbounded wait in the process, and it is process-wide rather than per-conversation: src/Max/DB/Connection.hs:26 builds one 24-resource, single-stripe pool, four of which are permanently held by LISTEN waiters.

C. absorb is a substitute for a queue that was never built

One turn at a time per conversation is right. Merging the second message into the first is the part to revisit. The predicate at src/Max/Handler.hs:1873 is listTasks … (Just gm.groupId) — conversation only, not author, not topic. Production shows it firing across authors:

01:10:02 dispatch/llm absorbed into a running turn  aimed=false group_id=650536599 message_id=98421 task=t2

aimed=false, and a different sender than the one whose turn was running. The code is honest about it (NoteAmbient, "nobody claimed this message is about the work") but the consequence is that an unrelated question becomes ambient noise in someone else's context and never gets its own answer.

Combined with B this is the real outage shape: a stuck turn silently swallows every subsequent message in the conversation, each acknowledged with the 托腮 reaction whose comment promises "the message will be read" — a promise that is false exactly when it matters.

D. Debts owed by a dead turn are not durable

completeDispatch … DispatchCompleted runs immediately after the async fork (src/Max/Handler.hs:523), before the turn has computed anything. Absorbed messages live only in the in-memory inFlightTriggers. Kill a stuck turn and the questions it swallowed are not re-dispatched — they are answered only if a later message happens to start a turn that reads them out of history.

E. The orchestration layer leaks at its own exit

  • A fork child's tool ceiling is the plannable catalogweb_search, get_message_by_id, context_search, memory_list (src/Max/Plan/Catalog.hs) — because childGrants intersects against planCatalog (src/Max/Handler.hs:1258). ADR 007 §"The front model keeps the fast tools" (line 553) says the opposite: "the split is by latency, not by capability: Browser, Sandbox, Video … belong in children." The catalog's admission rule is "someone hand-wrote ptResult", which the plan expression language needs and a child does not — a child's typed boundary is subgoal_return, whose schema is the goal's expected type. Conflating the two makes fork buy nothing but parallel web_search.
  • WatchEach / JoinAll are parsed and never consumed: no reference outside Max.Plan.Types and Max.Plan.Parse.
  • Nothing but the front model can open a plan. No command, no monitor, no operator entry point.

F. Fifteen workers, no shared failure policy

Max.Worker is the whole supervisor: a required worker returning normally throws, and every worker exception is linked to the parent (src/Max/Worker.hs:59). No restart, no isolation. So "what counts as fatal" is decided independently inside each of the fifteen workers — matrix swallows its 504s, imessage swallows its timeouts, each by hand.

Prior art worth borrowing

Temporal's timeout taxonomy (activity execution, detecting activity failures) is the most directly transplantable piece. Four timeouts, each catching a different failure: schedule-to-start (worker dead or fleet behind — Temporal defaults it to ∞ and recommends a metric instead), start-to-close (strongly recommended; the server cannot detect a silently wedged worker, so it depends on this to force a retry), schedule-to-close (total across retries), and heartbeat (for long-running work: a periodic ping meaning "still making progress"; no ping inside the window means the worker is presumed dead).

max is one field away from the heartbeat: setTurnPhase already fires at both round boundaries (src/Max/Effects/Agent.hs:348 for "llm", :430 for "tools") but writes only the phase text (src/Max/Tasks.hs:337). Stamping a time there yields a real progress clock that the absorb predicate, !ps, and a watchdog can all read; TaskInfo currently exposes only tiStartedAt (src/Max/Tasks.hs:198), which is start age, not progress.

Orleans/Dapr virtual actors (actor features, reentrancy) are what C should become. Turn-based concurrency takes a per-actor lock and queues the rest; max takes the same lock and merges the rest. Worth noting that ADR 007 already invented the other half independently — "a child is not decided while a plan it opened is suspended" is reentrancy. The queue is the missing piece, not the lock.

FOR UPDATE SKIP LOCKED (pattern, caveats) is the shape the seven copies in A should collapse into: one claim function, a (status, ordering) composite index per queue, claim committed with the transaction.

Cognition, Don't Build Multi-Agents is the honest counter-reading to keep on file. Its argument — isolated parallel subagents make implicit decisions under diverging assumptions and produce work the coordinator cannot reconcile — does not refute ADR 007, whose justification for Fork is latency, not intelligence. It lands squarely on the JoinAll gap: a fork whose combining step cannot be written in advance is a net loss, which ADR 007's own Consequences section already concedes. Read as evidence for splitting by latency and against splitting by task.

Work

  • One claim primitive. SKIP LOCKED + owner + expiry, beside the existing Max.DB.Notify wait side, with the seven call sites in A migrated onto it. Landing this first is what makes the rest cheap to state.
  • A timeout taxonomy. start-to-close and heartbeat, declared separately at the turn, child and tool layers. The front-model turn is the missing one; note it cannot copy the child's race, because a front turn has already streamed part of a reply and must run an epilogue (hold what was sent, swap the reaction, requeue the inbox) rather than being dropped.
  • A progress heartbeat. Timestamp setTurnPhase; expose it on TaskInfo; read it from the absorb predicate, !ps, and a watchdog.
  • Bound the pool acquire. The one unbounded wait in the process. Failing to get a connection should be an error, not a permanent stall.
  • Queue instead of absorb. A second message in a live conversation queues behind the running turn; merging becomes the explicit reentrant case (same author, or an explicit reply into the running turn), not the default.
  • Make an owed answer durable. completeDispatch should not mark a row done before the turn it spawned has produced anything, so a killed turn returns its debts to the queue.
  • Widen the fork child's ceiling. Translate tdEffectsPlanEffect and intersect against the goal budget, instead of intersecting against planCatalog. This is what makes Fork mean what ADR 007 §553 says it means. Blocked on nothing above, but pointless until B and C stop a slow child from wedging its parent conversation.

Sequencing: claim primitive → timeout taxonomy + heartbeat (the heartbeat is what the timeouts are measured against) → queue-instead-of-absorb and durable-owed-answer (both read the lifecycle state the first two establish) → child ceiling. The pool acquire bound is independent and can land at any time.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions