Skip to main content

Module driver

Module driver 

Source
Expand description

@arch:layer(kg_store) @arch:role(substrate) @arch:see(.yah/docs/working/yah-task-runs.md)

PTY subprocess driver — spawn commands, capture output as append-only chunks, handle SIGTERM/SIGKILL with a grace period, and mark stale Running runs as Lost when the daemon restarts.

§Tier 2 side-channel (yah-log shims)

When SpawnOpts::log_fd_enabled is true (the default), the driver creates a named pipe (FIFO) and exports two env vars into the child:

  • YAH_TASK_RUN — the TaskRunId as a hyphenated UUID string.
  • YAH_LOG_PIPE — absolute path to the FIFO.

The child opens YAH_LOG_PIPE for writing and emits JSON-lines. The driver reads those lines in a background thread and stores them as [EventSource::Shim] events.

Why FIFO instead of a raw fd? portable-pty calls close_random_fds() in its pre_exec hook, closing every fd ≥ 3 before exec. A raw-pipe write fd is always ≥ 3 and would be closed before the child could use it. Opening a FIFO by path requires no fd inheritance.

Wire format — one JSON object per line:

{"level":"info","target":"myapp::module","msg":"text","fields":{"key":"val"}}

Optional shim-identity keys: "_lib" (string), "_lib_ver" (string). Unknown keys in fields pass through as freeform JSON.

The driver holds the write end of the FIFO open until the run lifecycle task completes, which triggers EOF for the receiver thread. The FIFO file is deleted after the receiver thread drains the last line.

On non-Unix platforms YAH_TASK_RUN and YAH_LOG_PIPE are not exported. Shim libraries must treat absent YAH_TASK_RUN as “not inside a TaskRun”.

@yah:ticket(R617-F6, “Reattach-by-run_id replaces Lost-on-disappear for origin=terminal shells”) @yah:status(review) @yah:assignee(agent:bundle-anthropic-ashguard) @yah:at(2026-07-24T01:26:41Z) @yah:phase(P3) @yah:parent(R617) @arch:see(.yah/docs/working/W280-durable-terminal-sessions.md) @yah:depends_on(R617-F13) @yah:handoff(“DELIVERED. Verified: cd oss/qed && cargo test -p task-runs --lib 243/243 (was 237 — 6 new); cargo test -p kg-daemon --lib shell_vt 9/9; cargo test -p yah --lib r617 9/9; cargo test -p desktop --lib 357 pass / 2 fail, both pre-existing and in files this ticket does not touch (agent.rs rules-view expects 12 rows and a peer’s approval-rule change makes 19; agent_process reader-finished is a known timing flake).”) @yah:handoff(“THE TICKET’S OWN FRAMING WAS WRONG ABOUT THE MECHANISM, and the correction is the design. @yah:next said to ‘re-adopt’ a live shell by ‘control channel rebuilt, reader thread restarted against the surviving PTY’. That is not possible and never was: you cannot re-open another process’s PTY master fd. The real defect is narrower and worse — a driver was tombstoning runs IT DID NOT OWN. .yah/db/task-runs.turso has several writers (desktop, the R617-F13 shell host, one CampService per MCP sidecar), and TaskDriver::new assumed any leftover Running row must be its own predecessor’s corpse. So every attach marked some other LIVE process’s shell Lost, and that shell kept producing output under a status saying it was dead. The fix is therefore ‘do not tombstone what you do not own’, not ‘reattach’. Actual PTY reattach is unnecessary once F13 puts the PTY in a process that outlives the desktop.”) @yah:handoff(“HOW OWNERSHIP IS KNOWN: new TaskRunMeta::host_pid — the pid of the process whose driver spawned the run, NOT the child’s. Stamped by spawn_run at INSERT, before the child exists, so a crash between insert and spawn still leaves the row attributable. Store column added by the same idempotent ALTER TABLE ... ADD COLUMN pattern origin used, and row_to_meta reads index 15 with .ok().flatten() so a DB with no such column reads None rather than erroring.”) @yah:handoff(“THE SEAM IS ORIGIN-AGNOSTIC, per this ticket’s gotcha. New task_runs::StaleRunPolicy in oss/qed/crates/task-runs/src/driver.rs: LostOnDisappear (the default — TaskDriver::new and with_channels behave exactly as before, so no existing embedder changed) and AdoptLiveHosts { origins: Vec<String> }, which spares a leftover run only when its host_pid names a process that still exists. The crate decides on OWNERSHIP and takes the origin list as data — it never learns what ‘terminal’ means. New TaskDriver::with_config is the constructor that takes it.”) @yah:handoff(“yah side: crates/yah/kg-daemon/src/service.rs::open_task_store now passes AdoptLiveHosts { origins: [ORIGIN_TERMINAL] }. Also replaced the magic string — new kg_daemon::shell_vt::ORIGIN_TERMINAL now backs the two live origin == \"terminal\" gates in shell_vt.rs plus the policy, so the VT-parsing gate and the tombstone-exemption gate cannot drift apart by a typo. The constant lives on the yah side, NOT in task-runs, precisely to keep the crate generic.”) @yah:handoff(“Also stamped at app/yah/desktop/src/terminal.rs:519 — the desktop-local PTY path (terminal_open_local’s scrollback mint) owns its own PTYs, so those rows carry the desktop’s pid. Without it the shell host’s driver would tombstone a live desktop-local session on attach, which is the same bug pointing the other way.”) @yah:handoff(“PID REUSE is the honest weakness and is why the policy is opt-in and origin-narrowed. kill(pid, 0) (EPERM counts as alive — the process exists, it is just not ours to signal) can read a recycled pid as the original owner. The failure mode of a false ‘alive’ is one run left Running until something closes it; the false ‘dead’ this replaces kills a live session’s status. Strictly the better direction for an interactive shell, and the exposure is bounded to origins the embedder opted in. Non-unix has no kill(2), so host_process_alive reports false there and the platform keeps the old behaviour rather than stranding runs forever.”) @yah:handoff(“SIX NEW TESTS, each pinned to a failure rather than a code path: a live-owner terminal run survives a new driver (the ticket’s whole point); a run whose owner pid was spawned and reaped in-test IS tombstoned (a crashed host must not leave zombie tiles); origin-less and non-matching origins are tombstoned even with a live owner (an in-flight cargo build whose driver is gone has nobody left to record its exit); an unattributed row (pre-migration) is tombstoned; TaskDriver::new still tombstones unconditionally (no silent behaviour change for existing embedders); and spawn_run stamps this process — the policy is worthless if rows arrive unattributed.”) @yah:verify(“cd oss/qed && cargo test -p task-runs –lib # 243/243, 6 new under driver::tests”) @yah:verify(“cargo test -p kg-daemon –lib shell_vt # 9/9”) @yah:verify(“cargo test -p yah –lib r617 # 9/9”) @yah:verify(“Manual (needs a desktop rebuild): open a shell, run sleep 300, quit and relaunch the desktop — the run is still Running, not Lost”) @yah:verify(“sqlite3 .yah/db/task-runs.turso "select id, origin, host_pid, status from runs where status=‘running’;" # every live row names a pid that ps shows”) @yah:gotcha(“This is an oss/qed crate — changes land in-tree under oss/qed/crates/task-runs and flow outward via scripts/export-oss.sh. The seam was kept origin-agnostic (StaleRunPolicy decides on host_pid, takes origins as data); the one yah-ism, ORIGIN_TERMINAL, lives in crates/yah/kg-daemon/src/shell_vt.rs instead.”) @yah:gotcha(“host_pid is NOT on the wire. rpc::WireRunMeta does not carry it, so a client cannot ask ‘is this run’s owner alive’. Nothing needs it today — the policy runs entirely daemon-side — but R617-F7 should check whether reattaching tiles want it before adding a second liveness notion of their own.”) @yah:gotcha(“pid reuse can make a dead owner read alive, leaving a run Running with nobody driving it. Bounded on purpose (opt-in + origin-narrowed) and strictly safer than the false-dead it replaces, but it is a real edge: if zombie terminal rows ever accumulate, this is why.”) @yah:gotcha(“TaskRunMeta gained a required field, so every struct-literal construction site had to be updated (velveteen-exec x4, scryer, task-runs fixtures, kg-daemon fixtures, desktop/terminal.rs x2). A new construction site added by anyone else will fail to compile until they pick a value — which is the intended forcing function: a run with no recorded owner is a run the policy has to tombstone.”)

@yah:ticket(R617-B9, “Pre-existing: task-runs log_pipe_events_land_in_store never completes (233 pass / 1 fail)”) @yah:status(review) @yah:assignee(agent:bundle-anthropic-ashguard) @yah:at(2026-07-22T19:50:25Z) @yah:phase(P1) @yah:parent(R617) @yah:handoff(“Root cause: not the FIFO, not the PTY. The whole pipeline completed correctly every time (child wrote the JSON line, receiver drained it, reader hit EOF, child.wait returned 0) — but the lifecycle’s terminal store.update_status returned Sql(Busy(\"database is locked\")) and run_lifecycle swallowed it with let _ =, so the run stayed Running forever and the 20s poll deadline blew. A live run has three concurrent turso writers (PTY chunk appends, shim-FIFO event appends, lifecycle status) on independent connections with no busy handling at all.”) @yah:handoff(“Fix in oss/qed/crates/task-runs/src/store.rs: (1) conn() now sets busy_timeout(5s) on every connection; (2) new exec_retry() wraps writes in an outer exponential-backoff retry on the Busy/BusySnapshot class, because turso caps its internal backoff and then hands Busy back; (3) insert_run / update_status / update_beholder_status / append_chunk / append_event all routed through it.”) @yah:handoff(“driver.rs run_lifecycle no longer swallows the terminal status write — a genuine failure after retries now prints [yah task-runs] failed to record terminal status for run <id>, matching the crate’s existing eprintln convention.”) @yah:handoff(“New regression test store.rs::concurrent_writers_do_not_lose_the_terminal_status — two background tasks hammer append_chunk/append_event while update_status lands. Verified it has teeth: with busy_timeout and the retry disabled it fails 3/3 with the exact Busy(\"database is locked\"); with them it passes 5/5.”) @yah:verify(“cd oss/qed && cargo test -p task-runs –lib — 237 passed / 0 failed (was 235 pass / 1 fail)”) @yah:verify(“log_pipe_events_land_in_store run 8x sequentially: 8/8 green in ~0.58s each. Before the fix the same loop was 11/12 red at the 20s timeout.”)

@yah:ticket(R652-T6, “Login shell: when cmd is the resolved shell, exec it directly (not sh -c) with -l”) @yah:at(2026-08-02T00:03:08Z) @yah:status(review) @yah:assignee(agent:bundle-ollama-cloud-boulder) @yah:phase(P1) @yah:parent(R652) @yah:handoff(“Login shells now exec directly with -l instead of going through sh -c. SpawnOpts (oss/qed/crates/task-runs/src/driver.rs) gained argv: Option<Vec<String>>: when set, spawn_run builds the CommandBuilder from that argv verbatim instead of wrapping cmd in sh -c. camp-service task_run sets it to [resolved_shell, "-l"] whenever the request is a shell request.”) @yah:handoff(“Why an argv escape hatch rather than a login_shell: bool flag in the driver: task-runs is an oss/qed crate and has no business knowing what a login shell is. The caller names the exact process; the driver just execs it. This also made R652-T4 a two-line addition rather than a second flag.”) @yah:handoff(“Three things this fixes beyond .zprofile finally running. (1) sh -c \"zsh -l\" left an inert sh as the PTY’s foreground process group leader, so job control misbehaved and signals went to the wrong process. (2) That same inert sh is what the foreground-pid cwd probe (R652-T2) would have reported for, so T2 could not have worked without this. (3) -l is now a real argv element instead of text inside a shell string, so no quoting layer can eat it.”) @yah:handoff(“cmd is still what lands on TaskRunMeta.command, so a shell run reads back as "$SHELL" – the rail label and the history re-run path both keep working. Beholder argv rewriting is bypassed when argv is set (the attach runs with BeholderSelect::None): the rewritten argv would be discarded on that path, so recording a rewrite=... that never happened would be a lie in the run metadata.”) @yah:handoff(“An empty argv falls back to the sh -c path rather than spawning nothing – a caller bug should not become an exec of the empty string.”) @yah:verify(“cd oss/qed && cargo test -p task-runs –lib # 246/246 green (3 new: explicit_argv_execs_the_program_directly, explicit_argv_still_records_the_requested_command, empty_argv_falls_back_to_the_shell_path)”) @yah:verify(“Manual (needs desktop rebuild): add echo W289-login-test >> /tmp/w289.log to ~/.zprofile, open a shell tile, confirm the file gets a line”) @yah:gotcha(“driver.rs is an oss/qed crate – this lands in-tree under oss/qed/crates/task-runs and flows outward via scripts/export-oss.sh on the next release. SpawnOpts gained a field, but every in-tree construction site uses ..Default::default(), so nothing else needed touching.”)

Structs§

DriverChannels
Optional side-channels a driver can publish to. Both are fire-and-forget: a closed receiver never stalls or fails a run.
SpawnOpts
Options for TaskDriver::spawn_run.
TaskDriver
Manages in-flight task runs for a single camp.

Enums§

DriverError
StaleRunPolicy
What a freshly-constructed TaskDriver does with Running rows it finds already in the store.