Skip to content

0077: Converge lifecycle signals on one session finalizer

Status: accepted (2026-08-17) · Scope: product tier (core/agents, core/events/v2, adapters, web)

Some harnesses report a trustworthy session-end callback and some do not. A finished response is not the end of a session. Removing a task from a sidebar is also not enough by itself: users can restore archived tasks, applications can crash, and a host may disappear without delivering a final callback.

The V2 ledger already separates a native terminal event from a provisional cleanup observation. The missing piece was a safe way to combine several weak signals without inventing native telemetry or writing competing terminal rows.

Harnery has one canonical approved finalizer. Native callbacks continue to write session.ended with authority: "native". Every non-native path uses the same private producer state, lease, pending-event recovery, and append-only V2 writer to emit at most one session.ended with authority: "approved".

The approved reason identifies the source:

  • approved_explicit_end for harn agents end and the harn-end skill;
  • approved_verified_archive for a durable adapter archive observation after its cancellation grace period; and
  • policy_* for idle timeout, parent or run completion, delegated-agent completion, stale sweep, supersession, and host disappearance.

An archive observation is adapter-specific. Codex reads only id, archived, archived_at, and updated_at_ms from a private snapshot of its local state database, then joins the ID to Harnery’s HMAC-addressed producer state. It does not ingest titles, prompts, response bodies, working directories, or model metadata. Other harnesses can submit the same normalized archive observation through harn agents observe-archive.

An archive waits 10 minutes by default. Unarchiving or any subsequent Harnery activity cancels the pending request. Idle sessions are observed after three days and become policy-terminal after seven days. Cascades and host-loss observations wait one hour by default. All requests are durable owner-only records, and reconciliation is serialized by a no-clobber lease.

harn agents reconcile runs one pass. --watch turns it into the host supervisor loop; its default interval is 15 minutes. V2 session start also runs one fail-soft pass, so ordinary work repairs missed scheduled runs. Parent terminal, agent.completed, run.completed, stale-sweep, duplicate live generation, and explicit host-loss evidence are discovered during the same pass.

Explicit end is stricter. It refuses a delegated child or unfinished Git claim. When the request comes from inside the turn it is trying to end, Harnery first writes a durable end request tied to that exact turn and its already-open tool spans. The adapter stop hook may emit the terminal event only after the spans and turn close. A new turn, new tool request, new delegation, or resume cancels the request. This avoids both a self-deadlock and premature termination.

harn agents status --end-turn --end-session combines the repository hygiene check, final status output, and durable end request in one final command. The web control uses the same path: it records immediately for an idle generation or reports that the end is queued for the current turn. Neither path deletes a heartbeat file. A failed or unsafe finalizer never falls back to a V1 row or filesystem deletion.

Finalization authority and evidence eligibility remain separate. Native ends and deliberate explicit ends can enter candidate collection. Verified archive ends carry terminal_ambiguity_review_required, because archive is reversible and needs a reviewer to confirm the task really ended. Automatic policy_* ends remain useful for coordination cleanup but are excluded from the run-quality corpus. They cannot turn uncertainty into labeled evidence.

Sessions now have several recovery paths without producing several terminal authorities. A stale producer cannot resurrect a finalized generation. The system retains the reason and completeness of every approved end, while raw adapter identity stays out of the ledger.

The design does not claim that every harness exposes archive state. Basic V2 compatibility only requires normal event capture. Archive-assisted finalization is an adapter capability; explicit end, idle reconciliation, ledger cascades, and host supervision remain available when it is absent.

Treat archive as a native session end. Archive is reversible and the Codex UI does not promise a terminal callback, so it remains an approved, review-gated observation.

Delete heartbeat files. This destroys a projection without recording terminal authority and leaves the evidence corpus unable to distinguish ended from vanished.

Let every failsafe emit its own terminal row. Competing writers create terminal races and make deduplication a reader concern. One leased finalizer keeps exactly-once authority at the write boundary.

Disable the open-turn guard for harn-end. The command itself runs inside a tool span and turn, but bypassing the guard would let the terminal row precede real completion. A durable deferred request preserves the ordering invariant.

Admit idle and stale closures to the corpus. These signals are useful for cleanup but too ambiguous to label run quality without independent review.

Result: terminal-generation cache cleanup (2026-08-22)

Section titled “Result: terminal-generation cache cleanup (2026-08-22)”

Event Ledger V3 exposed a housekeeping gap after this decision shipped. The stale sweep recorded lifecycle.sweep_observed against the heartbeat’s own generation before deleting its disposable cache row. That works while the generation is joinable. It cannot work after session.ended, because V3 correctly refuses to append another event to the terminal generation.

The sweep now keeps the provisional lifecycle event for joinable generations. For a terminal or otherwise unavailable generation, it writes a body-free heartbeat_sweep_unrecorded producer diagnostic and then removes the stale cache row. If neither the event nor the diagnostic can be persisted, the row is kept. This does not finalize or resurrect a session: the canonical ledger already owns that truth, and the deleted file is only the disposable .harnery/active/ projection.

Result: the sweep stopped running in the reconcile pass (2026-08-31)

Section titled “Result: the sweep stopped running in the reconcile pass (2026-08-31)”

This decision says the stale sweep is discovered during the same reconcile pass. The wiring drifted away from that. Both entry points, the public agents reconcile command and the session-start pass, came to call only reconcileSessionFinalizationV3. The sweep survived as its own stale-sweep subcommand that nothing invoked, and the session-start comment still claimed it ran.

A second defect hid the first. The finalizer matched lifecycle.sweep_observed with observation: "stale_sweep", which is the name of the finalization request, not of the observation. The sweep emits stale_heartbeat. From a cold start the branch was unreachable: the sweeper’s observation never matched, so no stale_sweep request was ever created, so the derived echo the predicate would have matched never existed. Reaping was therefore silently absent while the health rollup kept reporting the stale rows it could not clear.

Both are fixed together, because either one alone leaves the pass broken. The predicate now matches stale_heartbeat, and one shared composition, reconcileCoordinationV3, runs the sweep before finalization and reports its counts. Both entry points call that composition. Creating a request still writes a derived observation named after the trigger; no predicate matches that echo, so the finalizer cannot feed itself, and an integration test pins the whole chain, including that a second pass neither re-sweeps nor duplicates the request.

The sweep’s own safety contract is unchanged: configured coordination freshness for valid rows, an additional stale-mtime gate for malformed or missing timestamps, a durable audit record before any deletion, and keeping the row when neither audit write succeeds. No dry-run mode or confirmation flag was added. agents reconcile is the documented mutating supervisor pass, and .harnery/active/ is a disposable projection that heals on the owning agent’s next tool call.