Debug a durable session runtime
Use this runbook when a session survives in the list but cannot attach, when a daemon restart closed an attach terminal, or when an update reports a worker problem.
Start with public, non-destructive inspection:
- Run
pohunek health --jsonand wait for the replacement daemon to become ready. Readiness follows worker discovery and reconciliation. - Run
pohunek session inspect <target> --json. - Record
runtime.state,runtime.worker_id,runtime.worker_instance_id, decimal-stringruntime.runtime_generation,runtime.last_connected_at, andruntime.loss_reason. - On the session’s host, run
pohunek service status --json. Each entry inworkersis one worker generation (session_id,generation,service_id,state,pid, and the proven executable and arguments). A session has at most one worker generation. To inspect the native job without changing it, derive its name from the namespace and generation: on Linuxsystemctl --user status pohunek-<ns>-worker-<session-id>-<generation>.service; on macOSlaunchctl print gui/$(id -u)/io.github.zajca.pohunek.<ns>.worker.<session-id>.<generation>(read its exit status:0loaded,113absent; the printed text is informational only). - Inspect daemon and worker structured logs under
~/.local/state/pohunek/logs/. Do not copy prompt, input, or raw terminal content into reports.pohunekd.jsonlplus seven rotations retain at most 256 MiB;pohunek-session-<session-id>.jsonlplus three rotations retain at most 16 MiB across all worker generations for that session.pohunek-hooks.jsonlplus one rotation (at most 2 MiB) is the hook failure trace of every session on the host (see below).
A session.new or fork that fails before any runtime exists reports one of
these codes instead of a runtime state:
worker_socket_path_invalid: the worker socket below the daemon’s runtime directory could never be bound. A worker first binds a staged name (.splus 16 hexadecimal characters) besidecontrol.sock, so the message names that longer path and the platform limit (107 bytes on Linux, 103 on macOS). The create is refused before anything is written or launched; restartpohunekdwith a shorterXDG_RUNTIME_DIR.worker_exited_before_ready: the worker job ended before it accepted connections. The daemon stops waiting as soon as the service manager shows the job ended, instead of waiting for the worker connect deadline, and retires the job. The message carries the worker’s exit status in--dev-subprocessmode and the job’s final state otherwise. In--dev-subprocessmode the worker’s stderr (its first 8 KiB) is logged topohunekd.jsonlas aworker.stderr.capturedevent; for a native job read the systemd journal or the launchd job log instead.
Every time the daemon classifies a session runtime conflict, lost,
reconnecting, or incompatible, at startup or while it runs, pohunekd
logs exactly one WARN with the message template session runtime is {runtime.state}: {reason} (the braces name the fields that carry the values)
and the fields session_id, worker_id (none when the record names no
worker), runtime.state, reason, and detail when there is evidence text,
for example the control-connection error behind worker_connection_lost.
Search ~/.local/state/pohunek/logs/pohunekd.jsonl for session runtime is
and filter by session_id. A re-check that finds the same state and reason
logs nothing more and emits no new event; a runtime whose worker connection
dropped logs worker_connection_lost (reconnecting) once and a second WARN
when it is classified again, for example lost with runtime_lost.
When pohunek session inspect <target> shows a subagent row that disagrees with
the provider, read the session’s worker log
(pohunek-session-<session-id>.jsonl). The worker logs every admitted subagent
claim it ignores as worker.subagent.claim.ignored and every claim it refuses
as worker.subagent.claim.refused, with subagent.action, subagent.id, the
reported and recorded sequences, and a subagent.reason code:
duplicate_start (a newer start for a child that is already running),
stop_without_start (a Claude stop with no recorded start, such as Claude’s
prompt-suggestion helper after every main turn), stale_sequence (a claim not
newer than the recorded one, or a start not newer than a remembered Claude
stop without a start) and not_running (a newer stop for a terminal child) at INFO,
and capacity (a start refused because the running-child limit is full) at
WARN. A child’s phase follows the highest-sequence claim the worker admitted
for it, for every provider: an ignored duplicate_start or not_running
claim newer than the record still advances the record’s journaled sequence,
so a later stale_sequence line for an older claim of the same child is the
expected outcome of reordered hooks, not lost state. An inadmissible claim, such as one from the wrong
process or runtime, is the worker.identity.rejected warning with
identity.operation subagent_start, subagent_stop or subagent_snapshot
(the last also for a Claude task list that breaks a bound).
At every Claude main-turn end the state hook sends the worker a snapshot of
Claude’s running background tasks. The worker logs one
worker.subagent.snapshot.applied at INFO per admitted snapshot, with
subagent.provider, subagent.sequence.reported, and the subagent.snapshot.*
fields tasks (tasks in the snapshot), hosts (whether the snapshot listed a
task that can host an unlisted agent, any type except shell, monitor and
MCP task), and the counts of the reporting process’s rows: listed (running
rows the snapshot listed), terminalized and deferred. Each row it completes
is a worker.subagent.reconciled INFO event with subagent.provider,
subagent.id, subagent.sequence.reported (the snapshot’s),
subagent.sequence.recorded (the row’s own sequence, which the completed row
keeps), and subagent.reason snapshot_absent: the snapshot omitted a running
row whose newest listing by the same reporter falls within its current run and
is older than this snapshot, or another omitted row while no hosting task was
listed and the reporter’s newest snapshot was older than this one and newer
than the row. The worker keeps only the reporter’s newest listing of each row
and its newest snapshot, so a snapshot older than that evidence, delivered out
of order, settles nothing from it. A snapshot never reopens a row and never changes a listed row’s
sequence: a listed running row stays running with no event and its own
SubagentStop completes it, and a listed terminal row stays terminal (Claude
often still lists a fast background agent whose stop already arrived; a
self-woken agent reopens through its own SubagentStart).
A non-zero deferred count names running rows of the reporting Claude process,
older than the snapshot, that it neither lists nor proves absent: rows whose
newest listing by this reporter is missing, outside their current run or not
older than this snapshot, while hosts is true or the reporter’s newest
snapshot is not both older than this one and newer than the row. For example,
a row started at 10 and listed by snapshot 30, then listed again by a late
snapshot 20, is deferred by a late empty snapshot 25: the newest listing (30)
and the newest snapshot (30) are not older than 25. The worker remembers the last snapshot of at most
SNAPSHOT_REPORTER_CAPACITY (16) reporting processes and forgets the least
recently reporting one first, so a forgotten process’s never-listed rows stay
deferred through the reporter’s next snapshot, which only records itself, and
settle at the earliest on the one after it. They are typically teammates, synchronous
children of a listed background agent, workflow agents, which Claude does not
list by agent id, or an agent that started in the main turn that just ended;
they stay running until their own SubagentStop or a later snapshot that
can settle them. A deferred count that persists across many turns is either
such a child that is still alive or one whose stop was missed; check the agent
in Claude first. A row whose stop was missed stays running until a snapshot
settles it, and a row whose reporter never ends another main turn stays
running until the runtime ends, when it becomes lost. Rows started by
another process, such as a second Claude in a shell session, are not counted.
The same snapshot also updates the session’s background work, which the session inspect detail shows beside the subagent rows. The worker logs the background snapshot at INFO with four events, none of which carries a label, program, command or background item id:
worker.background.applied: the snapshot replaced the background, withbackground.revision,background.sequence.reported,background.itemsandbackground.kinds, the item count per kind askind=countpairs (shell=2 subagent=1).worker.background.ignored: the snapshot did not replace it, withbackground.reasonand the reported sequence and item count.stale_sequence: a newer background snapshot was already applied, from any process, so the latest sequence wins.too_many_items: the list is overBACKGROUND_MAX_ITEMS(64) tasks, so the previous background stays.worker.background.fields_dropped: optional fields were dropped, withbackground.dropped.invalid(a label or program failed its check, or had the wrong JSON type) andbackground.dropped.kind_mismatch(a field the task’s kind does not carry). The rest of the snapshot applied.worker.background.cleared: the background was cleared, withbackground.reasonruntime_end,release(the process that reported the snapshot released its session, with a release reason other thanclear),agent_changed(an admitted identity report from another process than the snapshot’s reporter),session_start(an admittedstartuporresumereport from that reporter), orowner_exited(the reporting process is no longer running: exited, reaped, a zombie, or its pid now names another process; the inspect path checks it, so the clear follows the exit within one daemon poll, about 100 ms).
A release or session-start clear also raises the sequence mark, so a claim the
reporter began before that clear is stale_sequence if it arrives late. An
owner_exited clear does not raise it. The background mark is clamped to the worker’s CLOCK_MONOTONIC reading, so a
claim with a sequence ahead of that clock (for example from a runtime package
with its own clock) can apply, but it cannot lock the background view: the next
turn end replaces it.
The snapshot belongs to the process that reported it. A nested Claude inside
the session reports its own snapshot, which replaces the outer one, and its
release clears it. The outer agent’s next turn end restores its own snapshot,
so an empty background while the outer agent runs no turn is expected after a
nested owner has exited. A clear release or a compact session of the owner
keeps the background, and so does a clear session that follows a clear
release. When a turn that listed tasks is missing from the background, look
for one of these events in the log, or for a Claude main turn that never
ended, because the snapshot is sent at the end of each turn.
When a Claude main turn ends without a snapshot.applied event, check in this
order: a worker.identity.rejected warning with identity.operation
subagent_snapshot (the worker refused the snapshot, for example a task list
over a bound); the hook-side cases in which no snapshot is sent (a missing or
invalid task list, more than 256 tasks, an input over 1 MiB, or a Stop with a
non-empty agent_id; each is a no_snapshot or input_* line in the hook
failure trace described below); a Claude hook asset older than integration version 12,
which has no Stop state hook (pohunek integration status --agent claude
reports it outdated; reinstall it); and a session worker started by a release
without the snapshot claim, which refuses it.
A claim, snapshot or identity report that never reached the worker log (no
claim.ignored, claim.refused, snapshot.applied or identity.rejected
line) was dropped by the state hook itself, which always exits 0 so it never
breaks the provider. The hook records why in the hook failure trace,
~/.local/state/pohunek/logs/pohunek-hooks.jsonl (the pohunek log directory;
the worker passes the path to every managed child as POHUNEK_HOOK_TRACE_PATH
and the rotation size as POHUNEK_HOOK_TRACE_MAX_BYTES). One file serves
every session of the host: filter it by session, for example
jq -c 'select(.session == "<session-id>")' ~/.local/state/pohunek/logs/pohunek-hooks.jsonl,
and match ts against the worker log. The file is owner-private (0600) and
rotates at 1 MiB into a single pohunek-hooks.jsonl.1, so the trace holds at
most 2 MiB; read both files for the full window. Each line is one JSON object
of at most 512 bytes with ts (RFC 3339, UTC), session (the Pohunek session
id), provider, action (the hook action, such as subagent-start or
stop), reason and an optional detail (a fixed short code, often an errno
name). A line never carries the provider payload, ids taken from the input
(agent, task or native session ids), task data, paths or environment values.
The reason codes:
input_too_large: the input exceeded 64 KiB (1 MiB for the Claudestopaction);input_invalid:detailempty,not_json,not_utf8,too_deep,not_object, a read errno,agent_id_missing(a subagent action without an agent id) orsession_id_missing(asessionaction without a native session id);no_snapshot: the Claudestopaction sent no snapshot, withdetailsubagent_stop(aStopwith a non-emptyagent_id),tasks_missing,tasks_invalidortasks_over_bound(more than 256 tasks);action_invalid: a defensive code the managed hook never writes, because its shell part exits on an unknown action before the session handshake;worker_socket_missing(detailunset: no worker socket in the environment;ENOENT: no socket atPOHUNEK_WORKER_SOCKET_PATH),worker_instance_missing,agent_pid_invalidandagent_identity_unavailable(the agent’s process start identity could not be read): the hook could not build or route the claim;worker_unreachable(connect failed,detailthe errno),worker_timeout(no answer within the 0.5 s socket timeout),worker_closed(no usable answer line;detailan errno,emptyormalformed);worker_refused: the worker answered withoutok: true; match it with theworker.identity.rejectedorworker.subagent.claim.refusedline of the same moment in the worker log;daemon_socket_missing,daemon_unreachable,daemon_timeout,daemon_closed,daemon_refused(the daemon answered an error) anddaemon_protocol_invalid(no worker socket and no usablePOHUNEK_PROTOCOL_VERSION): the daemon-socket fallback of thesessionaction (and the Claudereleaseaction) failed; match them inpohunekd.jsonl.
Lines carry no run id: a session or release report the worker did not
accept and the daemon fallback then delivered leaves only the worker_* line,
and so does one for which no daemon route was configured, so check
pohunekd.jsonl around the same ts before concluding either. Only exits
after the hook established that it runs in a Pohunek session (POHUNEK_ENV=1
and a session id) and started python3 are traced. An empty trace for a
session whose claims are missing therefore means the hook did not run there,
python3 is not on the hook’s PATH (no state hook of that session reports
anything then), the trace lock stayed contended (the hook tries it at most
5 times, 5 ms apart, then drops that run’s lines), the trace variables were
missing (a session worker of a release without the trace sets neither), or the
installed hook is older than integration version 13 (pohunek integration status reports it outdated). Tracing never changes a hook’s exit code or
output, and a report delivered on its first route writes no line.
Interpret runtime states as follows:
live: the daemon has the current worker controller lease. Attach should use the existing runtime.reconnecting: a known worker is still being validated or adopted. Do not start native recovery or restart the worker. Reasonruntime_supervision_unavailablemeans the service manager (systemd user manager or launchd) could not be inspected, or could not retire the job of a worker generation that is proven ended; nothing was killed and reconciliation retries on its own (after 1 s, doubling to at most 60 s). A session whose removal was interrupted (for example the daemon stopped midsession rm) also shows this reason while its removal waits for the job to be retired or its cleanup to succeed; the retry then finishes the removal and the session disappears. A session whose stop was interrupted shows this reason too while its stop replay fails (its worker answers but returns no terminal outcome yet); the retry replays the stop until the worker commits it, and the session then readsstopped. Reasoncreate_compensation_pendingmarks asession newthat failed after binding a worktree and whose runtime is proven ended, but whose checkout, worktree binding, or create record could not be removed yet (typically a worktree locked withgit worktree lock, or a store write failure). Nothing of it runs; the same retry repeats the removal, so fix the cause (for examplegit worktree unlock <path>) and the session disappears withsession_removedon the next pass, without a daemon restart.terminal: the worker observed child exit and the logical outcome is being retained or has been imported.lost: no live PTY generation remains. The logical record is intentionally retained; explicit native recovery is possible only with a valid launch recovery reference. Reasonruntime_lostmeans the worker job ended while its journal still said live, and the ownership-marker sweep removed that generation’s leftover processes.runtime_lost_cleanup_unconfirmedmeans the same, but the sweep could not confirm that every marked process ended, for example because a worker child carries the session’sPOHUNEK_SESSION_IDandPOHUNEK_WORKER_IDbut noPOHUNEK_WORKER_INSTANCE_ID(a child of a worker before 0.33.1; a daemon-run lifecycle hook has no worker marker and is ignored; the sweep never signals it, and thesession.runtime.sweep_unmarkedWARN inpohunekd.jsonlnames it by pid);session.resumeretries the marker sweep against the exact journaled runtime; it refuses withruntime_supervision_ambiguousand a recovery hint until cleanup is confirmed. Inspect and end any remaining marked processes, then retry. If the recorded runtime cannot be matched to its journal, recovery remains refused. A lost session without a journal for its generation reportsworker_unavailable; its job is retired, but no process is swept, so checkpsfor leftovers. A session is reportedlostonly after the service manager retired its job; until then it staysreconnectingwithruntime_supervision_unavailable. The same classification runs when a worker dies while the daemon is running: a proven crash is reportedlostimmediately, and a worker that stays unreachable is classified after the worker connect deadline (conflictwhile its job still runs,reconnectingwhile the manager is unavailable). A socket that accepts but never answers counts as unreachable: each connect attempt ends at the same deadline.- A stale
control.sockthat refuses connections is only a failed endpoint probe. If the recorded generation’s native job is absent and its journaled worker is proven gone, the daemon sweeps processes bearing that runtime’s ownership marker before reportinglostand allowingsession resume. A still-present job with a silent socket staysconflict; an unreadable journal stays ambiguous, while an unconfirmed sweep reportslost/runtime_lost_cleanup_unconfirmedand requires process inspection before recovery. Inspect the session state and service status together rather than treating the socket pathname or inventory reason as proof by itself.
A terminal or lost hook runtime without a trusted native reference refuses
session.resume before launching anything. runtime/native_identity_missing
means its own generation journal has no native claim;
runtime/native_identity_unverified means it has a claim but no verified
launch-process binding. runtime/native_identity_evidence_unavailable means
the durable session record or exact generation journal cannot be read, the
journal is absent or ambiguous, or its worker id or instance does not match the
record. Preserve both records for diagnosis. A foreign process claim keeps the
existing typed process-identity reason and cannot replace the stored native
reference.
runtime/native_identity_uncertain means the generation journal carries a
newer conversation switch that this daemon cannot verify: the journal names a
different conversation than the persisted one and no newer accepted report
covers it. Native recovery is unavailable with the older verified target
intact, because the daemon cannot tell which conversation the agent last used.
Resume the newer conversation in a fresh session with
<agent> --resume <resolved-id> or clear the journal discrepancy in the
session’s own generation before retrying; never delete the journal.
For Claude, runtime/agent_native_reference_missing on resume or fork means
the selected conversation’s regular transcript file could not be verified
under the effective Claude config home. Restore or inspect the transcript tree
before retrying; the refused operation creates no new worker or fork child.
runtime/agent_native_store_relocated means the agent would keep its
conversations outside the store its recovery checks (for Pi: --session-dir
in the profile args, a sessionDir setting, or a relative
PI_CODING_AGENT_SESSION_DIR); nothing was launched. On session new, follow
the recover hint: set the named variable in the profile [env] to the
absolute directory, or remove the argument. On resume or fork the launch had
passed the same refusal, so something changed since: a setting appeared, or
the variable the launch had is gone (a daemon restart without it, an accepted
profile edit). Undo that change: remove the setting (or make the settings file
readable JSON again), or restore the variable to the absolute path the session
was launched with and pass --accept-profile-change; pointing the variable at
a new directory does not find the conversation.
daemon/native_store_check_task_panicked on session new, resume or fork
means the native reference check itself crashed (on resume and fork it covers
the whole check, Claude’s transcript check included); nothing was launched,
and the daemon log has the panic.
conflict: multiple or mismatched identities claim the session. Never stop, unlink, or kill either candidate by hand, and the daemon never kills one automatically. Preserve the job, journal, and socket evidence for diagnosis;pohunek session stop <id>is the supported exit when it can prove the recorded identity (see below).runtime_supervision_ambiguousmeans the native job is present but its worker socket does not answer and its journal is not terminal, or that its journals or worker socket directory could not be read at all. It is not permanent: the daemon re-checks it in the background (after 1 s, doubling to at most 60 s) without killing anything, adopts the worker when it answers again, and reportslostwithruntime_lostonce the job ends and the journaled worker process is gone. A create that was pending when the daemon restarted also shows this reason while its job is watched until the worker initialization deadline; the job is then retired and the unfinished session disappears.runtime_identity_mismatchmeans the job’s definition or process does not match the recorded executable, session, or generation. A conflict whose record names a worker generation is watched whatever its reason, while a worker still answers or its job is supervised: adoption reasons (launch_identity_*,active_identity_*, subagent snapshot rejections,multiple_worker_candidates),runtime_identity_mismatch, andruntime_supervision_ambiguous. The first re-check runs after 1 s and the delay doubles to at most 60 s; a session that newly joins the watch is re-checked after 1 s again, whatever the delay of the others has grown to. Each pass classifies from fresh evidence and never kills: while the evidence holds the session staysconflict(no new event or log), a worker that becomes adoptable is adoptedlive, and once the worker is gone and its job has ended the session becomeslostwithruntime_lost(runtime_lost_cleanup_unconfirmedwhen the marked-process sweep is unconfirmed). Every pass probes all worker sockets, not only the session’s own: a worker is adopted only while no other socket claims the same session, so a shadow worker keeps the conflict (multiple_worker_candidates) until it is gone. A worker that exits in an orderly way and leaves a terminal journal is imported asterminal, notlost. Not re-checked, and needing the operator: a record that disagrees with its stored resume binding (resume_binding_*reasons), a journal-only conflict (worker_journal_identity_mismatch, a journal naming another worker than the record), and a record naming no worker generation. A live worker started by the previous release is adoptedliveafter the store migration. Its reported launch identity is checked against the stored native recovery binding and fails closed only on a real contradiction: another provider than the session’s runtime (launch_identity_provider_mismatch), a reference kind that contradicts the binding’s launch spec or is stored under the other kind when the binding has no launch spec (launch_identity_reference_kind_mismatch), or a reference that differs from the stored one (launch_identity_reference_mismatch). A binding the migration could not complete (no launch spec, for example a deleted profile or a profile without native resume; the session carries anative_recoverywarning) does not block adoption: the worker is adoptedliveandscreen,input, andstopwork; only resume and fork stay unavailable.pohunek session stop <id>on aconflictstops the supervised job by the identity the record names instead of refusing. Before anything is written it proves that the journal of the recorded generation names the recorded worker and that the job the service manager shows under that generation’s service id is that worker’s (executable,--session-idand--worker-generationarguments, and main process match the journal). It refuses withsession_runtime_conflictwhen the record names no generation or worker,runtime_identity_mismatchwhen no journal of the generation names the recorded worker or the job is not that worker’s,runtime_supervision_ambiguouswhen the journals are unreadable or no job holds the recorded worker process while it still runs (nothing could stop it), andruntime_supervision_unavailablewhen the job cannot be inspected. After the proof it persists the stop intent, retires the job of the exact generation (which ends the worker and its child), requires every worker journaled for the generation to be gone, and sweeps the processes carrying the session’s runtime ownership markers. A sweep that cannot confirm every marked process exited refuses withruntime_supervision_ambiguousand arecoverhint to inspect and end the listed processes and retry the stop; a stop never accepts unconfirmed cleanup (--accept-unconfirmed-cleanupbelongs tosession rmonly). On success the result isstopped: true, the session state isstopped, the runtimeterminal, and the stop transaction committed. A refusal leaves the record exactly as it was (nodesired_state = stopped, norequestedstop transaction); a retirement the supervisor reports as failed rolls the intent back only when the worker is proven alive beside a live job. Every other failure (a worker still running after the retirement, an unconfirmed marked-process sweep, a retirement error with the worker gone or unverifiable) keeps the persisted stop intent, and the watch (or the next daemon start) finishes the stop asstoppedonce the worker is gone and the marked-process sweep is confirmed; until then the session staysconflictwithruntime_supervision_ambiguous. A stop whose record names a runtime (worker_instance_id) that the journal does not is refused withruntime_identity_mismatchbefore anything is written, and only journal-proven runtimes are swept. A persisted stop or removal intent is finished even over aresume_binding_*conflict, which quarantines adoption only, at startup and in the watch. A pending stop ends only once the marked-process sweep is confirmed, also when the worker left a terminal journal. Every watch pass judges the persisted resume binding again, so a quarantine whose classification could not be written stays in force, and an adoption or classification whose write fails keeps the session pending (listed asruntime_supervision_unavailable, with its one WARN, if it had no entry); the next pass writes the classification without logging it again. A committed stop is never turned intolostby a restart, although the retired job leaves no terminal journal (aresume_binding_*conflict is never re-checked).session stopon alost,reconnecting, orincompatiblesession is refused before anything is written withsession_runtime_lost,session_runtime_reconnecting, orworker_protocol_incompatible.pohunek session rm <id>removes aruntime_supervision_ambiguoussession: it retires the worker job of the exact generation the record names through the service manager, which stops that worker and its child, requires every worker the session’s journals record for that generation to be gone, and then deletes the logical record. For another conflict reason, it first applies the same journal, job, and process identity proof assession stop; a proven worker is stopped before its record is removed. It refuses, keeping the record, an unproven worker (runtime_identity_mismatchorruntime_supervision_ambiguous), a record that names no worker generation (session_runtime_conflict), a retirement the service manager cannot complete (runtime_supervision_unavailable), a still-running worker journaled under another generation (runtime_identity_mismatch), and unreadable session journals or a journaled worker that still runs after the retirement, for example outside its job (runtime_supervision_ambiguous). Stop such a worker by hand after preserving the evidence, then retry the removal. Every removal also sweeps the processes carrying the session’s runtime ownership markers and fails withruntime_supervision_ambiguous, keeping the session and its removal intent, while that sweep cannot confirm every marked process exited (for example one whose environment cannot be read); look for leftover processes of the session withps, stop them, and retry. On macOS 27 the kernel withholds the environment of Apple platform binaries such as/bin/shand/bin/zsh; the sweep decides those by fork lineage from the lost worker’s journaled creation number (worker_spawn_id, used only when the journal was written in the current boot). A descendant of the worker (a/bin/zshsession root and its helpers) is reaped, and a process created before the worker is ignored. A hidden process the kernel puts in other coalitions than the worker’s (the worker’s journaledworker_coalitions, same boot rule), such as a launchd job, a system helper or another session’s agent started after the worker, is ignored too. An orphan that re-executed after its creator died, and a process whose creator exited after the worker started, stay unreadable candidates, sosession rmmay still need--accept-unconfirmed-cleanupafter you judge the listed processes. A runtime whose worker journaled no creation number (a worker started by the previous release) has no lineage or coalition proof, so every hidden process is a candidate. When unreadable same-user processes are the only obstacle, the error message names each aspid N (start S, commandname)(at most eight, thenand N more) andrecoversays to inspect them, end the ones that belong to the session, and retry; a refusal for another reason lists no processes. If you have inspected the listed processes and accept that they may keep running,pohunek session rm <id> --accept-unconfirmed-cleanupremoves the session anyway for that one call (it uses thesession.remove_accepting_unconfirmedmethod, so an older daemon answersmethod_not_found): the processes are not signalled, and the result lists them asaccepted_unconfirmed_processes(pid N (start S, commandname)in human output; the command name is escaped because the process chooses it; more than 64 candidates refuse the removal before anything is deleted). One that does carry the runtime marker keeps running unsupervised after the worktree, logs, and record are gone. Any other unconfirmed reason still refuses, and reconciliation and the retention sweep never have the consent. A removal finished by reconciliation after a daemon restart shows thisconflictreason while it retries on its own.incompatible: the worker is alive but the daemon does not manage it.worker_protocol_incompatiblemeans private protocol negotiation failed;worker_marker_legacymeans its journal names a worker release before 0.33.1 (or none), whose children carry noPOHUNEK_WORKER_INSTANCE_ID, so no sweep could attribute them. Aworker_protocol_incompatibleworker can be left alive for a daemon release that manages it; aworker_marker_legacyworker comes from a release outside the supported upgrade window and its end is not noticed, so remove it.pohunek session rm <id>retires such a worker through the service manager the same way as an ambiguousconflictone (refused withworker_protocol_incompatiblewhen the record names no worker generation). Forworker_marker_legacy,session stop, input, resize and attach are refused withworker_protocol_incompatible, amsgnaming the reason and arecoverhint; on removal the runtime is lost, and a child that left the worker’s job keeps the removal refused until it is ended by hand.
Workers live only as long as the login session. Closing a terminal or locking
the screen is safe. Logging out or rebooting ends every worker job; after the
next login the daemon reports those sessions lost with runtime_lost and
never restarts them. Use pohunek session resume <id> to recover a session
with a native recovery reference into a new worker generation.
Restarting the daemon job is safe for workers, but it closes current public connections. On Linux:
systemctl --user restart pohunek-<ns>-daemon.serviceOn macOS, the standard launchctl command
launchctl kickstart -k gui/$(id -u)/io.github.zajca.pohunek.<ns>.daemon
restarts the agent; it is not exercised by Pohunek’s tests. The namespace <ns> is the namespace field of
pohunek service status --json.
After health returns, the same worker_id, worker_instance_id, worker generation
and PID, and agent child PID demonstrate reconnection. A new runtime id means explicit
recovery or a defect; it is not normal daemon restart behavior.
If a lifecycle or runtime operation returns
runtime/session_runtime_commit_stale, it lost a concurrent durable runtime
commit. Its candidate was not published to the session registry or event
stream. Run pohunek session inspect <target> --json again and treat the
returned runtime identity, decimal generation, and state as authoritative.
Retry only when the operation is still valid from that current runtime; do not
reuse the losing operation’s runtime coordinates.
Do not interpret this error as a failed atomic rename or uncertain disk commit.
When rename succeeds but the parent-directory durability sync fails, the daemon
keeps the visible commit applied and writes a sanitized internal warning. It
does not return session_runtime_commit_stale for that condition.
For non-destructive terminal diagnosis, start without a historical cursor:
pohunek session screen <target> --jsonpohunek session detection <target> --jsonpohunek session read <target> --source recent --lines 100 --jsonpohunek session output <target> --max-bytes 65536 --jsonUse session detection when activity classification is surprising. Its
supported_regions array identifies the engine’s accepted region kinds, while
previews shows the exact current text supplied to each region required by the
active manifest. Empty text is meaningful: for example,
last_non_empty_above_prompt_box is empty without a complete prompt box. The
preview waits for accepted agent/configuration changes before rendering.
session_detection_response_too_large means the complete diagnostic would
exceed the public response budget; reduce the manifest’s number or size of
parameterized regions before retrying.
Current workers expose rendered visible rows rather than scrollback or
soft-wrap metadata. For recent, recent_unwrapped, and detection, verify the
truthful source_used: "visible" fallback and inspect alternate_screen before
interpreting the tail as ordinary main-screen history. ANSI reads are reserved
and fail with session_read_ansi_unavailable.
Carry the returned worker_instance_id, runtime_generation, and next_offset into a
continued output read or session wait. session_runtime_changed means the
cursor belongs to an older PTY generation; restart from a fresh screen/tail.
session_terminal_unavailable means the managed worker cannot currently serve
the terminal; session_has_no_managed_terminal identifies an external observer
entry. worker_feature_unavailable means a previous-version worker remains
usable for lifecycle/attach but cannot provide control-plane observation.
session_waiter_limit_reached is temporary bounded resource pressure; wait no
longer than eight seconds and retry instead of opening unbounded parallel waits.
For session.wait_turn the same code means the separate turn waiter pool (one
global slot plus one slot per target) is full; end an older turn wait before
starting another. A cursor_reset result follows a daemon restart or a cursor
outside what the in-memory turn journal retains, also during a wait: continue from
the returned cursor and read the progress, because events in between were not
reported. A turn event arrives no earlier than attention_debounce_secs (5
seconds by default) after the last stop or attention report, and later when
reports repeat. A turn_completed whose window ends while the session is
working is a stale stop and never fires; a short turn still fires once after
its stop. A notification.create from a producer without a hook schema, or for
a session this daemon does not hold, never produces a turn event.
pohunek session wait <target>... without legacy flags is the CLI form of
this method: a cli_usage failure saying the targets must be on one host means
the targets name different hosts (run one wait per host), and one saying the
legacy wait takes exactly one target means a legacy flag such as --state or
--timeout-ms selected the 8-second single-session wait.
Do not restart, stop, or boot out a worker job by hand. A worker job has no
restart policy (systemd Restart=no, launchd without KeepAlive) because once
a worker exits its PTY cannot be recreated. Stopping the job ends that
generation’s processes and therefore loses the runtime. Use
pohunek session stop <session-id> for an intentional session stop.
For a Hermes runtime, first run pohunek host inspect local --json. The daemon
serves hermes only from the official Hermes package: without an installed,
enabled and selected pohunek.runtime.hermes package the inventory has no
hermes entry, and pohunek session new --agent hermes answers
agent_profile_not_found with a hint that names pohunek plugin install runtime/packages/hermes.tar.zst --catalog runtime/runtime-catalog.json from an
extracted daemon archive. With the package installed, the runtime
must identify agent_base: "hermes", version: "0.20.0", and
supported: true before starting a new Hermes session. A missing executable
has no version-policy fields, and a launch answers agent_binary_missing; an
installed but unparseable or wrong version is supported: false, and a launch
answers agent_runtime_unsupported. Do not diagnose by opening, copying, or editing Hermes
state.db or the operator’s real HERMES_HOME. The compatibility commands use
their own temporary homes.
For the first worker-aware installation, let all legacy sessions finish or stop
them explicitly. The archive installer runs under
pohunek service lock -- <installer>, so it refuses before any change while
another service command holds the transaction lock (service_transaction_in_progress) and no such command can
start during its run. It then refuses before any change whenever
pohunek service check --json fails: an unusable HOME or XDG root, an invalid
prefix, or a prefix, unit, config, state, or runtime directory reached through
a symlink or not private to the user, each reported with the error code the
final install or upgrade would fail with. It starts a stopped legacy daemon so
the migration snapshot is always taken, moves the legacy daemon’s control
socket aside, runs migration preflight --socket <moved-socket> there, and
lists live sessions that lack durable runtime metadata to refuse
replacement. Sessions with a runtime binding are already worker-owned, are
excluded from this one-time guard, and survive the daemon restart.
packaging/install-daemon.sh --accept-runtime-loss is destructive consent:
existing legacy PTYs cannot be transferred into workers. Use it only after
recording the affected ids and accepting that shell and uncaptured agent
sessions cannot be reconstructed. The same installer refuses to retire an
older template-unit install while any pohunek-session@<id>.service worker
outside the inactive state survives its post-stop re-check; stop those
sessions first. The flag also gates pohunek service upgrade of an installed
service, whose live-session preflight is described in
update after release.
A worker-aware daemon started over a legacy store without that preflight (for
example after replacing the binary by hand) finds legacy resume bindings but no
logical record and no manifest. It starts anyway and imports nothing: pohunek session list shows none of those sessions, and pohunek session runtime-inventory
lists each binding as orphaned with reason migration_manifest_missing
(the daemon log carries reconcile.migration.manifest_missing). The bindings
stay on disk, and while they are unimported pohunek session new and session fork fail with migration_manifest_missing before writing anything, because
a first logical record would make a later manifest unimportable. To recover
them, reinstall the legacy release, then rerun the worker-aware installer so
its pohunek migration preflight snapshots them; the worker-aware daemon it
starts imports the manifest, and session creation works again. Once any
logical record exists, a later manifest is archived without being imported.