When a Durable Workflow Still Depends on a Chat Window

How to keep an agentic workflow resumable while rotating its controller context, stopping repeated approaches and treating usage reports as investigation leads.

A coding-agent workflow can survive worker restarts and still have a fragile controller. The work graph may be durable, while the conversation coordinating it grows until it becomes the place where progress, retries and authority are remembered.

That is an awkward failure mode. A long conversation is expensive to carry forward, difficult to inspect, and easy to treat as authoritative even when the durable work state says something else. The controller can look busy while repeating an approach, waiting on an external system, or reconstructing facts already stored elsewhere.

The earlier articles built the scheduler and added Beads as durable workflow state. The next step was to make the controller’s own context disposable. Agentflow 0.0.3 is the result, but the useful ideas do not depend on Agentflow. They are a small set of state, ordering and evidence rules that can be added to any orchestrator.

Start with a cost signal, then investigate it

A CodeBurn optimisation report highlighted sessions with many retries and weak delivery signals. CodeBurn is a local-first tool that reads coding-agent session data and looks for patterns such as duplicate reads, context-heavy sessions, low-worth sessions, retry-heavy edits and unused capabilities. Its optimise documentation explains the detectors and the fixes it suggests.

The important word is “candidate”. A session with no edit may be a valuable review. A session with many turns may be waiting for CI or integrating a change. A high estimated saving is not a measured saving, and it is not a reason to delete a skill or stop a workflow automatically.

Run the report locally from the project you want to inspect:

npx --yes codeburn optimize -p 30days --format json > codeburn.json

Then filter the findings that are useful starting points for a controller-lifecycle investigation:

jq '.findings[]
  | select(.id == "low-worth-sessions" or .id == "context-heavy-sessions" or .id == "retry-heavy-capabilities")
  | {id, title, severity, explanation}' codeburn.json

A redacted, representative result looks like this:

[
  {
    "id": "low-worth-sessions",
    "title": "3 possibly low-worth expensive sessions",
    "severity": "high",
    "explanation": "Sessions with meaningful spend but weak delivery signals were found. Review their task and delivery evidence before changing the workflow."
  },
  {
    "id": "context-heavy-sessions",
    "title": "5 context-heavy sessions",
    "severity": "medium",
    "explanation": "Effective input/cache tokens are much larger than output. Stale context, inherently broad work, or an abandoned run may be contributing."
  }
]

The values above are deliberately redacted and representative; do not publish a raw report containing local paths, session identifiers or account figures. For each candidate, compare the session with evidence that CodeBurn cannot know:

Question Evidence to check
Was useful work delivered without an edit? Review findings, research notes, decisions, CI transitions and accepted task results
Was the same approach retried? The approach name, failure reason and the next attempt’s changed assumption
Did the work reach the repository? Commits, checks, merged changes or an explicit rejected/deferred disposition
Was the controller waiting? A deterministic watcher and the external state transition

This produces an investigation record rather than a blind optimisation action. In Agentflow, the report is passed to usage optimize so the CodeBurn signal can be compared with workflow and delivery evidence. In another system, the same rule can be implemented with a JSON report, a task database and a small reconciliation script.

Separate durable state from replaceable context

The core design decision is an authority boundary:

Durable state Replaceable context
Work graph, dependencies, decisions and acceptance evidence Prompts, responses and tool logs
Controller lease, checkpoint and authenticated provider results A long-running controller conversation
Task-class progress and named retry approaches The next controller session’s working memory

The durable store can be Beads, SQLite, Postgres, a queue plus an event log, or a set of carefully versioned files. The choice matters less than the rule that a new controller can reconstruct the next safe action without replaying the previous transcript.

At minimum, persist these fields for the workflow root:

{
  "schema": "controller-state/v1",
  "workflow": "root-id",
  "checkpoint": {
    "state": "running",
    "phase": "review"
  },
  "policy": {
    "rotate_after_tasks": 4,
    "rotate_after_phases": 2,
    "same_approach_failures": 2
  },
  "progress": {
    "coding": {"completed": 2, "evidence": 4},
    "review": {"completed": 1, "evidence": 3},
    "external_wait": {"completed": 0, "evidence": 1}
  },
  "attempts": {
    "task-17:bounded-retry": {"failures": 1}
  }
}

Keep the transcript, credentials, return capabilities and untrusted task prose out of this record. A database is not made durable in the useful sense if it is just a second place to store an unfiltered chat log.

Count evidence by task class

An edit counter is easy to implement and wrong often enough to be dangerous. A reviewer can produce a valuable finding without changing a file. A researcher can settle a design question. A CI watcher can make progress while doing no model work at all.

Use a task-class ledger instead:

Task class A progress event
Coding An edit, a passing check or a completed task
Research Evidence, a finding, a decision or completion
Review Evidence, a finding, a decision or completion
External wait A watcher result, an external state change or completion

The event should be recorded when the controller accepts the evidence, not whenever a tool returns. That prevents a large log or a failed command from looking like delivery.

The rotation decision can then be deterministic:

def should_rotate(progress, policy):
    completed_tasks = sum(lane["completed"] for lane in progress.values())
    completed_phases = len({lane.get("last_phase") for lane in progress.values()
                            if lane.get("last_phase")})
    return (
        completed_tasks >= policy["rotate_after_tasks"]
        or completed_phases >= policy["rotate_after_phases"]
    )

These are context-health thresholds, not task timeouts. They recommend a fresh conversation; they do not cancel safe autonomous work. Keep the thresholds in the durable policy record. When a command omits an override, load the stored value instead of silently replacing it with a default.

Stop repeated approaches, not useful work

Retry limits become useful when they describe what was retried. “The task failed twice” is weaker than “the same approach failed twice.” The latter gives the controller a reason to stop and a decision to make.

Give each attempt a stable key based on the task and a named approach. Record the failure before scheduling another attempt:

def record_failure(state, task_id, approach):
    key = f"{task_id}:{approach}"
    attempt = state["attempts"].setdefault(key, {"failures": 0})
    attempt["failures"] += 1
    if attempt["failures"] >= state["policy"]["same_approach_failures"]:
        state["checkpoint"] = {
            "state": "blocked",
            "reason": "same-approach-limit",
            "task": task_id,
            "approach": approach,
        }

The next action is now explicit: record a decision, ask for a different approach, or stop. Do not turn the same patch, command or assumption into a larger context simply because the previous context was large.

Rotate with a minimal handoff

A handoff should contain enough information to resume safely and no more. A useful shape is:

{
  "schema": "controller-handoff/v1",
  "workflow": "root-id",
  "checkpoint": {"state": "running", "phase": "review"},
  "ready_task_ids": ["task-17"],
  "blocked_task_ids": [],
  "progress": {"review": {"completed": 1, "evidence": 3}},
  "next_action": "reproduce-accepted-finding"
}

The fields should be an allowlist, not a serialisation of whatever metadata happens to be attached to a task. Use identifiers, enumerated stages and bounded values. Exclude task titles, free-form descriptions, prompts, tool output, secrets and provider return capabilities. If a human needs the full explanation, link to the durable evidence record rather than embedding the explanation in the handoff.

Rotation must also be side-band and read-only with respect to ownership. It may authenticate the operator and snapshot state, but it must not acquire the live controller’s lease, increment its epoch or fence an autonomous process that is still running. The fresh context should start by reading the checkpoint and the work graph, not by assuming that the handoff itself is authority.

Check terminal state before mutating the graph

Recovery code often begins by asking the work graph for ready tasks. That ordering is unsafe if the controller has already been marked blocked or complete: the recovery path can claim work or update metadata before it notices the terminal checkpoint.

Make the terminal check the first stateful operation:

TERMINAL = {"blocked", "complete", "cancelled"}

def resume(state, graph):
    checkpoint = state.read_checkpoint()
    if checkpoint["state"] in TERMINAL:
        return {"status": "terminal", "checkpoint": checkpoint}

    ready = graph.list_ready(state.workflow)
    return {"status": "ready", "tasks": ready}

The real implementation must make the checkpoint read and task claim safe against concurrent writers, usually with a transaction or compare-and-swap version. The ordering rule is the portable part: recovery is observational before it is mutating.

Treat delivery evidence as a separate trust boundary

Usage data says that a session happened. It does not say that the session delivered an accepted result. A provider pane, process record, task claim or mapping-shaped JSON object can look convincing while being unrelated to the task being measured.

For a provider-backed result, require all of the following before counting it as delivery evidence:

  1. A structured result exists.
  2. The return channel was consumed.
  3. Task, launch, provider and session identities match the bound session.
  4. The canonical result digest matches the recorded digest.

Anything short of that remains an observation or an advisory signal. This is especially important when reconciling a third-party usage report with your own workflow database: identity and integrity checks belong at the join, not in a later paragraph of documentation.

The review findings became implementation decisions

The review of the 0.0.3 controller found five material gaps. The useful story is the engineering decision each gap forced:

Finding Decision Implementation
Side-band rotation could fence a live controller Rotation must not change ownership Authenticate and snapshot without reattaching, changing the lease epoch or acquiring the lease
Terminal resume could claim work too early Terminal state has precedence over traversal Read and validate the checkpoint before listing, claiming or mutating tasks
Omitted flags could reset persisted thresholds “Unset” is different from “explicit default” Load the stored policy first; apply an override only when the user supplied one
A result-shaped record could be mistaken for delivery evidence Shape is not provenance Require consumed return state, matching identities and a digest match
Untrusted task text could enter the handoff Handoffs use a schema allowlist Serialize IDs, enumerated stages and bounded state; omit free-form text and credential-like values

A follow-up pass found narrower bypasses around result identity, digest checking and stage-label suffixes. Those paths were tightened with the same approach: define the invariant first, then test the exact boundary rather than adding another general warning.

The resulting tests are more valuable than the review narrative. They exercise terminal resume without graph mutation, omitted policy flags, side-band rotation, forged result records, mismatched digests and unsafe labels. The next time the controller changes, those invariants can be rerun without relying on anyone remembering why the code was written that way.

How this maps to Agentflow 0.0.3

Agentflow packages these patterns as task-aware controller progress, repeated-approach halts, transcript-free controller rotate handoffs and evidence-aware CodeBurn reconciliation:

agentflow controller rotate

agentflow usage optimize \
  --codeburn /path/to/codeburn-report.json \
  --root . \
  --workflow-root <root-id>

The reconciliation output labels CodeBurn savings as a third-party heuristic, not verified savings. It combines the report with completed usage records, terminal workflow state and authenticated provider returns, while refusing to automatically remove managed skills or agents.

That behaviour is not the only way to build the system. The portable lesson is the sequence: observe the signal, join it to delivery evidence, persist the decision, and keep the controller context replaceable.

Release and next measurements

Agentflow 0.0.3 is a public preview release. Its release gates recorded 472 passing tests with one documented skip. Repository and package validation, clean-wheel installation, hosted Python and macOS boundary coverage, security and distribution checks also passed. The published wheel was verified before the local installation was upgraded.

Install the tagged preview in an isolated tool environment:

uv tool install "git+https://github.com/saintdle/agentflow.git@v0.0.3"
# or
pipx install "git+https://github.com/saintdle/agentflow.git@v0.0.3"

Then check the command and local dependencies:

agentflow --version
agentflow doctor
agentflow install --dry-run

Read the 0.0.3 release notes and the public repository before adapting the commands to a real workflow.

The estimate is only a lead. To see whether this change helps, compare similar work before and after it. Count the controller sessions, repeated approaches, completed tasks and accepted results. Record elapsed time and provider usage when available. Did the context get smaller without the work getting worse?