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
2 changes: 2 additions & 0 deletions .agents/skills/orch/references/watch-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The harness picks the path before any launch. A harness that delivers a detached

## Repeat watch

The registered overseer's lead turn end refuses with `lane-mail-check: wake=unarmed` when the repeat watch is live but its follow is not running on the watch claim's cwd. Re-arm with `sh "[RUN_DIR]/follow.sh" "[RUN_DIR]/watch.log" [NEXT_LINE]` from the next unhandled line. The hook starts no process.

Launch the repeat command once from the overseer's own pane by [Waiter launch](waiter-launch.md) § Launch, run path `[RUN_DIR]/watch`: output in `[RUN_DIR]/watch.log`, status in `[RUN_DIR]/watch.exit`. `[NEXT_LINE]` starts at 1 in each fresh `[RUN_DIR]`. Save the numbered follow below as `[RUN_DIR]/follow.sh` with the harness file-write tool. Every harness follows the log with `sh "[RUN_DIR]/follow.sh" "[RUN_DIR]/watch.log" [NEXT_LINE]`, one simple command that prefixes each line with its number. Arm every follow from the line after the last number handled. The launch starts the watch through the orch job runner ([job-units.md](job-units.md)), which records how it runs in `[RUN_DIR]/watch.runner`; line 1 of `[RUN_DIR]/watch.log` repeats its runner line. The watch is the launch shell, whose argv carries the run path as its own word, and that shell leads the process group of everything the watch started. One read finds it, `pgrep -f 'waiter[.][RUN_ID]/watc[h] '`, `[RUN_ID]` being the letters and digits `mktemp` put after `waiter.` in `[RUN_DIR]`, whatever spelling launched the command: the name holds no regex character where the checkout path may, the bracket keeps the read from matching the shell that runs it, the trailing space keeps it off `watch.log`, and a pid it prints is that group. Exit 0 is a live watch and exit 1 is no watch. Any other status is a failed read: report it with pgrep's stderr, and launch, stop or relaunch nothing on it. The watch's own `oversee-watch.pid`, beside the fleet state, is its claim record for the refusals `oversee-watch --help` states, not this liveness read. After a self-succession `oversee-succeed` restarts the watch from the successor pane, and the successor's own launch here takes that watch over.

After each delivery and expiry, run that read first, then `test -s "[RUN_DIR]/watch.exit"`. A printed pid is a live watch. With no pid, a nonempty file ends the watch: `stopped` is the mark the stop below writes and ends oversight with no restart, and any other value follows the stop and restart rules of [oversee.md § 4](../workflows/oversee.md#4-watch-and-advance). With no pid and an empty file, the watch died without writing its status, killed together with its launch shell: report it and launch a new one in a fresh `[RUN_DIR]` under the same restart rule, then end the current follow and arm a new one on the new `[RUN_DIR]/watch.log` from line 1 (on Pi, stop the kept pid's task and keep the new pid). A follow is re-armed only while these checks read a live watch.
Expand Down
13 changes: 10 additions & 3 deletions .agents/skills/orch/scripts/lib/watch-pid.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ watch_pid_paths() { # STATE
# Whether a live watch holds the record for STATE, with its WATCH_PID,
# WATCH_PANE, WATCH_ORIGIN, WATCH_SCRIPT and WATCH_CWD set. Returns 1 where
# there is no record, its state is another file, or its pid runs no
# oversee-watch.
# oversee-watch. Returns 2 when the process query is unknown, with its cause
# on stderr; callers must not treat that answer as an absent claim.
watch_pid_live() { # STATE
local line state=""
WATCH_PID="" WATCH_PANE="" WATCH_ORIGIN="" WATCH_SCRIPT="" WATCH_CWD=""
Expand All @@ -78,11 +79,17 @@ watch_pid_live() { # STATE
}

# Whether PID runs an oversee-watch: it is running, is no zombie nobody has
# reaped, and its command line names oversee-watch.
# reaped, and its command line names oversee-watch. Returns 2 if ps fails
# while PID still exists, rather than reporting that process as dead.
watch_pid_runs() { # PID
local line
kill -0 "$1" 2>/dev/null || return 1
line="$(ps -o stat= -o args= -p "$1" 2>/dev/null)" || return 1
if ! line="$(ps -o stat= -o args= -p "$1" 2>&1)"; then
# The process can exit between kill and ps. Only that race is absence.
kill -0 "$1" 2>/dev/null || return 1
printf 'watch-pid: process=unknown pid=%s\n%s\n' "$1" "$line" >&2
return 2
fi
[[ "${line# }" != Z* && "$line" == *oversee-watch* ]]
}

Expand Down
119 changes: 116 additions & 3 deletions .claude/hooks/lane-mail-check.sh

Large diffs are not rendered by default.

119 changes: 116 additions & 3 deletions .codex/hooks/lane-mail-check.sh

Large diffs are not rendered by default.

119 changes: 116 additions & 3 deletions .pi/kendex/hooks/lane-mail-check.sh

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions changelog.d/added/registered-session-wake-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Repeat-watch overseers and configured masters must arm their wake process before ending a fresh lead turn. The refusal prints the start command.
2 changes: 1 addition & 1 deletion hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The catalog's hooks, one script each. `crates/core/tests/hooks_readme.rs` render
- `block-worktree-refresh`: Stops a kendex command that writes a project from inside a linked git worktree, where the write would land somewhere the command does not name.
- `command-safety`: Refuses shell commands matching a project's declared deny pattern, and every command while its settings file cannot be read.
- `doc-drift-check`: Stops an agent at the end of its turn when documents covering the code it changed did not change or an architecture topic names a path that does not exist, and hands it the list. Where some topic declares a Covers entry, changed code with no covering document is named too.
- `lane-mail-check`: Hands a lane the messages its overseer sent before the turn can end, so a directive is acted on instead of waiting for the next launch, and the same way hands the notes another repository's overseer sent to the one session the checkout's fleet record names. It also holds the turn end once the lane is near the end of its context window or its account's limit, until the lane records where it got to and exits, so the work resumes in a fresh session instead of stopping mid-round. The session running the fleet is held the same way, and hands itself over to a fresh one. A launched lane's turn end with no lane-mail ask or notice sent is held or reported to its overseer, and never judged from the lane's words; this hook's description states when each happens.
- `lane-mail-check`: Hands lanes their overseer's mail and holds turn ends for handoff or idle checks, plus a `wake=unarmed` refusal for the registered overseer without its repeat follow or a master whose `ORCH_WAKE_PROCESS` does not match, printing `ORCH_WAKE_START`; empty master keys with no watch claim disable the check, and a running single pass is its own wake. The wake check holds Claude Code Stop, Codex Stop and Copilot CLI agentStop, is partial on Pi, and is unsupported on Gemini, Antigravity, OpenCode and Cursor; a continuation or unavailable check warns without continuing the turn.
- `lane-mail-compact`: Marks a Copilot lane or overseer for handoff when Copilot starts compacting it automatically before its context reading could hand it off, so its next turn end holds it until it hands the work to a fresh session.
- `lane-mail-deliver`: Hands a working lane the messages its overseer sent as soon as a tool call finishes, and the notes another repository's overseer sent to the one session the checkout's fleet record names. It also tells the session running the fleet, at its next tool call, that it has used enough of its context to hand over.
- `lane-mail-halt`: Stops a lane at its next tool call when its overseer sends a halt, until the lane reads it, and refuses the harness question tool in a lane, naming lane mail as the route.
Expand Down
119 changes: 116 additions & 3 deletions hooks/lane-mail-check.sh

Large diffs are not rendered by default.

218 changes: 218 additions & 0 deletions hooks/tests/lane-mail-check-wake.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
# The registered session's wake, over real processes with the follow and
# single-pass argv watch-delivery produces. The harness rows drive the hook's
# existing lead identity and refusal formats, not a second wake implementation.
# HOOK_UNDER_TEST selects the planted copy for each must-fail control.
set -euo pipefail

# shellcheck source=lib/lane-mail-world.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/lane-mail-world.sh"

SELECTED=""
if [ "${1:-}" = --wake-row ]; then SELECTED=$2; fi
REARM='sh "[RUN_DIR]/follow.sh" "[RUN_DIR]/watch.log" [NEXT_LINE]'
MASTER_START='run-master-wake --resume'

# CASE|HARNESS|EXIT|STDERR-KEY|DECISION|CONTEXT-KEY|COMMAND-COUNT[|WAKE-NOTICE]
# Stop, Codex Stop and Copilot agentStop are held. Pi's carrier can make only
# one further model request, so these are hook-format tests, not proof of an
# indefinitely held Pi turn. Unsupported harnesses dispatch no hook at all.
while IFS='|' read -r scenario harness want_rc first decision context commands wake_notice; do
[ -z "$SELECTED" ] || [ "$scenario:$harness" = "$SELECTED" ] || continue
stop_wake_processes
wake_overseer "wake-$scenario-$harness" "$harness"
command=$REARM
payload='{"session_id":"s1","stop_hook_active":false}'
if [ "$harness" = copilot ]; then
payload=$(jq -nc --arg p "$COP_HOME/session-state/s1/events.jsonl" '{sessionId:"s1",transcriptPath:$p,timestamp:1}') || exit 1
fi
case "$scenario" in
armed) start_wake_watch repeat; start_follow ;;
no-follow | continuation | continuation-mark | agent-id | agent-type | subagent-stop | other-session | no-pgrep | probe-error | probe-error-mark | watch-probe-error | record-cwd | record-armed | missing-cwd | unreadable-record)
start_wake_watch repeat
;;
single) start_wake_watch single ;;
master-live | master-dead | master-continuation | incomplete-master)
command=$MASTER_START
if [ "$scenario" = master-live ]; then
wake_process "$TMP_ROOT/master-wake" --fleet "$LANE"
CALL_ENV+=("ORCH_WAKE_PROCESS=master-wake --fleet $LANE")
else
CALL_ENV+=("ORCH_WAKE_PROCESS=master-wake --fleet $LANE")
fi
CALL_ENV+=("ORCH_WAKE_START=$MASTER_START")
;;
empty-keys | absent-keys) command="" ;;
no-record)
command=""
(cd -- "$LANE" && "$REPO_ROOT/skills/orch/scripts/workflow-state" set oversee overseer null >/dev/null)
CALL_ENV+=(ORCH_WAKE_PROCESS=never-matched "ORCH_WAKE_START=$MASTER_START")
;;
*) echo "wake-suite: case=unknown value=$scenario" >&2; exit 1 ;;
esac
case "$scenario" in
no-follow)
# A matched master setting cannot replace a repeat follow. The live
# watch is also deliberately left running throughout the refusal.
CALL_ENV+=("ORCH_WAKE_PROCESS=oversee-watc[h]" "ORCH_WAKE_START=$MASTER_START")
;;
continuation | continuation-mark | master-continuation) payload=$(jq -c '. + {stop_hook_active:true}' <<< "$payload") || exit 1 ;;
agent-id) payload=$(jq -c '. + {agent_id:"child"}' <<< "$payload") || exit 1 ;;
agent-type) payload=$(jq -c '. + {agent_type:"worker"}' <<< "$payload") || exit 1 ;;
subagent-stop) payload=$(jq -c '. + {hook_event_name:"SubagentStop"}' <<< "$payload") || exit 1 ;;
other-session) CALL_ENV+=(TMUX_PANE=%3) ;;
no-pgrep) wake_without_pgrep ;;
probe-error | probe-error-mark)
printf '#!/bin/sh\necho "probe failed" >&2\nexit 2\n' > "$TMUX_BIN/pgrep"
chmod +x "$TMUX_BIN/pgrep"
;;
watch-probe-error)
executable=$(command -v ps) || exit 1
{
printf '#!/bin/sh\n'
printf 'if [ "$2" = stat= ]; then echo "watch-query-failed" >&2; exit 1; fi\n'
printf 'exec %q "$@"\n' "$executable"
} > "$TMUX_BIN/ps"
chmod +x "$TMUX_BIN/ps"
;;
record-cwd | record-armed)
# A follow on the session root does not arm a watch whose claim names
# another cwd. The path includes regex characters and a space.
stop_wake_processes
start_wake_watch repeat "$TMP_ROOT/watch [cwd].root"
if [ "$scenario" = record-armed ]; then start_follow "$TMP_ROOT/watch [cwd].root"; else start_follow; fi
;;
missing-cwd)
sed '/^cwd=/d' "${WAKE_STATE%/*}/oversee-watch.pid" > "$TMP_ROOT/claim"
mv -- "$TMP_ROOT/claim" "${WAKE_STATE%/*}/oversee-watch.pid"
;;
unreadable-record)
rm -- "${WAKE_STATE%/*}/oversee-watch.pid"
mkdir -- "${WAKE_STATE%/*}/oversee-watch.pid"
;;
empty-keys) CALL_ENV+=(ORCH_WAKE_PROCESS= ORCH_WAKE_START=) ;;
incomplete-master) CALL_ENV+=(ORCH_WAKE_START=); command="" ;;
esac
if [ -n "$wake_notice" ]; then
# An unarmed continuation or failed wake probe coincides with a handoff
# mark. Its Stop row and context record must still land.
judge_says "$CONTEXT_MARK_LINE"
wake_row_process_table
mkdir -p "$LANE/tmp/lane-mail/overseer"
fi
record_key="wake-record=${WAKE_STATE%/*}/oversee-watch.pid"
process_key="wake-process=follow[.]sh ${LANE//./\\.}/tmp/waiter[.][^/]*/watch[.]log"
case "$context" in record) context=$record_key ;; process) context=$process_key ;; esac
case "$first" in record) first=$record_key ;; process) first=$process_key ;; esac
[ "$first" = - ] || first="lane-mail-check: $first"
[ "$context" = - ] || context="lane-mail-check: $context"
run_payload "$payload"
got=$(wake_observation "$command") || exit 1
want="RC=$want_rc first=$first decision=$decision context=$context command=$commands"
# Exit 0 alone does not end Claude Stop: additionalContext requests a turn.
# Pin the whole output count.
objects=$(jq -s 'length' "$TMP_ROOT/stdout") || exit 1
event=$(jq -rs '.[0].hookSpecificOutput.hookEventName // "-"' "$TMP_ROOT/stdout") || exit 1
want_objects=0 want_event=-
if [ "$decision" = block ]; then want_objects=1; fi
got="$got replies=$objects event=$event"
want="$want replies=$want_objects event=$want_event"
if [ -n "$wake_notice" ]; then
row=$(jq -rs 'map([.event, .harness] | join(":")) | join(",")' \
"$LANE/tmp/lane-mail/overseer/session-$OVERSEER_SERVER-${OVERSEER_PANE#%}.jsonl") || exit 1
record=$(jq -r '[.harness, .session_id, .pane_key, (.tokens | tostring)] | join(":")' \
"$LANE/tmp/lane-mail/overseer/context.json") || exit 1
notices=$(grep -c "^lane-mail-check: $wake_notice" "$ERR_FILE") || exit 1
objects=$(jq -s 'length' "$TMP_ROOT/stdout") || exit 1
case "$harness" in
copilot)
# The wake warning joins the block reason, never a second JSON reply.
reason=$(jq -r '.reason | split("\n") | map(select(startswith("lane-mail-check: wake"))) | length' "$TMP_ROOT/stdout") || exit 1
output="objects=1 reason=1"
tokens=100000
;;
*) reason=-; output="objects=0 reason=-"; tokens=null ;;
esac
got="$got row=$row record=$record notices=$notices objects=$objects reason=$reason"
want="$want row=Stop:$harness record=$harness:s1:$OVERSEER_SERVER $OVERSEER_PANE:$tokens notices=1 $output"
fi
assert_eq "$got" "$want" "$scenario:$harness"
rm -f -- "$TMUX_BIN/pgrep" "$TMUX_BIN/ps"
done <<'ROWS'
armed|claude|0|-|-|-|0
armed|codex|0|-|-|-|0
armed|copilot|0|-|-|-|0
armed|pi|0|-|-|-|0
no-follow|claude|2|wake=unarmed|-|-|1
no-follow|codex|2|wake=unarmed|-|-|1
no-follow|copilot|0|wake=unarmed|block|-|1
no-follow|pi|2|wake=unarmed|-|-|1
continuation|claude|0|wake=unarmed|-|-|1
continuation|codex|0|wake=unarmed|-|-|1
continuation|copilot|0|wake=unarmed|-|-|1
continuation|pi|0|wake=unarmed|-|-|1
continuation-mark|claude|2|context=612000|-|-|1|wake=unarmed
continuation-mark|codex|2|context=612000|-|-|1|wake=unarmed
continuation-mark|copilot|0|context=612000|block|-|1|wake=unarmed
continuation-mark|pi|2|context=612000|-|-|1|wake=unarmed
probe-error-mark|claude|2|context=612000|-|-|0|wake-process=
probe-error-mark|codex|2|context=612000|-|-|0|wake-process=
probe-error-mark|copilot|0|context=612000|block|-|0|wake-process=
probe-error-mark|pi|2|context=612000|-|-|0|wake-process=
agent-id|claude|0|-|-|-|0
agent-type|codex|0|-|-|-|0
subagent-stop|claude|0|-|-|-|0
other-session|claude|0|-|-|-|0
single|claude|0|-|-|-|0
master-live|claude|0|-|-|-|0
master-dead|claude|2|wake=unarmed|-|-|1
master-continuation|claude|0|wake=unarmed|-|-|1
empty-keys|claude|0|-|-|-|0
absent-keys|claude|0|-|-|-|0
no-record|claude|0|-|-|-|0
no-pgrep|claude|0|wake-tools=pgrep|-|-|0
probe-error|claude|0|process|-|-|0
watch-probe-error|claude|0|record|-|-|0
record-cwd|claude|2|wake=unarmed|-|-|1
record-armed|claude|0|-|-|-|0
missing-cwd|claude|0|record|-|-|0
unreadable-record|claude|0|record|-|-|0
incomplete-master|claude|0|wake-setting=ORCH_WAKE_START|-|-|0
ROWS

# Copilot's custom subagent names its own session and the lead's transcript.
# This is the measured agentStop producer, distinct from agent_id/type.
if [ -z "$SELECTED" ]; then
stop_wake_processes
wake_overseer wake-copilot-child copilot
start_wake_watch repeat
payload=$(jq -nc --arg p "$COP_HOME/session-state/s1/events.jsonl" '{sessionId:"child",transcriptPath:$p,timestamp:1}') || exit 1
run_payload "$payload"
got=$(wake_observation "$REARM") || exit 1
assert_eq "$got" 'RC=0 first=- decision=- context=- command=0' 'Copilot custom subagent exclusion'

wake_control disabled no-follow:claude \
' refuse wake unarmed "$start"' \
' message wake unarmed "$start"; return 0'
wake_control continued-held continuation:claude \
' [ "$CONTINUED" != true ] || { wake_report wake unarmed "$start"; return 0; }' \
' [ "$CONTINUED" != true ] || refuse wake unarmed "$start"'
wake_control empty-held absent-keys:claude \
' [ "$mode" != single ] || return 0' \
' [ "$mode" != single ] || refuse wake unarmed "$start"'
wake_control unavailable-held no-pgrep:claude \
' command -v pgrep >/dev/null 2>&1 || { wake_report wake-tools pgrep; return 0; }' \
' command -v pgrep >/dev/null 2>&1 || refuse wake-tools pgrep'
wake_control warning-exits continuation-mark:claude \
' WAKE_NOTICE=$(message "$@" 2>&1) || refuse notice unwritten "$WAKE_NOTICE"' \
' WAKE_NOTICE=$(message "$@" 2>&1) || refuse notice unwritten "$WAKE_NOTICE"; exit 0'
wake_control warning-continues continuation:claude \
' printf '\''%s\n'\'' "$WAKE_NOTICE" >&2' \
' printf '\''%s\n'\'' "$WAKE_NOTICE" >&2; CONTEXT_EVENT=Stop; hand_over "$WAKE_NOTICE"'
wake_control watch-unknown-skips watch-probe-error:claude \
' *) exit "$rc" ;;' \
' *) printf "single\t" ;;'
fi

printf '\n=== %s passed, %s failed ===\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]
Loading
Loading