Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion docs/TODO.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,120 @@
# vigil TODO

Last Updated: 2026-07-02
Last Updated: 2026-07-14

Planned work and deferred decisions.

## TODO-003: Daemon decision introspection

**Status:** Planned. Motivated by the 2026-07-14 investigation into a daemon that
reported active sessions but held nothing. The daemon runs detached with
`/dev/null` stdio, so its decision state is invisible: diagnosing it needed a
`sample` stack trace and a restart experiment, and two wrong guesses (a legitimate
battery cap, then a deadlock) before `pmset -g log` and a restart isolated the real
causes (see session `2026-07-14_1329_turn-span-safe-upgrade-and-overnight-fix`).

Expose the daemon's live decision so `vigil status` can answer "why is it (not)
holding" in one command, distinct from status's own independent recomputation.

### Design sketch

Each housekeeping tick (or on each decision change), the daemon writes a small
state file to the runtime dir, for example `${VIGIL_RUNTIME_DIR}/daemon-state.json`,
with the fields it just computed: `ts`, `active`, `want_hold`, `battery_capped`,
`hold_since`, `on_ac`, `charge`, and the daemon pid. `vigil status` reads it and
prints the daemon's actual decision and the reason it holds or releases. A stale
`ts` (older than a few `POLL_INTERVAL`s) signals a wedged or dead daemon, which the
current pgrep-based "daemon running" line cannot distinguish from a healthy idle
one.

### Work

- Add a serde state struct and an atomic write (temp + rename, as
`write_settings_backed_up` does) each tick in `daemon::run`.
- Read and render it in `daemon::status`, including a staleness note.
- Decide write cadence: every tick is simplest; on-change avoids churn but adds
state. Every tick is fine (one small file write per `POLL_INTERVAL`).
- Clean up the state file on clean exit (self-exit, disable flag, self-upgrade).
- Consider an opt-in append-only debug log (`$VIGIL_DEBUG` env) for a rolling
history rather than only the latest snapshot, if the snapshot proves too thin.

### Acceptance

- `vigil status` shows why the daemon holds or releases (active, want_hold, and the
battery-cap reason) from the daemon's own last decision, not a recomputation.
- A wedged or dead daemon is distinguishable from a healthy idle one via state
staleness.

### Related

- Session `2026-07-14_1329_turn-span-safe-upgrade-and-overnight-fix` (the diagnosis
that motivated this)
- ADR-0013 (battery interactions), ADR-0007 (daemon lifecycle, detached stdio)

## TODO-002: Turn-span activity model (phase 1)

**Status:** Phase 1 built on branch `feat/turn-span-model`, not yet merged or
deployed (session `2026-07-14_1329_turn-span-safe-upgrade-and-overnight-fix`). Two
follow-up fixes landed on the same branch after real-use testing: the awaiting-input
release set was missing `idle_prompt`/`agent_completed` (held the display overnight),
and the battery max-hold counted total hold instead of battery-only hold. Design in
ADR-0013. Supersedes the timeout-driven release from ADR-0006 and dissolves the
commit-aware timeout from ADR-0005. Motivated by the 2026-07-13 mid-turn
display-sleep incident (session `684e315d`), where a 245s gap with no hook events
elapsed the 120s timeout and released the hold.

Replace the daemon's activity test with turn-span: a session is active while its
log exists, its process is alive, its transcript's newest line is not the interrupt
marker, its newest log line is not an awaiting-input `Notification`, and its log age
is under a long safety cap. Verification on 2026-07-13 established that no
incremental signal (hooks or transcript) survives a long thinking block, so the
turn boundary is the only reliable span marker (full evidence in ADR-0013).

### Phase 1 work

- **Recorder / event schema.** Wire `Notification` as a seventh hook event; append a
line carrying `notification_type`. Add the optional `notification_type` field to
the `Event` schema (`event.rs`). Keep `Stop`/`StopFailure`/`SessionEnd` as
log-deleting terminal events.
- **Activity test.** Rewrite the freshness check in `evaluate_sessions` (`daemon.rs`)
to turn-span. A session whose newest line is one of `permission_prompt`,
`agent_needs_input`, `elicitation_dialog` is awaiting-input and not active, with a
~90s grace before release.
- **Remove the commit apparatus.** Delete `COMMIT_TIMEOUT`, `STANDARD_TIMEOUT`,
`is_unmatched_commit`, `is_commit_command`, `is_git_commit_segment`, and their
tests. The commit hold falls out of turn-span.
- **Safety cap.** A single long absolute cap on log age (target 12h), derived from
file state so it survives daemon restarts. Replaces `GC_THRESHOLD` as the primary
backstop, or sits alongside it.
- **Scan-time interrupt check.** Check the interrupt marker when a session is first
evaluated, alongside the liveness check, so a missed marker is caught at scan time
rather than held to the safety cap.
- **Battery-floor invariant.** Keep the battery cap a veto over the hold decision
(`want_hold = active AND (on_ac OR NOT battery_capped)`). Extract the cap decision
into a pure function and unit-test the active-on-battery-at-floor release case, so
the invariant cannot silently regress. This extraction also serves TODO-001.
- **status.** Report per-session state (working, tool-in-flight, awaiting-input).

### Acceptance

- Unit tests: turn-span activity given a synthetic newest line (each state),
awaiting-input release, battery-floor veto while active, safety-cap release.
- `cargo test`, `clippy -D warnings`, `fmt --check` green.
- Manual: re-run the incident shape (a turn with a >120s tool-free gap) and confirm
the hold spans it; confirm a permission prompt releases within the grace; confirm
on battery at the floor the hold releases mid-turn.

### Deferred to phase 2 (only if warranted)

- A shorter cap keyed on the newest event type (long for `PreToolUse` tool-in-flight,
medium for between-tools states), gated on real usage showing missed-`Stop` leaks.
- Live confirmation of `idle_prompt` and `StopFailure` (documented, not captured).

### Related

- ADR-0013 (`adr/0013-turn-span-activity-model.md`)
- ADR-0006 (superseded), ADR-0005 (dissolved), ADR-0009 (battery cap, retained)

## TODO-001: Graduated battery response (charge-tiered max-hold)

**Status:** Deferred. v1 ships the two-guard form (floor + single max-hold) from
Expand Down
211 changes: 211 additions & 0 deletions docs/adr/0013-turn-span-activity-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# ADR-0013: Turn-span activity model

**Status:** Draft

**Date:** 2026-07-13

## Context

The release model to date treats a session as active while the idle time since
its newest log line is under a timeout (ADR-0006, demoted to a backstop by
ADR-0010/0011 but still the freshness signal in `evaluate_sessions`). The timeout
is `STANDARD_TIMEOUT` (120s), or `COMMIT_TIMEOUT` (300s) while the newest line is
an in-flight commit (ADR-0005).

On 2026-07-13 a session on battery slept the display mid-turn. Session
`684e315d-aeb4-4a7b-a63c-404d96fae6fc` produced no hook events for 245s while the
model worked (a burst of reads at 10:36:06, then the next event at 10:40:11). The
120s timeout elapsed at 10:38:07, vigil released, powerd began its 2-minute
display-off grace, and the display turned off at 10:40:17, six seconds after vigil
re-acquired at 10:40:13. The pmset log records the release
(`PID 94896(caffeinate) ClientDied ... 10:38:07`) and the re-acquire
(`PID 1677(caffeinate) Created ... 10:40:13`). A `PreventUserIdleDisplaySleep`
assertion does not cancel a display-off already in progress, so the late
re-acquire did not relight the screen.

The stated goal is a hold that spans a whole turn: from `UserPromptSubmit` until
the turn ends or the session is waiting on the user, with no mid-turn release.

### Verification (2026-07-13)

Whether an incremental activity signal could carry a mid-turn hold was tested
directly.

- **Transcript writes are not a mid-turn signal.** During the incident's 245s hook
gap the session transcript was also silent: one write at 10:36:06, the next at
10:40:09 (243s). The earlier gap showed 139s of transcript silence. A headless
`claude -p` prose session (1500-word essay, no tools) held its transcript at
18323 bytes for 68s, then wrote the whole body in one append to 35369 bytes at
completion. The transcript is written at message-block and tool completion, not
during token streaming, so it goes quiet during the same long thinking or
generation stretch that produces no hooks.
- **Turn boundaries are reliable.** Clean turn completion fired `Stop` in every
observed case (two headless sessions, one interactive). `SessionEnd` fired on
every session close, with `reason:"other"` on normal end and
`reason:"prompt_input_exit"` on `/exit`.
- **`Notification` reports awaiting-input.** An interactive session showing a
permission dialog fired `Notification` with
`{notification_type:"permission_prompt", message:"Claude needs your permission"}`.
The documented awaiting-input types are `permission_prompt`, `idle_prompt`,
`agent_needs_input`, and the `elicitation_*` set (Claude Code docs, hooks
reference, retrieved 2026-07-13).
- **Not reproduced live.** `idle_prompt` did not fire during a 75s wait while a
permission dialog was already open. `StopFailure` (documented for API errors) was
not induced. `PermissionRequest` wiring was added after the interactive probe ran.

The finding that no incremental signal survives a long thinking block removes the
option of proving liveness mid-turn. What remains reliable is the turn boundary
(`UserPromptSubmit` start, `Stop`/`StopFailure`/`SessionEnd` end), process
liveness (ADR-0010), and the interrupt marker (ADR-0011).

## Decision

Replace the timeout-driven freshness test with a turn-span activity test. A
session is active when all of the following hold:

- its log exists (the recorder creates it on `UserPromptSubmit` and deletes it on
`Stop`/`StopFailure`/`SessionEnd`, so an existing log means a turn is in flight),
- its `claude` process is alive (ADR-0010),
- its transcript's newest line is not the interrupt marker (ADR-0011),
- its newest log line is not an awaiting-input `Notification`, and
- its log age is under a long safety cap.

The freshness timeout, the commit-extended timeout, and commit detection are
removed. An in-flight commit is a tool in flight like any other and is held for
the duration of the turn, so the Touch ID sheet is covered without a special case.

### Awaiting-input

`Notification` is wired as a seventh hook event. The recorder appends a line
carrying `notification_type`. A session whose newest line is one of
`permission_prompt`, `agent_needs_input`, `elicitation_dialog` (the user must act),
`idle_prompt` (the user has gone idle at the prompt), or `agent_completed` (the turn
finished) is not actively working and is not active. `auth_success` and
`elicitation_complete`/`_response` mean work is resuming and do not release. The next
`PreToolUse` (after approval) or a new `UserPromptSubmit` becomes the newest line and
the session is active again.

`idle_prompt` was omitted from the release set in the first implementation. It held
the display awake overnight on 2026-07-13: an abandoned session whose newest line was
an `idle_prompt` Notification stayed active under the 12h safety cap and pinned the
assertion from 20:27 to 10:44 (per `pmset -g log`). Adding `idle_prompt` and
`agent_completed` releases such a session after the grace.

Awaiting-input uses a short grace before release (target 90s) rather than
immediate release, so the display does not sleep while the user reads a dialog
they have not yet answered. An unattended session releases roughly 90s after the
prompt.

### Safety cap

A single long absolute cap on log age (target 12h) is the backstop for a turn that
never ends: a `Stop` that did not fire while the process stays alive with no
interrupt marker. No shorter cap is applied, because a long single tool (a build,
a test run) can leave the newest line unchanged for a long time while genuinely
working, and vigil cannot distinguish that from a turn whose `Stop` was missed. The
cap is derived from log age (file state), not held in daemon memory, so it survives
the daemon self-exit and respawn that a multi-turn `/loop` causes.

### Battery floor invariant

The battery cap (ADR-0009) is applied as a veto over the hold decision, not as part
of the activity test. `want_hold = active AND (on_ac OR NOT battery_capped)`. The
turn-span change alters only the `active` term. On battery, once charge reaches
`BATTERY_FLOOR_PCT`, `battery_capped` is set from the live `pmset` read and the hold
is released regardless of how long the turn has been active. A mega-turn held for
hours on battery is released at the floor. The floor re-derives from the current
charge on every poll, so it re-latches after a daemon restart.

The battery-cap decision is extracted into a pure function and unit-tested,
including the case where a session is active, on battery, and at or below the floor,
and the hold is not taken. This locks the invariant against regression during the
turn-span change.

The `BATTERY_MAX_HOLD` guard (ADR-0009) measures `now - hold_since`, where
`hold_since` marks the start of the continuous hold. Turn-span makes holds span a
whole turn rather than resetting every 120s, so a hold can run for hours on AC. The
first implementation left `hold_since` running across AC, so a multi-hour AC hold
consumed the battery budget before an unplug: on 2026-07-13 a ~14h continuous hold
(mostly on AC) tripped the 3h guard the instant the machine was unplugged, releasing
at 100% charge. `hold_since` is now cleared while on AC and set on the first battery
tick, so the max-hold guard counts battery time only and each unplug starts a fresh
budget. This matches ADR-0009's stated intent ("continuous battery-powered
holding"). Extracted into a pure `next_hold_since` and unit-tested.

### Scan-time interrupt check

The interrupt marker is checked when a session is first evaluated, alongside the
liveness check, in addition to the existing reactive transcript-write check. Under
the timeout model a missed marker released after 120s. Under turn-span there is no
short timeout, so a marker that is a transcript's last line with no following write
(a daemon that starts after the interrupt) is caught at scan time rather than held
to the safety cap.

## Consequences

**Covers the incident.** Both gaps in session `684e315d` were mid-turn with the log
present, the process alive, and no interrupt marker. Under turn-span the hold spans
the gap.

**Failure direction inverts.** Under the timeout model a missed signal false-releases,
bounded at 120s. Under turn-span a missed end signal false-holds, bounded by the
safety cap on AC and by the battery guards on battery. `Stop` was reliable in every
observed clean end, so the missed-`Stop` case is rare; its cost is a held display
until the user returns (their next input or prompt resolves it) or the cap fires.

**Overnight, one mega-turn.** A single long instruction is one `UserPromptSubmit`
and one `Stop`, held throughout. An un-preauthorized permission prompt releases
(awaiting-input) and the display sleeps until the user returns and approves, which
is the intended behavior for a genuinely blocked run.

**Overnight, many turns (`/loop`).** Each iteration ends with `Stop`, releases, and
the display sleeps during the gap. Once the display sleeps and the lock engages,
Touch ID keychain reads fail until the user returns regardless of vigil, so this
matches the existing headless workflow (unsigned commits between iterations).

**Battery unchanged.** A long turn on battery was already held continuously under
the timeout model (tool events refreshed it), so the floor was already the sole
guard against draining an unattended battery session. It remains so, now enforced
by an explicit invariant and a test.

**Commit apparatus removed.** `COMMIT_TIMEOUT`, `is_unmatched_commit`, and the
git-commit command parser are deleted. ADR-0005 dissolves. The commit hold is a
consequence of turn-span rather than a detection heuristic.

**Deferred.** A shorter cap keyed on the newest event type (a build sitting on
`PreToolUse` held long, a between-tools state held for a medium interval) is not
built. It keys on a reliable signal (event type) rather than the refuted transcript
signal, so it is a viable later refinement, gated on real usage showing that
missed-`Stop` leaks occur. `idle_prompt` and `StopFailure` behavior are confirmed
by documentation but not by live capture.

## SPEC impact

To be applied in a dedicated spec-update session:

- "Reference counting & the staleness backstop" and "Commit-aware timeout": replace
the timeout-driven activity test with turn-span (log present, alive, not
interrupted, not awaiting-input, under the safety cap). Remove commit detection.
- "Timeouts & Configuration": remove `STANDARD_TIMEOUT` and `COMMIT_TIMEOUT`; add
the awaiting-input grace and the safety cap; note the battery-floor invariant.
- "Hook Contract / Wired Events": add `Notification` as a seventh event; record the
awaiting-input `notification_type` set.
- "Event Log / Line Schema": add `notification_type` (optional).
- "Daemon / Supervisor loop": the activity test changes; the battery veto and the
self-exit logic are unchanged.
- "CLI status": report per-session state (working, tool-in-flight, awaiting-input).

## References

- `../SPEC.md` sections "Reference counting & the staleness backstop",
"Commit-aware timeout", "Power source & battery cap", "Hook Contract"
- Incident 2026-07-13, session `684e315d-aeb4-4a7b-a63c-404d96fae6fc`; pmset log
release 10:38:07, re-acquire 10:40:13, display off 10:40:17
- Verification 2026-07-13: transcript-silence measurement, headless batched-write
measurement, `Notification` permission_prompt capture
- Claude Code hooks reference (retrieved 2026-07-13): `Notification` types,
`StopFailure`, `PermissionRequest`
- ADR-0006 (staleness release, superseded by this ADR), ADR-0005 (commit-aware
timeout, dissolved), ADR-0010 (process liveness, retained), ADR-0011 (reactive
loop and interrupt marker, retained), ADR-0009 (battery cap, retained as the hold
veto), ADR-0012 (reactive log-directory watch, retained)
Loading