Skip to content
Open
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
20 changes: 19 additions & 1 deletion .pi/extensions/fm-primary-pi-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,13 @@ const fmRoot = process.env.FM_ROOT_OVERRIDE || root;
const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`;
const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`;
const armScript = `${fmRoot}/bin/fm-watch-arm.sh`;
const wakeContextScript = `${fmRoot}/bin/fm-wake-context.sh`;
const marker = `${state}/.pi-watch-extension-loaded`;
const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`;
const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250);
const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000);
const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5);
const wakeContextTimeoutMs = positiveInteger("FM_WAKE_CONTEXT_TIMEOUT_MS", 5000);
// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in
// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be
// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s.
Expand Down Expand Up @@ -185,6 +187,22 @@ function classifyClose(stdout: string, stderr: string, code: number | null, sign
};
}

function wakeContextPresentation(): string {
const fallback = "WAKE_CONTEXT_FALLBACK: run bin/fm-wake-drain.sh once.";
const result = spawnSync("bash", [wakeContextScript, "--present"], {
cwd: fmRoot,
env: { ...process.env, FM_HOME: fmHome, FM_STATE_OVERRIDE: state, FM_ROOT_OVERRIDE: fmRoot },
encoding: "utf8",
maxBuffer: 128 * 1024,
timeout: wakeContextTimeoutMs,
});
if (["ENOBUFS", "ETIMEDOUT"].includes((result.error as NodeJS.ErrnoException | undefined)?.code || "")) return fallback;
const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
if (result.status === 0 && output) return output;
if (output.includes("WAKE_CONTEXT_FALLBACK:")) return output;
return output ? `${output}\n${fallback}` : fallback;
}

function createGeneration(): SessionGeneration {
return {
id: ++nextGenerationId,
Expand Down Expand Up @@ -245,7 +263,7 @@ export default function (pi: ExtensionAPI) {
if (!generationIsLive(owner)) return;
const content = encodeFirstmateOperationalInput(
"watcher",
`FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.`,
`FIRSTMATE WATCHER WAKE: ${message}\n\n${wakeContextPresentation()}\n\nHandle this presentation without rebuilding fleet context. Watcher continuity is extension-owned.`,
);
await pi.sendUserMessage(content, { deliverAs: "followUp" });
}
Expand Down
38 changes: 33 additions & 5 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,7 @@ status_open_decisions_incremental() { # <status-file> [<captured-end-offset>]
offset=$size
cursor_dirty=1
fi
if [ "$cursor_dirty" -eq 1 ]; then
if [ "$cursor_dirty" -eq 1 ] && [ "${FM_WAKE_CONTEXT_NONMUTATING:-0}" != 1 ]; then
target_cursor="$cf.tmp.$$"
{
printf 'version=%s\n' "$FM_OPEN_DECISIONS_FOLD_VERSION"
Expand Down Expand Up @@ -866,9 +866,9 @@ $snapshot
EOF
}

status_commit_presentation_snapshot() { # <state> <snapshot>
local state=$1 snapshot=$2 task endpoint ident f cur_ident size tmp
tmp="$state/.status-presentation-cursor.tmp.$$"
status_commit_presentation_snapshot() { # <state> <snapshot> [<target>]
local state=$1 snapshot=$2 target=${3:-$1/.status-presentation-cursor} task endpoint ident f cur_ident size tmp
tmp="$target.tmp.$$"
: > "$tmp" || return 1
while IFS=$(printf '\t') read -r task endpoint ident; do
[ -n "$task" ] || continue
Expand All @@ -887,7 +887,35 @@ status_commit_presentation_snapshot() { # <state> <snapshot>
done <<EOF
$snapshot
EOF
mv -f "$tmp" "$state/.status-presentation-cursor" || { rm -f "$tmp"; return 1; }
mv -f "$tmp" "$target" || { rm -f "$tmp"; return 1; }
}

status_merge_presentation_cursor() { # <state> <staged-cursor>
local state=$1 staged=$2 cursor lock tmp idents f task ident
cursor="$state/.status-presentation-cursor"; lock="$state/.status-presentation-lock"
[ -f "$staged" ] && [ ! -L "$staged" ] || return 1
fm_lock_acquire_wait "$lock" || return 1
tmp="$cursor.tmp.$$"
if [ -e "$cursor" ] || [ -L "$cursor" ]; then
[ -f "$cursor" ] && [ ! -L "$cursor" ] || { fm_lock_release "$lock"; return 1; }
else
: > "$cursor" || { fm_lock_release "$lock"; return 1; }
fi
idents="$tmp.idents"; : > "$idents" || { fm_lock_release "$lock"; return 1; }
for f in "$state"/*.status; do
[ -f "$f" ] && [ ! -L "$f" ] || continue
task=${f##*/}; task=${task%.status}; ident=$(_fm_open_decisions_file_ident "$f") || continue
printf '%s\t%s\n' "$task" "$ident" >> "$idents"
done
if ! awk -F '\t' 'FILENAME == ARGV[1] { live[$1] = $2; next }
FILENAME == ARGV[2] { staged[$1] = $0; next }
{ split(staged[$1], fields, FS); if ($1 in staged && fields[2] == live[$1]) { if (fields[2] == $2 && fields[3] + 0 < $3 + 0) print; else print fields[1] FS fields[2] FS fields[3]; delete staged[$1] } else print }
END { for (task in staged) { split(staged[task], fields, FS); if (fields[2] == live[task]) print staged[task] } }' "$idents" "$staged" "$cursor" > "$tmp" \
|| ! mv -f "$tmp" "$cursor"; then
rm -f "$tmp" "$idents"; fm_lock_release "$lock"; return 1
fi
rm -f "$idents"
fm_lock_release "$lock"
}

scan_open_decisions_snapshot() { # <state> <task-and-endpoint-snapshot>
Expand Down
8 changes: 7 additions & 1 deletion bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,13 @@ if [ "$ACTIONABLE" -eq 1 ]; then
{
printf 'firstmate watcher wake - one supervision event needs a handling turn now.\n'
[ -n "$OUT" ] && grep -E '^(signal:|stale:|check:|heartbeat)' "$OUT" 2>/dev/null | head -8
printf 'Run bin/fm-wake-drain.sh first, handle the wake, then run its exact WAKE_ACK_REQUIRED --ack-through command. Until that post-handling acknowledgement, interruption leaves the wake durable for idempotent re-handling. This Stop hook owns watcher continuity: when the handling turn ends, the next needed cycle arms automatically - do NOT run bin/fm-watch-arm.sh after an ordinary wake.\n'
CONTEXT_OUT=$("$SCRIPT_DIR/fm-wake-context.sh" --present 2>&1)
CONTEXT_RC=$?
[ -z "$CONTEXT_OUT" ] || printf '%s\n' "$CONTEXT_OUT"
if [ "$CONTEXT_RC" -ne 0 ] && ! printf '%s\n' "$CONTEXT_OUT" | grep -F 'WAKE_CONTEXT_FALLBACK:' >/dev/null; then
printf 'WAKE_CONTEXT_FALLBACK: run bin/fm-wake-drain.sh once.\n'
fi
printf 'Handle the attached presentation without rebuilding fleet context. After handling, run its exact acknowledgement command. This Stop hook owns watcher continuity: when the handling turn ends, the next needed cycle arms automatically - do NOT run bin/fm-watch-arm.sh after an ordinary wake.\n'
} >&2
[ -z "$OUT" ] || rm -f "$OUT" 2>/dev/null || true
exit 2
Expand Down
6 changes: 3 additions & 3 deletions bin/fm-supervision-instructions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,13 @@ repair_line() {
ordinary_wake_line() {
case "$HARNESS" in
claude)
printf '%s\n' '- Ordinary wake: the Stop-owned auto-arm (bin/fm-claude-stop-autoarm.sh) already owns watcher continuity; drain and handle the wake, and do not arm another cycle yourself.'
printf '%s\n' '- Ordinary wake: handle the attached fm-wake-context.v1 packet and run its acknowledgement after handling; the Stop-owned auto-arm (bin/fm-claude-stop-autoarm.sh) already owns watcher continuity, so do not arm another cycle yourself.'
;;
codex)
printf '%s\n' '- Ordinary wake: take the next foreground bin/fm-watch-checkpoint.sh checkpoint as directed below.'
printf '%s\n' '- Ordinary wake: handle the checkpoint-attached fm-wake-context.v1 packet and run its acknowledgement after handling, then take the next foreground bin/fm-watch-checkpoint.sh checkpoint as directed below.'
;;
pi|pi-signed)
printf '%s\n' '- Ordinary wake: the Pi extension already owns watcher continuity; do not arm another cycle.'
printf '%s\n' '- Ordinary wake: handle the Pi-attached fm-wake-context.v1 packet and run its acknowledgement after handling; the Pi extension already owns watcher continuity, so do not arm another cycle.'
;;
opencode)
printf '%s\n' '- Ordinary wake: the OpenCode TUI plugin already owns watcher continuity; do not arm manually.'
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ family_for_basename() {
fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-pi-watch-extension.test.sh|\
fm-session-lock-ancestry.test.sh|fm-cursor-primary.test.sh|\
fm-supervision-events.test.sh|fm-turnend-guard.test.sh|fm-wake-daemon-lifecycle-e2e.test.sh|\
fm-wake-drain-unread-status.test.sh|\
fm-wake-context.test.sh|fm-wake-drain-unread-status.test.sh|\
fm-tool-update-check.test.sh|\
fm-wake-queue.test.sh|fm-watch-arm.test.sh|fm-watch-checkpoint.test.sh|fm-watch-recovery-loop.test.sh|\
fm-watch-triage.test.sh|\
Expand Down Expand Up @@ -489,6 +489,7 @@ tests/fm-turnend-guard.test.sh 34915
tests/fm-update.test.sh 5280
tests/fm-vendor-auth-probe.test.sh 43243
tests/fm-wake-daemon-lifecycle-e2e.test.sh 6219
tests/fm-wake-context.test.sh 1500
tests/fm-wake-drain-open-decisions-cursor.test.sh 17357
tests/fm-wake-drain-open-decisions.test.sh 11300
tests/fm-wake-drain-unread-status.test.sh 25214
Expand Down
Loading
Loading