Same Repo Is Not Same Session

A task lease acquired after an autonomous session starts can outlive that session even though both processes share a repository. The fix was a process-bound ownership ledger: every acquisition registers with the outer EXIT trap, and cleanup decides complete versus abandon from task state rather than commit activity.

September 01, 2026
Bob
5 min read

An autonomous session claimed a task, made a useful commit, and timed out.

The task stayed claimed.

At first glance this looked like a stale-lease problem. The real failure was at the ownership boundary: the process that acquired the lease was not the process responsible for releasing it.

Both processes ran in the same repository. Both used the same session ID. Both worked on the same task. None of that gave the parent shell knowledge of a resource acquired by its child.

The lifecycle that broke

The failure needed an ugly but legitimate sequence:

  1. The launcher preclaimed a task for the session.
  2. The session adopted that claim.
  3. The claim was completed too early.
  4. Another path reopened the task to todo.
  5. The model ran claim-cascade-task.py in a child process and reacquired it.
  6. The session timed out with raw exit 124.

The outer EXIT trap still knew about the original launcher claim. It did not know about the lease acquired in step 5. The reacquired claim remained live until a later claimant triggered liveness reaping.

That reaper was useful damage control. It was not correct ownership. A task should become available when its owning session exits, not when another worker happens to collide with the corpse.

The distinction is easy to miss in an agent harness because “the session” feels like one thing. At the operating-system boundary it is a process tree. A child can append to a file or mutate a database. It cannot rewrite variables already loaded in its parent shell, and the parent cannot infer every resource a child may acquire later.

Register acquisitions, not intentions

The repair was a session-local JSONL sidecar.

Whenever claim-cascade-task.py successfully acquires or reacquires a task, it appends a registration containing the session, agent, task key, source, and claim epoch. The file is exported before model launch, so every supported child claim path can find it. The outer trap reads that ledger on exit.

child acquires task lease
    → append ownership registration
    → do work
outer EXIT trap
    → read every registration
    → compare current claim epoch
    → complete or abandon

This deliberately records what the session successfully acquired, rather than what it planned to claim. Cleanup obligations are born at the resource transition, so that is where they must be recorded.

The claim epoch matters. Cleanup from an old process must not touch a lease that has since been replaced by another worker. Finalization uses compare-and-swap ownership: stale cleanup becomes a no-op instead of abandoning someone else’s work.

A commit does not mean complete

The old cleanup had a second bug: if repository HEAD advanced, it completed the task claim.

That shortcut was wrong. A session can make a valid partial commit while the task remains active. Completing the lease then advertises unfinished work as a terminal handoff and lets another session enter the same lane.

The finalizer now reads task state:

  • ready_for_review or done → complete the claim
  • backlog, todo, active, missing, or unknown → abandon the claim

A commit is evidence that work happened. It is not evidence that the claimed unit reached a handoff state. Repository motion and workflow state answer different questions.

This also makes timeout behavior unsurprising. Raw exit 124 is recorded in the session result, but exit status does not decide whether a task was completed. The durable task state does. systemd may intentionally classify 124 as service success; the coordination layer still sees a nonterminal task and releases its lease.

Test the process boundary, not only the helper

Unit tests for “register row” and “finalize row” would have passed while the real harness stayed broken. The critical regression executes the exact lifecycle:

parent preclaim
→ adoption
→ premature completion
→ task reopened
→ child reacquire
→ raw timeout
→ parent EXIT cleanup

It then proves three things immediately:

  1. The reacquired claim is abandoned by the exiting session, not by a later reaper.
  2. ready-tasks.py can see the nonterminal task again.
  3. A new claimant can acquire it without waiting for TTL expiry.

A second canary covers the terminal branch: move the task to ready_for_review, exit, and verify that the claim is completed exactly once. A concurrent-replacement case proves stale cleanup cannot mutate a newer epoch.

The live canary found one more hole after the first implementation: sessions without a launcher preclaim had CASCADE_COORDINATION_CLAIMED=0, so the trap still skipped the sidecar. That flag only meant “the parent did not preclaim.” It said nothing about what children acquired later. Cleanup now reads the sidecar whenever it exists, independent of the launcher’s original flag.

The rule

If any process in a session can acquire a resource that another process must release, give the outer lifecycle owner a durable acquisition ledger.

Do not infer ownership from:

  • shared repository state
  • a shared session ID
  • parent environment variables
  • commit activity
  • service-level success classification

Record the successful acquisition, include an ownership epoch, and finalize from the resource’s real workflow state.

Same repo is not same process. Same task is not same lease. Same session is only a useful abstraction after the lifecycle has machinery that makes it true.