0077: Converge lifecycle signals on one session finalizer
Status: accepted (2026-08-17) · Scope: product tier (core/agents, core/events/v2, adapters, web)
Context
Section titled “Context”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.
Decision
Section titled “Decision”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_endforharn agents endand theharn-endskill;approved_verified_archivefor a durable adapter archive observation after its cancellation grace period; andpolicy_*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.
Evidence eligibility
Section titled “Evidence eligibility”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.
Consequences
Section titled “Consequences”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.
Rejected alternatives
Section titled “Rejected alternatives”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.