wake-agent: event-triggered headless Pi continuation with a footer badge #285

Open
allod-agent wants to merge 2 commits from agent/wake-agent-281 into master
Member

Owner summary

A machine gains one command, wake-agent <probe-cmd> <brief-file>, which leaves a transient user unit behind, polls the probe until it succeeds twice across a grace sleep, and then runs one fresh pi --no-session -p <brief> under a timeout, writing a status file and a log for every outcome. Nothing is installed anywhere yet: the command and the Pi footer extension are new flake outputs in this repo, and no module, timer or VM wires them in, so every running machine is identical after this merges. The proof is the issue's own gate: a live end-to-end pilot on allod-dev where the event fired, Pi resumed, the artifact landed byte-exact and the report was written, plus the Go suite and a Node check of the extension. If it is wrong, revert the commit; there is no state to undo beyond files under ~/.local/state/wake-agent on a machine that ran it by hand.

Closes allod/tools#281

What changed

  • cmd/wake-agent: the foreground command validates the brief (regular file, under 128 KiB, no NUL, and the shell-quoted Pi command also under that limit), resolves pi and util-linux script, and starts wake-agent.service through systemd-run --user --collect with itself as --worker. The worker interprets probe exits as 0 observed, 1 wait, 2 dead target, other broken; confirms after --grace; runs Pi under a pseudo-terminal because this Pi build hangs in print mode without one; bounds each attempt with --timeout and the whole run with --cap; sends TERM to the child group and KILLs survivors after two seconds; retries once with --retry and never after a cap or cancel. Every exit writes status and the live sentinel atomically at mode 0600, with probe output bounded before it reaches state.
  • extensions/wake-agent.ts: a Pi extension that sets the persistent status wake-agent: running while the sentinel names a live stage and wake-agent.service is active, and unlinks a sentinel whose unit is gone, so a SIGKILL or a failed terminal write cannot leave a stale badge.
  • flake.nix: wake-agent package and check, and a wake-agent-extension check that runs the extension's Node test against a mocked systemctl. go-checks gains util-linux because the Go suite drives the real script.
  • docs/wake-agent.md: the contract, the probe exit table, state and sentinel paths, the install step for the footer, and the timeout trap below.

Risk

R3. It is a wrapper that creates a transient systemd user unit and supervises a child process group, which the rubric names, even though nothing deploys it and a bad run is bounded by the cap and recoverable by systemctl --user stop wake-agent.service. The worst credible failure after validation is a continuation that runs against the wrong target or a wrong model and burns a timeout's worth of tokens, written to the log and status as such. It cannot touch remotes, secrets or provisioning. Reviewed by one cold Claude-family reviewer, invoked by the Codex overseer that drove the build; its fourteen findings and the fix round are listed below.

Validation

Run on allod-dev from the worktree at this PR's head.

Go suite, vet and race:

nix develop -c sh -c 'go vet ./... && go test -count=1 ./... && go test -race -count=1 ./cmd/wake-agent'
ok  	forge.anarch.diy/allod/tools/cmd/wake-agent	8.617s
ok  	forge.anarch.diy/allod/tools/cmd/wake-agent	9.652s   (race)

every other package ok as before.

Every flake check from the committed tree:

nix build .#checks.x86_64-linux.{allod,allod-parity,cascade-suites,flake-status-suite,flake-update-cascade,forge,git-hook-suites,go-checks,wake-agent,wake-agent-extension}
all ten built; exit 0

The end-to-end pilot the issue names as the promotion gate, run against the real transient user unit with a brief asking Pi to write an artifact and a report:

wake-agent --grace 1s --interval 1s --cap 10m --timeout 5m 'test -f $d/trigger' $d/brief.md
Running as unit: wake-agent.service; invocation ID: 61eb073be4304f5cad2ce6a5482c2644
2026-10-22T02:13:36Z stage=waiting
2026-10-22T02:13:38Z stage=confirming
2026-10-22T02:13:39Z stage=running
2026-10-22T02:13:50Z stage=complete
status: stage=complete attempt=1
artifact.txt: "wake-agent pilot complete\n" (26 bytes, od-verified)
report.md: The artifact `artifact.txt` was written and verified to contain `wake-agent pilot complete` followed by a newline.

The pilot pinned Pi to nullsink-claude/claude-sonnet-5-5 through a one-line pi shim placed first on PATH, because the tool resolves pi from the caller's PATH and pins the absolute path into the unit; the command inside the unit stayed pi --no-session -p <brief>. Two earlier pilots against the machine's default model timed out at two and three minutes with no output; probe-models then showed that model answers a one-word prompt in under seven minutes, so those timeouts were too short for a thinking model rather than the tool failing. That is now a documented trap: size --timeout for the model, not only the task.

The footer extension against the real unit and the real systemctl, not the mocked one the Nix check uses: with a watcher in waiting the extension reports wake-agent: running; a second launch while that unit exists is refused by systemd; after systemctl --user stop the status reads stage=canceled detail=watcher stopped while waiting and the extension reports no badge.

Review findings

Fixed in the one fix round: the stale badge after SIGKILL (extension now checks unit liveness and unlinks; sentinel stage parsed by line, not substring), the TERM grace that never ran (no CommandContext; explicit TERM, two-second wait, then KILL), retry recorded after a cap or cancel (refused when the parent context is done), launcher and unit environment drift (pi and script resolved at launch and pinned by path into the unit), unbounded probe output copied into state (probe writes to a file, bounded to 4 KiB, detail bounded to 8 KiB; docs warn that the probe string is visible in unit metadata), a backgrounded probe child holding the pipe (file, not pipe, so Wait returns with the probe), the success-versus-cap race, brief validation at launch and the 128 KiB argument limit, and the comment bloat.

Kept as the contract, and documented: the cap bounds the whole run including a running Pi attempt, because the issue's cap is a total lifetime and a watcher that outlives its cap is the failure the cap exists to prevent. The unit name is fixed, so a second watcher per user is refused rather than isolated. Linger is the user manager's business and the docs say so.

Deferred, out of this slice: --worker and the path flags remain parseable public flags that the usage text omits, and the worker's dead-target exit 2 shares a number with a usage error; neither affects the status file, which is the contract a reader uses. Deployment wiring and the install path for the footer are the deployment's, per the issue's scope.

Found on the way

allod/tools#284: building allod-parity or go-checks with a path: reference from a linked worktree fails with invalid description, because the copied tree carries a dangling .git file and git check-ref-format aborts under it. The same tests pass from the committed tree through the git reference, which is what the Validation section above ran. Not touched here.

## Owner summary A machine gains one command, `wake-agent <probe-cmd> <brief-file>`, which leaves a transient user unit behind, polls the probe until it succeeds twice across a grace sleep, and then runs one fresh `pi --no-session -p <brief>` under a timeout, writing a status file and a log for every outcome. Nothing is installed anywhere yet: the command and the Pi footer extension are new flake outputs in this repo, and no module, timer or VM wires them in, so every running machine is identical after this merges. The proof is the issue's own gate: a live end-to-end pilot on allod-dev where the event fired, Pi resumed, the artifact landed byte-exact and the report was written, plus the Go suite and a Node check of the extension. If it is wrong, revert the commit; there is no state to undo beyond files under `~/.local/state/wake-agent` on a machine that ran it by hand. Closes allod/tools#281 ## What changed - `cmd/wake-agent`: the foreground command validates the brief (regular file, under 128 KiB, no NUL, and the shell-quoted Pi command also under that limit), resolves `pi` and util-linux `script`, and starts `wake-agent.service` through `systemd-run --user --collect` with itself as `--worker`. The worker interprets probe exits as 0 observed, 1 wait, 2 dead target, other broken; confirms after `--grace`; runs Pi under a pseudo-terminal because this Pi build hangs in print mode without one; bounds each attempt with `--timeout` and the whole run with `--cap`; sends TERM to the child group and KILLs survivors after two seconds; retries once with `--retry` and never after a cap or cancel. Every exit writes `status` and the live sentinel atomically at mode 0600, with probe output bounded before it reaches state. - `extensions/wake-agent.ts`: a Pi extension that sets the persistent status `wake-agent: running` while the sentinel names a live stage and `wake-agent.service` is active, and unlinks a sentinel whose unit is gone, so a SIGKILL or a failed terminal write cannot leave a stale badge. - `flake.nix`: `wake-agent` package and check, and a `wake-agent-extension` check that runs the extension's Node test against a mocked `systemctl`. `go-checks` gains util-linux because the Go suite drives the real `script`. - `docs/wake-agent.md`: the contract, the probe exit table, state and sentinel paths, the install step for the footer, and the timeout trap below. ## Risk R3. It is a wrapper that creates a transient systemd user unit and supervises a child process group, which the rubric names, even though nothing deploys it and a bad run is bounded by the cap and recoverable by `systemctl --user stop wake-agent.service`. The worst credible failure after validation is a continuation that runs against the wrong target or a wrong model and burns a timeout's worth of tokens, written to the log and status as such. It cannot touch remotes, secrets or provisioning. Reviewed by one cold Claude-family reviewer, invoked by the Codex overseer that drove the build; its fourteen findings and the fix round are listed below. ## Validation Run on allod-dev from the worktree at this PR's head. Go suite, vet and race: ``` nix develop -c sh -c 'go vet ./... && go test -count=1 ./... && go test -race -count=1 ./cmd/wake-agent' ok forge.anarch.diy/allod/tools/cmd/wake-agent 8.617s ok forge.anarch.diy/allod/tools/cmd/wake-agent 9.652s (race) ``` every other package ok as before. Every flake check from the committed tree: ``` nix build .#checks.x86_64-linux.{allod,allod-parity,cascade-suites,flake-status-suite,flake-update-cascade,forge,git-hook-suites,go-checks,wake-agent,wake-agent-extension} all ten built; exit 0 ``` The end-to-end pilot the issue names as the promotion gate, run against the real transient user unit with a brief asking Pi to write an artifact and a report: ``` wake-agent --grace 1s --interval 1s --cap 10m --timeout 5m 'test -f $d/trigger' $d/brief.md Running as unit: wake-agent.service; invocation ID: 61eb073be4304f5cad2ce6a5482c2644 2026-10-22T02:13:36Z stage=waiting 2026-10-22T02:13:38Z stage=confirming 2026-10-22T02:13:39Z stage=running 2026-10-22T02:13:50Z stage=complete status: stage=complete attempt=1 artifact.txt: "wake-agent pilot complete\n" (26 bytes, od-verified) report.md: The artifact `artifact.txt` was written and verified to contain `wake-agent pilot complete` followed by a newline. ``` The pilot pinned Pi to `nullsink-claude/claude-sonnet-5-5` through a one-line `pi` shim placed first on PATH, because the tool resolves `pi` from the caller's PATH and pins the absolute path into the unit; the command inside the unit stayed `pi --no-session -p <brief>`. Two earlier pilots against the machine's default model timed out at two and three minutes with no output; `probe-models` then showed that model answers a one-word prompt in under seven minutes, so those timeouts were too short for a thinking model rather than the tool failing. That is now a documented trap: size `--timeout` for the model, not only the task. The footer extension against the real unit and the real `systemctl`, not the mocked one the Nix check uses: with a watcher in `waiting` the extension reports `wake-agent: running`; a second launch while that unit exists is refused by systemd; after `systemctl --user stop` the status reads `stage=canceled detail=watcher stopped while waiting` and the extension reports no badge. ## Review findings Fixed in the one fix round: the stale badge after SIGKILL (extension now checks unit liveness and unlinks; sentinel stage parsed by line, not substring), the TERM grace that never ran (no `CommandContext`; explicit TERM, two-second wait, then KILL), retry recorded after a cap or cancel (refused when the parent context is done), launcher and unit environment drift (`pi` and `script` resolved at launch and pinned by path into the unit), unbounded probe output copied into state (probe writes to a file, bounded to 4 KiB, detail bounded to 8 KiB; docs warn that the probe string is visible in unit metadata), a backgrounded probe child holding the pipe (file, not pipe, so `Wait` returns with the probe), the success-versus-cap race, brief validation at launch and the 128 KiB argument limit, and the comment bloat. Kept as the contract, and documented: the cap bounds the whole run including a running Pi attempt, because the issue's cap is a total lifetime and a watcher that outlives its cap is the failure the cap exists to prevent. The unit name is fixed, so a second watcher per user is refused rather than isolated. Linger is the user manager's business and the docs say so. Deferred, out of this slice: `--worker` and the path flags remain parseable public flags that the usage text omits, and the worker's dead-target exit 2 shares a number with a usage error; neither affects the status file, which is the contract a reader uses. Deployment wiring and the install path for the footer are the deployment's, per the issue's scope. ## Found on the way allod/tools#284: building `allod-parity` or `go-checks` with a `path:` reference from a linked worktree fails with `invalid description`, because the copied tree carries a dangling `.git` file and `git check-ref-format` aborts under it. The same tests pass from the committed tree through the git reference, which is what the Validation section above ran. Not touched here.
Add cmd/wake-agent: a foreground command that validates the brief,
resolves pi and script, and starts one collected transient user unit
(wake-agent.service) running itself as the worker. The worker polls a
probe through /bin/sh -c, confirms a success after a grace sleep, then
runs pi --no-session -p <brief> under a pseudo-terminal with a per-attempt
timeout, a total cap and an optional single retry. Every exit writes a
key=value status snapshot and a live sentinel; the log carries stage
transitions and Pi output.

Add extensions/wake-agent.ts: a Pi extension that shows
"wake-agent: running" while the sentinel names a live stage and the unit
is active, and removes a stale sentinel otherwise.

Package both in flake.nix with a Go test and a Node extension check, and
document the contract in docs/wake-agent.md.

Refs allod/tools#281
Author
Member

Caught up with master at 3909ce4 by merge commit 27607d6, since the forge refuses force-push on agent branches. No conflicts: the only shared file is flake.nix, and the merged tree differs from master by this PR's seven files alone, each flake binding present once. All ten flake checks rebuilt green on the merged tree from the git reference (allod, allod-parity, cascade-suites, flake-status-suite, flake-update-cascade, forge, git-hook-suites, go-checks, wake-agent, wake-agent-extension).

Caught up with master at 3909ce4 by merge commit 27607d6, since the forge refuses force-push on agent branches. No conflicts: the only shared file is flake.nix, and the merged tree differs from master by this PR's seven files alone, each flake binding present once. All ten flake checks rebuilt green on the merged tree from the git reference (allod, allod-parity, cascade-suites, flake-status-suite, flake-update-cascade, forge, git-hook-suites, go-checks, wake-agent, wake-agent-extension).
This pull request doesn't have enough approvals yet. 0 of 1 approvals granted.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin agent/wake-agent-281:agent/wake-agent-281
git switch agent/wake-agent-281
Sign in to join this conversation.
No description provided.