doc_id: architecture.durable-task-loop-headless title: “Chapter 4: The Durable Task Loop—Inside Maka Headless” language: en source_language: zh-CN counterpart: ./durable-task-loop-headless-draft.zh-CN.md implementation_status: mixed-current-and-target document_status: draft translation_status: synced last_verified: 2026-07-12 owners:
This chapter answers one question: when a task outlives a Turn, a Run, or even the process that started it, how does Maka preserve stable identity, progress, budgets, and interruption boundaries? Headless introduces
TaskRun: each Agent execution becomes a bounded Attempt, cross-Attempt task facts enter an append-only Task Event Log, and the next execution receives a projected working state from that log. A task is longer than a turn; durability begins by giving the task its own log.
This chapter is for Runtime engineers changing Headless orchestration, TaskRun, Autonomous Loop, Heavy-task progress, or recovery. The first half establishes the long-running-task mental model. The second half examines the event protocol, Attempt boundaries, budgets, permission parking, workspace continuity, and current recovery limits.
This chapter is only about how an Agent continues a long-running task in unattended or weakly interactive environments, and how Runtime preserves the public facts needed to answer “where should work continue?”
The chapter contains two lifecycle states:
TaskAgentController, AutonomousAgentLoop, TaskRunStore, Heavy-task, and Harbor Cell continuation;Target sections are not current guarantees. One distinction matters from the beginning: the TaskRun ledger is durable today, but workspace state, an in-flight Invocation, and scheduler ownership do not yet form one unified checkpoint protocol.
Suppose a user gives Maka this task:
Read a large repository, migrate a legacy interface, fix build failures incrementally, and stop for approval before executing high-risk actions.
The task may unfold like this:
A Turn can identify one exchange. A Run can identify one concrete execution. A RuntimeEvent ledger can replay what the model and tools experienced. A long-running task still needs answers to another set of questions:
What is the stable ID of this long-running objective? How many Attempts have happened? How much budget remains? Which progress snapshots are still current? Is the task running, waiting for approval, or stopped by a budget cap? Which context should the next execution inherit? Is the workspace still the one left by the previous Attempt? Who is allowed to take ownership of the next step?
This is why Headless adds the TaskRun layer.
Maka's execution identities form four layers:
| Identity | Question it answers | Typical lifetime |
|---|---|---|
| TaskRun | Which long-running objective exists from start to terminal state? | Across Attempts and executions |
| Attempt | Which bounded slice is advancing the task now? | One task-level try |
| Turn | Which input and response belong to this exchange? | One user/continuation exchange |
| Run / Invocation | How did this model-and-tool loop start and end? | One Runtime execution |
Read the diagram from top to bottom. TaskRun does not replace Runtime's Session, Run, or Invocation. It adds a longer durable envelope around them. Runtime Events preserve the semantic facts of each Agent execution; Task Events preserve facts that cross executions. Workspace is deliberately omitted because its lifecycle is not always as durable as TaskRun. A later section treats it separately.
The most important boundary is:
Headless does not reimplement Agent Runtime. It organizes Runtime executions into a long-running Task lifecycle.
A Session can span multiple Turns and preserve model history, but it primarily answers “which conversation contains these events?” TaskRun must also express:
An ordinary interactive Session should not be forced to carry those task-control fields. Conversely, different Attempts in one TaskRun may currently create different Sessions and AgentRuns. Merging the concepts would entangle UI conversation state with durable orchestration state.
Current references therefore flow from Task Events to sessionId and agentRunId; Session headers do not become the source of truth for TaskRun.
TaskRunStore persists TaskEvent in append-only JSONL, one file per taskRunId. Within one process, a promise queue serializes appends to the same TaskRun.
Task events relevant to the long-running control plane include:
needs_approval parked state.projectTaskRun() replays the events and computes TaskRunProjection: current status, Attempts, latest progress, evidence, permission facts, parked state, warnings, and references to Runtime executions.
The relationship matches earlier chapters:
TaskRunProjection(t) = Project(TaskEvents[0..t])
The projection is not a second mutable source of Task truth. CLI inspection, resume planning, prompt replay, and exports should derive from Task Events.
The current implementation durably preserves:
The TaskRun ledger alone does not guarantee:
This is the chapter's most important precision boundary: durable control facts do not mean the entire execution world has been checkpointed.
TaskAgentController.runTaskOnce() wraps one Attempt around Runtime. It currently advances through this sequence:
WorkspaceLeaseFacts and ToolExecutorIdentity.AgentRun, and single-Run active-session shell.task_run_started and task_attempt_started.AgentRun → AiSdkFlow → RuntimeRunner.This path bypasses the SessionManager.sendMessage() facade and assembles the Runtime core directly. Headless does not need another model loop. It needs a non-streaming InvocationResult, its own Task Event Log, an isolated backend lifecycle, and an Attempt post-processing boundary.
The current cost is partial duplication between runRuntimeAttempt() and the Turn shell in RuntimeKernel. A future shared runner must preserve Headless hooks for Task Events, intervention, and isolated lifecycle. A thin “send a message and return text” function would not be sufficient.
An Attempt is one advance with explicit budget and resource boundaries. It may end normally, fail during execution, remain incomplete, block on permission, exhaust a budget, be cancelled, or be aborted.
TaskRun may continue after an Attempt or enter a terminal state. The important property is not labeling every non-completion as a generic error. It is preserving enough structure for an outer policy to decide:
continue → advance the same objective, usually with a continuation instruction retry → try the current work again under a new execution slice stop → create no new Attempt abort → explicitly abandon further execution
AutonomousAgentLoop calls runTaskOnce() again for every Attempt. It does not resume an old RuntimeRunner instruction pointer or resurrect a tool process that already exited.
The accurate equation is therefore:
Long task progress = sequence of bounded Attempts + durable Task Events between them + explicitly selected continuation state
It is not:
Long task progress = one immortal Invocation
An unattended loop whose only condition is “continue until finished” has no safe terminal condition. Current AutonomousLoopBudget provides three caps:
| Budget | What it limits | When it is checked |
|---|---|---|
maxAttempts | Maximum Attempts in the TaskRun | Before and after each Attempt |
maxRuntimeSteps | Accumulated Runtime steps across Attempts | After each Attempt |
maxWallTimeMs | Total wall time of the TaskRun loop | Before and after Attempts |
LoopBudgetSnapshot passes used and maximum values to the decision policy. Even if a custom policy requests continue or retry, enforceCaps() reapplies the hard limits.
stateDiagram-v2 [*] --> Queued Queued --> Running: start Attempt Running --> Decide: Attempt ends Decide --> Running: continue / retry and budget remains Decide --> NeedsApproval: park intervention NeedsApproval --> Running: resume as a new Attempt Decide --> BudgetExhausted: cap reached Decide --> Stopped: stop / abort / cancel / terminal outcome BudgetExhausted --> [*] Stopped --> [*]
This diagram shows long-running control states rather than the complete TaskRunStatus union. Every loop passes through an Attempt boundary and budget decision. Resuming needs_approval also creates a new Attempt. Runtime's internal Turn and Run states are omitted.
Attempt count can be rebuilt from Task Events. Runtime steps also appear in Task feedback and execution output. The current loop's startedAt, accumulated counters, and decision closure, however, are primarily owned by the running runAutonomousTask() call.
Current state is auditable, but a generic TaskRun projection alone cannot restore every loop counter unambiguously at an arbitrary process-restart point. The Target section describes the budget checkpoint required for that guarantee.
A long-running task should not continue by placing only the previous Attempt's final natural-language response into the next prompt. Heavy-task mode introduces structured, append-only progress facts.
inventory_submit records a complete inventory snapshot: files or artifacts, status, purpose, and open questions. It is not a patch. A newer inventory event becomes the projection's current inventory, while older snapshots remain in the log.
todo_update also submits a complete todo snapshot. Each item carries an ID, priority, status, content, and optional kind. The projection can locate the active todo while preserving its history.
Headless captures bounded public evidence around Bash, Read, Grep, Write, Edit, Glob, and artifact paths. It preserves short summaries, truncation references, source links, and mutation metadata—not large stdout, complete file bodies, or raw diffs in the task prompt.
Self-check preserves public reasons, command/artifact evidence, and execution hygiene. It is advisory task state, not hidden authority. Workspace observation and a bounded repair gate may request one additional repair Turn, but the gate limits repair count explicitly so dissatisfaction cannot create an infinite self-check loop.
When a later Attempt starts, renderHeavyTaskProgressForPrompt(), renderHeavyTaskSelfCheckForPrompt(), and renderHeavyTaskEvidenceForPrompt() build bounded text from the TaskRun projection: a subset of inventory/todos, recent evidence, and a limited number of command/artifact entries.
This is a projection of the Task Event Log:
Full task history remains in Task Events → latest progress snapshots are selected → recent public evidence is bounded → next Attempt receives a continuation-oriented prompt view
It is not a complete replay and does not imply that omitted raw output remains in the prompt.
“Continue the task” currently refers to at least three mechanisms in Headless. They are not one resume protocol.
Harbor Cell continuation repeatedly calls SessionManager.sendMessage() within the same Session and container workspace. It currently continues only when the preceding Invocation ends because of a tool-step cap or incomplete Tool Calls.
The next Turn receives a fixed continuation prompt and is bounded by maxTurns and maxTotalRuntimeSteps. When enabled, the defaults allow up to three Turns and calculate the total Runtime-step default from 50 steps per Turn. The cell output combines Invocation events and retains per-Turn status, step-cap, and step summaries.
This path is the closest current mechanism to continued work on one workspace, but it still creates several new Invocations. It does not resume one Invocation at an internal instruction pointer.
Autonomous continue/retry calls runTaskOnce() again. Heavy-task state can be projected from Task Events into the new instruction. When replayPriorAttemptRuntimeContext is enabled, RuntimeEvents from earlier Attempts are explicitly appended to the next Runtime context.
That replay option is not mandatory by default. With it off, cross-Attempt continuation primarily depends on instruction feedback and TaskRun progress projection.
With intervention policy set to park, a permission request creates a TaskPermissionRequest, Inbox item, Attempt needs_approval, and TaskRun parked state.
Current task resume supports only this needs_approval state. It first resolves the Inbox item, then creates a new Attempt and writes grant facts to the Task Event Log.
A grant that arrives only after Runtime has emitted a handoff cannot retroactively authorize the old Tool Call. Current implementation explicitly rejects treating a post-hoc grant as though the interrupted Invocation had already been authorized. Resume supplies state to the next Attempt; it does not revive the old call stack.
By default, local runTaskOnce() creates a fresh throwaway copy from the task fixture for each Attempt and removes it in finally. Fixture symlinks are rejected so the copied tree cannot retain an escape path into the host.
Task Events record WorkspaceLeaseFacts:
cleanup_on_finally policy;createdAt; the contract reserves optional releasedAt, but the current controller does not append a lease-release event during cleanup.Recording a lease does not preserve workspace bytes. On the default local autonomous path, the next Attempt copies the fixture again. Changes from the previous Attempt do not reappear merely because taskRunId is unchanged.
External isolation may provide a stable workspaceDir, and Harbor Cell can share one workspace while the container remains alive. The current TaskRunStore, however, does not own a snapshot, lease-renewal, or fencing protocol for that external workspace. It cannot promise general cross-process continuity.
The boundary is:
Current TaskRun durability preserves control history; workspace continuity remains carrier-dependent.
A throwaway directory isolates fixture mutation; it is not an OS security sandbox. A real model-backed backend must provide RealBackendIsolation naming an external boundary such as a Harbor container, Docker workspace, or remote executor.
The standard Headless tools route Bash and file operations through IsolatedToolExecutor. Before dispatch, they reject absolute paths, .. escapes, and absolute glob patterns. Tool-executor identity and env/network/secret policy enter Task Events for later projection and audit.
Headless validates only the isolation record's shape and non-empty label. The external executor must implement real filesystem, network, process, and secret isolation. Writing { kind: "external" } into an object is not security; it is an assertion the carrier must honor.
File TaskRunStore appends JSONL per TaskRun. During reads:
event_corrupt and surface in projection warnings;These rules let a restarted process rebuild durable task-control facts. They do not automatically restart execution.
Missing general capabilities include:
running Attempt is alive or its owner died;The accurate statement is: Maka currently has durable, replayable Task state, but not a complete crash-resumable task executor.
If another process must safely take over a long-running task, Task Event high water alone is insufficient. A recovery point must bind at least five classes of state:
DurableTaskCheckpoint task taskRunId taskEventHighWater policyVersion runtime sessionId completedRunIds runtimeEventHighWaterByRun workspace snapshotId manifestHash carrierIdentity budget attemptsUsed runtimeStepsUsed elapsedAccounting control nextAction parkedInboxItemId? ownerLease / fencingToken
These are recovery invariants, not a commitment to final field names.
Task projection cannot claim that an Attempt's progress is recorded while the corresponding RuntimeEvent is not durable. Nor may it treat a terminal Runtime Run as unstarted. A checkpoint must reference committed Run and RuntimeEvent boundaries explicitly.
If a workspace snapshot is older than the progress event, the next Attempt believes in changes that do not exist. If the snapshot advances but the Task Event does not commit, a new owner may repeat side effects already performed. Snapshot ID, manifest hash, and event high water need an atomic association or a convergent two-phase protocol.
A process restart cannot reset Attempts, steps, or wall-time policy. Elapsed accounting must also distinguish active execution, parked time, and scheduler downtime. That is a policy decision, not something Date.now() - newProcessStart should decide accidentally.
The current in-process promise queue serializes appends only inside one process. Multiple schedulers, or a stale owner returning late, require a lease epoch or fencing token to prevent duplicate Attempts and conflicting terminal writes.
Read this target diagram left to right. Recoverability comes from consistently binding several high waters, not from writing another continuation prompt. It does not attempt to revive a provider socket or tool process. A new idempotent Attempt remains the primary recovery unit.
| Capability | Current | Target |
|---|---|---|
| Task identity | Durable taskRunId | Preserve |
| Task state | Append-only Task Events plus projection | Versioned checkpoint and scheduler read model |
| Attempt continuation | New Attempt with bounded prompt projection | Idempotent new Attempt plus explicit resume plan |
| Runtime history | References; optional replay of earlier Attempt events | Checkpointed Runtime high water |
| Progress | Heavy-task snapshots and evidence | Versioned domain-neutral task-state envelopes |
| Workspace | Local throwaway lease or carrier-owned directory | Durable snapshot, manifest, and lease fencing |
| Permission | Fail closed or park; needs_approval can resume as a new Attempt | General parked-state protocol and capability-scoped grants |
| Budget | In-loop counters that can be observed | Durable accounting across process ownership |
| Concurrency | Per-process append queue | Cross-process lease, CAS, and fencing |
| Crash recovery | Rebuild TaskRunProjection | Scheduler claim and safe continuation |
A Turn should retain a clear input and terminal boundary. A long-running task advances through bounded Attempts instead of holding one provider stream open forever.
Cross-Attempt state should come from bounded progress and evidence projections. Raw RuntimeEvents replay only when a policy selects them explicitly.
TaskRun state serves one concrete long-running objective. Cross-task user preferences and long-term knowledge have different lifecycles and governance boundaries.
The Task Event Log may reference a workspace lease or snapshot, but it cannot replace file bytes, an external container, or a remote volume.
Current continuation and the recommended future recovery unit both create a new Turn or Attempt. Recovering a provider stream or live tool process is a stronger and more fragile protocol; this chapter does not make it the default target.
taskRunId.The Target should add owner fencing, idempotent Attempt creation, checkpoint CAS, and durable budget accounting.
TaskRun adds another event protocol and projection. The cost is more identities and more states, plus references that must remain consistent between Task Events and Runtime Events. Placing those facts in Session messages or Run headers would bind long-running orchestration to interactive Runtime and create a greater cost.
TaskAgentController currently duplicates part of the Kernel shell in exchange for a non-streaming Invocation result and independent task hooks. If Headless, Desktop automation, and a scheduler later require the same capabilities, Maka should reevaluate and extract a shared Attempt runner.
Heavy-task progress is currently domain-specific. Inventory and todos fit engineering work well but may not fit every long-running Agent. When a second independent domain appears, Maka should consider lowering generic envelopes, source references, and projection windows while leaving concrete schemas in task profiles.
Local throwaway workspaces isolate Attempts but conflict with true continuation. The trigger for durable workspace snapshots should be a product need for cross-process recovery, long parking, or multi-worker scheduling—not merely longer execution time.
Core implementation locations:
packages/headless/src/task-contracts.ts: TaskRun/Attempt states, Task Events, permission, Inbox, workspace, and progress contracts;packages/headless/src/task-run-store.ts: append-only JSONL and projectTaskRun();packages/headless/src/task-agent-controller.ts: single-Attempt orchestration and Runtime assembly;packages/headless/src/autonomous-agent-loop.ts: Attempt loop, budgets, and decisions;packages/headless/src/heavy-task-progress.ts: inventory/todo recorder and prompt projection;packages/headless/src/heavy-task-evidence.ts: bounded public evidence;packages/headless/src/heavy-task-self-check.ts: advisory self-check state;packages/headless/src/heavy-task-self-check-gate.ts: bounded repair gate;packages/headless/src/isolation.ts: external-isolation assertion, workspace, and executor facts;packages/headless/src/tools.ts: isolated tool surface;packages/headless/src/harbor-cell.ts: same-Session, multi-Turn continuation;packages/headless/src/cli.ts: TaskRun inspection, parked resume, and command routing.Important tests:
task-run-store.test.ts: event ordering, projection, corrupt tails, permission/Inbox state, and terminal conflicts;task-agent-controller.test.ts: RuntimeRunner path, progress, bounded repair, permission fail-closed/park, and Runtime references;autonomous-agent-loop.test.ts: multiple Attempts, RuntimeEvent replay, hard caps, and budget parking;heavy-task-progress.test.ts, heavy-task-evidence.test.ts, and heavy-task-self-check.test.ts: cross-Attempt state projection;harbor-cell.test.ts: same-workspace continuation, Turn/step caps, and combined events;tools.test.ts: isolated file/tool semantics, path escape protection, and concurrent writes;cli.test.ts: TaskRun inspection and needs_approval resume.The center of Maka Headless is not “no UI.” It organizes open-ended Agent work into bounded, observable execution slices:
TaskRun → Task Event Log → Attempt → Run / Invocation → Runtime Events → bounded progress and evidence projection → decision under budget → next Attempt, park, or terminal state
The current implementation already provides the skeleton of a long-running task: stable taskRunId, append-only Task Events, Attempt lifecycle, three budget layers, Heavy-task progress and evidence, permission Inbox, and distinct paths for same-Session multi-Turn and cross-Attempt continuation.
Durability must remain precise. What is durable today is primarily control history and projection sources—not every workspace byte, in-flight process, or scheduler owner. A truly cross-process Durable Task Loop still needs one recovery protocol binding Task high water, Runtime high water, workspace snapshot, budget accounting, and owner fencing.
This also explains “Loop” in the title. It is not an endless while loop. It is a sequence of bounded Attempts connected by a log. Every execution may end; the task still knows where the next one should begin.