diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 102071b58e..f5b3c01949 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, PI_QUESTION_RECOVERY, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -43,6 +43,8 @@ When any diagnostic needs captain attention, report the plain consequence and re Resume the emitted supervision protocol after finishing the session-start wake handling. - Any other `PR_CHECK_MIGRATION:` refusal means migration did not complete safely, whether because watcher exclusion, a private path, a diagnostic, quarantine validation, or marker publication could not be proved. Keep each affected poll unavailable, inspect the named private state path, and do not bypass the migration or execute a quarantined artifact; a completed safe-scan marker allows unrelated authenticated polls to continue while private repair remains pending. +- `PI_QUESTION_RECOVERY: failed: ` - one or more pending Pi question records could not be reconciled before the wake queue was drained. + Inspect the named private record, keep late answers unavailable, and rerun `FM_HOME= bin/fm-pi-question-recover.sh --all` after correcting the concrete filesystem, process-identity, or schema problem. - `SECONDMATE_SYNC: secondmate : skipped: ` - the local-HEAD secondmate sync left a live secondmate home on its existing checkout because the home was dirty, diverged, unsafe, on the wrong branch, missing the primary target commit, or otherwise not fast-forwardable, or because inherited local-material propagation failed; bootstrap continued, but inspect the reason because the secondmate's tracked instructions, inherited settings, or shared captain preferences may be stale after a primary update. - `SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed: ` - the session-start liveness sweep could not guarantee that a live secondmate's recorded endpoint is running a real agent process. Investigate the reason because that secondmate is not guaranteed live. diff --git a/.agents/skills/pi-supervised-question/SKILL.md b/.agents/skills/pi-supervised-question/SKILL.md new file mode 100644 index 0000000000..e36f75fc03 --- /dev/null +++ b/.agents/skills/pi-supervised-question/SKILL.md @@ -0,0 +1,39 @@ +--- +name: pi-supervised-question +description: >- + Agent-only procedure for a Pi crew question wake whose status names state/questions//.request.json or key=ask-, including a repeated or stale question after recovery. +user-invocable: false +metadata: + internal: true +--- + +# Pi supervised question + +Use this only for a Pi crew's versioned supervised-question request. +Ordinary `needs-decision` events continue to follow the existing task and validation lifecycle. + +## Handle the request + +1. Drain the durable wake queue before reading the request, as required for every wake-handling turn. +2. Derive the task ID and call ID only from the validated relative path in the status event, then read `state/questions//.request.json` under the active `FM_HOME`. +3. Confirm that `state/.meta` still records `harness=pi` and that the request's `taskId`, `callId`, `protocolVersion`, and deadline match the event and current task. +4. Treat the request questions as the pending decision artifact, not as permission to answer them. + Existing authority still applies, so escalate captain-owned, destructive, irreversible, or security-sensitive choices and wait for the answer. +5. Build one `answers` entry per question in request order using the exact selected option label. +6. Publish through the guarded helper with answer JSON on standard input: + + ```sh + printf '%s\n' '' | FM_HOME= bin/fm-pi-answer.sh + ``` + +7. Never use `fm-send`, chat injection, direct response-file writes, or an answer to a previous call ID. + The agent is blocked inside the same tool call, so only the bridge response can resume it safely. +8. Verify that the status log appended `resolved: Pi question answered [key=ask-]`, then reconcile the crew's current state and resume the normal supervision cycle. + +## Stale or recovered calls + +If the guarded helper reports that the owner, bridge, deadline, or resolution is stale, do not retry that call ID. +Run `FM_HOME= bin/fm-pi-question-recover.sh `, confirm the old request is terminal, and wait for the resumed Pi agent to reissue the question with a new call ID. +An already recorded answer may be summarized through the resumed agent's normal context, but it is never copied into a new response without applying current authority and validating the new options. + +Completion requires either a matching keyed `resolved` event followed by continued crew execution, or a concrete captain escalation that preserves the still-pending call without chat injection. diff --git a/AGENTS.md b/AGENTS.md index 4f37db5cd7..fb1c3891bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ state/ volatile runtime signals; gitignored .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + questions/ private versioned Pi supervised-question requests, responses, owner identities, resolutions, and locks; docs/pi-supervised-questions.md owns the schema and lifecycle .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check .pr-poll private validated data sidecar for the byte-static PR merge poll @@ -129,7 +130,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state. 2. **Bootstrap** - detect-only checks (tool/version problems, GitHub auth, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run, but routine confirmations stay silent by default. When the lock could not be acquired, the worktree-tangle check uses read-only advisory wording without a checkout repair command. - The five MUTATING sweeps - non-executing legacy PR-check migration, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. + The six MUTATING sweeps - non-executing legacy PR-check migration, Pi question recovery, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. The secondmate liveness sweep deterministically guarantees every registered secondmate is actually running: it probes each live secondmate's endpoint for a real agent process (not just pane presence), respawns only on a confident dead reading, and reports only skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_alive`). 3. **Wake queue** - when locked, drains the durable wake queue and prints the raw records prominently as this turn's first work queue; a bounded, clearly labeled historical status-event annotation may follow a valid `signal` record but never replaces it or current-state reconciliation, and a lapsed watcher chain still surfaces here via the same guard alarm. When the lock could not be acquired, the queue is left untouched because another session owns it, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. @@ -326,6 +327,7 @@ A declared `paused:` event means a bounded external wait expected to clear on it Handle actionable wakes as follows: 1. For `signal:`, read the listed event lines first, then reconcile current state only where action depends on it. + When a Pi `needs-decision` event names `state/questions//.request.json` or `key=ask-`, load `pi-supervised-question` and answer only through its guarded bridge procedure. 2. For `stale:`, inspect the recorded endpoint and load `stuck-crewmate-recovery` for a stopped, looping, confused, or unresponsive worker; a deep-inspection reason also requires current-state and validation-log inspection. 3. For `check:`, act on the named poll result, including merges and X-mode events. 4. For `heartbeat:`, review the whole fleet from the structured fleet view, reconcile suspicious tasks and PR state, update the backlog, and never report an unchanged fleet as progress. @@ -449,7 +451,7 @@ It performs guarded fast-forward updates of firstmate and registered secondmate These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `PI_QUESTION_RECOVERY:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. @@ -460,6 +462,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. +- `pi-supervised-question` - load when a Pi `needs-decision` wake names a versioned `state/questions/` request or `key=ask-`, including stale or reissued calls after recovery. ## 14. X mode diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 2bd11baa43..635faa206d 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -11,6 +11,7 @@ # "CREW_DISPATCH: invalid config/crew-dispatch.json - ", # "FLEET_SYNC: : skipped|recovered|STUCK: ", # "PR_CHECK_MIGRATION: ", +# "PI_QUESTION_RECOVERY: failed: ", # "TANGLE: ", # "SECONDMATE_SYNC: secondmate : skipped: ", # "NUDGE_SECONDMATES: secondmate : send failed: ", @@ -66,9 +67,10 @@ # refresh relays any completed fm-fleet-sync.sh output before the # aggregate timeout skip line with timeout and elapsed seconds. # Set FM_FLEET_PRUNE=0 to skip branch pruning during that refresh. -# Set FM_BOOTSTRAP_DETECT_ONLY=1 to skip the five MUTATING sweeps -# (PR-check migration, secondmate_sync, secondmate_liveness_sweep, -# x_mode_setup, fleet_sync) while still printing every read-only detect line +# Set FM_BOOTSTRAP_DETECT_ONLY=1 to skip the six MUTATING sweeps +# (PR-check migration, Pi question recovery, secondmate_sync, +# secondmate_liveness_sweep, x_mode_setup, fleet_sync) while still +# printing every read-only detect line # above; the TANGLE line switches to advisory-only wording with no # checkout command. Used by # fm-session-start.sh's read-only path when another live session holds @@ -800,6 +802,9 @@ if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ echo "BOOTSTRAP_INFO: tasks-axi available" fi if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then + if ! recovery_error=$(FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-pi-question-recover.sh" --all 2>&1); then + echo "PI_QUESTION_RECOVERY: failed: ${recovery_error:-unknown recovery error}" + fi secondmate_sync secondmate_liveness_sweep x_mode_setup diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 93fab87382..459c12960b 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -124,9 +124,14 @@ status_is_paused_or_captain_held() { # # terminal line never clears an open captain decision. # # Decision key grammar (backward-compatible with the existing ": " -# format): an OPTIONAL "[key=]" token sits between the verb and the colon, +# format): an OPTIONAL "[key=]" token normally sits between the verb and the colon, # needs-decision [key=api-shape]: # resolved [key=api-shape]: +# The Pi supervised-question wire protocol is the one recognized suffix form, +# so its bounded wake line ends with the call identity without making arbitrary +# note prose look like status control syntax: +# needs-decision: [key=ask-] +# resolved: Pi question answered [key=ask-] # A line with no token uses the key "default", preserving the historical # one-open-decision-per-task behavior (a bare "resolved:" closes "default"). # The three parsers are pure reads of a single line; the verb parser strips any @@ -145,7 +150,7 @@ status_line_note() { # -> text after the first colon, trimmed esac } _fm_decision_key() { # -> key slug, or "default" when no token - local prefix=${1%%:*} k + local line=$1 prefix=${1%%:*} k case "$prefix" in *\[key=*\]*) k=${prefix#*\[key=} @@ -155,7 +160,19 @@ _fm_decision_key() { # -> key slug, or "default" when no token *) printf '%s' "$k" ;; esac ;; - *) printf 'default' ;; + *) + case "$line" in + needs-decision:\ Pi\ question\ request\ state/questions/*\ \[key=ask-*\]|resolved:\ Pi\ question\ *\ \[key=ask-*\]) + k=${line##* [key=} + k=${k%]} + case "$k" in + ''|*[!A-Za-z0-9._-]*) return 1 ;; + *) printf '%s' "$k" ;; + esac + ;; + *) printf 'default' ;; + esac + ;; esac } # Drop the record for from a newline-terminated "\t\t" set. diff --git a/bin/fm-pi-answer.sh b/bin/fm-pi-answer.sh new file mode 100755 index 0000000000..d53ddf6d1a --- /dev/null +++ b/bin/fm-pi-answer.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Guarded Firstmate answer publisher for a pending Pi supervised question. +# Usage: printf '%s\n' '{"answers":[...]}' | FM_HOME= fm-pi-answer.sh +# The answer shape and lifecycle are owned by docs/pi-supervised-questions.md. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ "${1:-}" = -h ] || [ "${1:-}" = --help ]; then + sed -n '2,4p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +fi +# shellcheck source=bin/fm-pi-question-lib.sh +. "$SCRIPT_DIR/fm-pi-question-lib.sh" + +fail() { + printf 'error: %s\n' "$1" >&2 + exit "${2:-1}" +} + +[ "$#" -eq 2 ] || fail "usage: fm-pi-answer.sh (answer JSON on stdin)" 2 +[ -n "${FM_HOME:-}" ] || fail "FM_HOME must be explicit" 2 +case "$FM_HOME" in /*) : ;; *) fail "FM_HOME must be absolute" 2 ;; esac +[ -d "$FM_HOME" ] || fail "FM_HOME is not a directory" 2 +TASK_ID=$1 +CALL_ID=$2 +fm_pi_question_id_valid "$TASK_ID" || fail "invalid task ID" 2 +fm_pi_question_id_valid "$CALL_ID" || fail "invalid call ID" 2 +command -v jq >/dev/null 2>&1 || fail "jq is required" + +STATE="$FM_HOME/state" +[ -d "$STATE" ] && [ ! -L "$STATE" ] || fail "state directory is unavailable" +fm_pi_question_task_is_pi "$STATE" "$TASK_ID" || fail "task is not owned by a Pi crew" +QUESTION_DIR="$STATE/questions/$TASK_ID" +[ -d "$QUESTION_DIR" ] && [ ! -L "$QUESTION_DIR" ] || fail "no pending Pi question for task $TASK_ID" +REQUEST="$QUESTION_DIR/$CALL_ID.request.json" +RESPONSE="$QUESTION_DIR/$CALL_ID.response.json" +OWNER="$QUESTION_DIR/$CALL_ID.owner.json" +RESOLUTION="$QUESTION_DIR/$CALL_ID.resolution.json" +[ -f "$REQUEST" ] && [ ! -L "$REQUEST" ] || fail "request $CALL_ID is absent" +[ -f "$OWNER" ] && [ ! -L "$OWNER" ] || fail "request $CALL_ID has no owner identity" +fm_pi_question_request_valid "$REQUEST" || fail "request $CALL_ID is malformed" +fm_pi_question_owner_valid "$OWNER" "$REQUEST" || fail "request $CALL_ID has a malformed owner identity" +[ "$(jq -r '.taskId' "$REQUEST")" = "$TASK_ID" ] || fail "request task ownership does not match" +[ "$(jq -r '.callId' "$REQUEST")" = "$CALL_ID" ] || fail "request call ID does not match" + +umask 077 +ANSWER_INPUT=$(mktemp "${TMPDIR:-/tmp}/fm-pi-answer.XXXXXXXXXX") || fail "cannot create answer buffer" +cleanup() { + fm_pi_question_lock_release + rm -f "$ANSWER_INPUT" +} +trap cleanup EXIT +cat > "$ANSWER_INPUT" || fail "cannot read answer JSON" + +ANSWERED_AT=$(fm_pi_question_now_iso) || fail "cannot create answer timestamp" +RESPONSE_JSON=$(jq -ceS \ + --argjson version "$FM_PI_ASK_PROTOCOL_VERSION" \ + --arg taskId "$TASK_ID" \ + --arg callId "$CALL_ID" \ + --arg answeredAt "$ANSWERED_AT" \ + 'if type == "object" and ((keys_unsorted - ["answers"]) | length == 0) and (.answers | type == "array") + then {protocolVersion: $version, taskId: $taskId, callId: $callId, answeredAt: $answeredAt, answers: .answers} + else error("invalid answer envelope") end' "$ANSWER_INPUT" 2>/dev/null) \ + || fail "answer must be one JSON object containing only answers" +VALIDATION_INPUT=$(mktemp "$QUESTION_DIR/.pi-answer.XXXXXXXXXX") || fail "cannot create validation buffer" +printf '%s\n' "$RESPONSE_JSON" > "$VALIDATION_INPUT" +chmod 0600 "$VALIDATION_INPUT" +fm_pi_question_response_valid "$VALIDATION_INPUT" "$REQUEST" || { + rm -f "$VALIDATION_INPUT" + fail "answer does not select exactly one listed label for each question in input order" +} +rm -f "$VALIDATION_INPUT" + +fm_pi_question_lock_acquire "$QUESTION_DIR" "$CALL_ID" || fail "question state is busy" +[ ! -e "$RESOLUTION" ] || fail "request $CALL_ID is already resolved; stale answers are refused" +[ ! -e "$RESPONSE" ] || fail "request $CALL_ID already has an answer" +fm_pi_question_owner_process_alive "$OWNER" owner || fail "owning Pi process is no longer alive; stale answers are refused" +fm_pi_question_owner_process_alive "$OWNER" bridge || fail "question bridge is no longer alive; stale answers are refused" +DEADLINE=$(jq -r '.deadline' "$REQUEST") +DEADLINE_EPOCH=$(fm_pi_question_iso_epoch "$DEADLINE") || fail "request deadline is invalid" +[ "$(date +%s)" -lt "$DEADLINE_EPOCH" ] || fail "request deadline has expired; stale answers are refused" +fm_pi_question_atomic_write "$RESPONSE_JSON" "$RESPONSE" || fail "cannot publish answer" +fm_pi_question_lock_release +printf 'answered state/questions/%s/%s.response.json\n' "$TASK_ID" "$CALL_ID" diff --git a/bin/fm-pi-ask-bridge.sh b/bin/fm-pi-ask-bridge.sh new file mode 100755 index 0000000000..2bf6e31870 --- /dev/null +++ b/bin/fm-pi-ask-bridge.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# Publish one Pi supervised-question request and block until its matching answer. +# Usage: FM_HOME= FM_TASK_ID= FM_INTERACTION_MODE=supervised \ +# FM_ASK_BRIDGE= fm-pi-ask-bridge.sh < request.json +# Stdout is reserved for the exact tool result: {"answers":[...]}. +# Errors are bounded and written to stderr with stable ASK_USER_* codes. +# The complete protocol and state paths are owned by docs/pi-supervised-questions.md. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ "${1:-}" = -h ] || [ "${1:-}" = --help ]; then + sed -n '2,7p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +fi +[ "$#" -eq 0 ] || { echo "error: fm-pi-ask-bridge.sh takes request JSON on stdin" >&2; exit 2; } +# shellcheck source=bin/fm-pi-question-lib.sh +. "$SCRIPT_DIR/fm-pi-question-lib.sh" + +fail() { + printf '%s: %s\n' "$1" "$2" >&2 + exit "${3:-1}" +} + +[ "${FM_INTERACTION_MODE:-}" = supervised ] || fail ASK_USER_UNAVAILABLE "FM_INTERACTION_MODE must be supervised" +[ -n "${FM_HOME:-}" ] || fail ASK_USER_UNAVAILABLE "FM_HOME is required" +case "$FM_HOME" in /*) : ;; *) fail ASK_USER_UNAVAILABLE "FM_HOME must be absolute" ;; esac +[ -n "${FM_TASK_ID:-}" ] || fail ASK_USER_UNAVAILABLE "FM_TASK_ID is required" +case "${FM_ASK_BRIDGE:-}" in /*) : ;; *) fail ASK_USER_UNAVAILABLE "FM_ASK_BRIDGE must be absolute" ;; esac +[ -d "$FM_HOME" ] || fail ASK_USER_UNAVAILABLE "FM_HOME is not a directory" +fm_pi_question_id_valid "$FM_TASK_ID" || fail ASK_USER_INVALID_REQUEST "invalid task ID" + +STATE="$FM_HOME/state" +[ -d "$STATE" ] && [ ! -L "$STATE" ] || fail ASK_USER_UNAVAILABLE "state directory is unavailable" +fm_pi_question_task_is_pi "$STATE" "$FM_TASK_ID" || fail ASK_USER_UNAVAILABLE "task is not owned by a Pi crew" +command -v jq >/dev/null 2>&1 || fail ASK_USER_UNAVAILABLE "jq is required" + +umask 077 +REQUEST_INPUT=$(mktemp "${TMPDIR:-/tmp}/fm-pi-question.XXXXXXXXXX") || fail ASK_USER_BRIDGE_ERROR "cannot create request buffer" +cleanup() { + fm_pi_question_lock_release + rm -f "$REQUEST_INPUT" +} +trap cleanup EXIT +cat > "$REQUEST_INPUT" || fail ASK_USER_INVALID_REQUEST "cannot read request" +fm_pi_question_request_valid "$REQUEST_INPUT" || fail ASK_USER_INVALID_REQUEST "request does not match protocol v1" + +TASK_ID=$(jq -r '.taskId' "$REQUEST_INPUT") +CALL_ID=$(jq -r '.callId' "$REQUEST_INPUT") +[ "$TASK_ID" = "$FM_TASK_ID" ] || fail ASK_USER_INVALID_REQUEST "request task ID does not match launch envelope" +fm_pi_question_id_valid "$CALL_ID" || fail ASK_USER_INVALID_REQUEST "invalid call ID" + +CREATED_AT=$(jq -r '.createdAt' "$REQUEST_INPUT") +DEADLINE=$(jq -r '.deadline' "$REQUEST_INPUT") +CREATED_EPOCH=$(fm_pi_question_iso_epoch "$CREATED_AT") || fail ASK_USER_INVALID_REQUEST "createdAt is invalid" +DEADLINE_EPOCH=$(fm_pi_question_iso_epoch "$DEADLINE") || fail ASK_USER_INVALID_REQUEST "deadline is invalid" +NOW=$(date +%s) +MAX_WAIT=${FM_PI_ASK_MAX_WAIT_SECS:-86400} +case "$MAX_WAIT" in ''|*[!0-9]*) MAX_WAIT=86400 ;; esac +[ "$CREATED_EPOCH" -le $((NOW + 60)) ] || fail ASK_USER_INVALID_REQUEST "createdAt is in the future" +[ "$DEADLINE_EPOCH" -gt "$NOW" ] || fail ASK_USER_TIMEOUT "deadline has passed" +[ "$DEADLINE_EPOCH" -gt "$CREATED_EPOCH" ] || fail ASK_USER_INVALID_REQUEST "deadline must follow createdAt" +[ "$DEADLINE_EPOCH" -le $((NOW + MAX_WAIT)) ] || fail ASK_USER_INVALID_REQUEST "deadline exceeds the supervised wait limit" + +QUESTION_DIR=$(fm_pi_question_prepare_directory "$STATE" "$TASK_ID") || fail ASK_USER_BRIDGE_ERROR "cannot prepare private question directory" +REQUEST="$QUESTION_DIR/$CALL_ID.request.json" +RESPONSE="$QUESTION_DIR/$CALL_ID.response.json" +OWNER="$QUESTION_DIR/$CALL_ID.owner.json" +RESOLUTION="$QUESTION_DIR/$CALL_ID.resolution.json" + +fm_pi_question_lock_acquire "$QUESTION_DIR" "$CALL_ID" || fail ASK_USER_BRIDGE_ERROR "question state is busy" +if [ -e "$REQUEST" ] || [ -e "$RESPONSE" ] || [ -e "$OWNER" ] || [ -e "$RESOLUTION" ]; then + fail ASK_USER_STALE_CALL "call ID has already been published; reissue with a new call ID" +fi + +OWNER_START=$(fm_pi_question_process_start "$PPID") || fail ASK_USER_BRIDGE_ERROR "cannot identify owning Pi process" +BRIDGE_START=$(fm_pi_question_process_start "$$") || fail ASK_USER_BRIDGE_ERROR "cannot identify bridge process" +OWNER_JSON=$(jq -cnS \ + --argjson protocolVersion "$FM_PI_ASK_PROTOCOL_VERSION" \ + --arg taskId "$TASK_ID" \ + --arg callId "$CALL_ID" \ + --argjson ownerPid "$PPID" \ + --arg ownerStart "$OWNER_START" \ + --argjson bridgePid "$$" \ + --arg bridgeStart "$BRIDGE_START" \ + '{protocolVersion: $protocolVersion, taskId: $taskId, callId: $callId, ownerPid: $ownerPid, ownerStart: $ownerStart, bridgePid: $bridgePid, bridgeStart: $bridgeStart}') \ + || fail ASK_USER_BRIDGE_ERROR "cannot encode owner identity" +REQUEST_JSON=$(jq -cS . "$REQUEST_INPUT") || fail ASK_USER_INVALID_REQUEST "cannot canonicalize request" +fm_pi_question_atomic_write "$OWNER_JSON" "$OWNER" || fail ASK_USER_BRIDGE_ERROR "cannot publish owner identity" +fm_pi_question_atomic_write "$REQUEST_JSON" "$REQUEST" || fail ASK_USER_BRIDGE_ERROR "cannot publish request" +fm_pi_question_lock_release + +fm_pi_question_append_status "$STATE" "$TASK_ID" \ + "needs-decision: Pi question request state/questions/$TASK_ID/$CALL_ID.request.json [key=ask-$CALL_ID]" \ + || fail ASK_USER_BRIDGE_ERROR "cannot publish decision status" + +CANCELLED=0 +trap 'CANCELLED=1' HUP INT TERM + +resolve_failure() { + local status=$1 detail=$2 code=$3 message=$4 resolution_json + fm_pi_question_lock_acquire "$QUESTION_DIR" "$CALL_ID" || fail ASK_USER_BRIDGE_ERROR "question state is busy" + if [ -f "$RESOLUTION" ]; then + fm_pi_question_lock_release + fail "$code" "$message" + fi + resolution_json=$(fm_pi_question_resolution_json "$TASK_ID" "$CALL_ID" "$status" "$detail") \ + || fail ASK_USER_BRIDGE_ERROR "cannot encode resolution" + fm_pi_question_atomic_write "$resolution_json" "$RESOLUTION" \ + || fail ASK_USER_BRIDGE_ERROR "cannot publish resolution" + fm_pi_question_lock_release + fm_pi_question_append_status "$STATE" "$TASK_ID" \ + "resolved: Pi question $detail [key=ask-$CALL_ID]" || true + fail "$code" "$message" +} + +consume_response() { + local result resolution_json + fm_pi_question_lock_acquire "$QUESTION_DIR" "$CALL_ID" || fail ASK_USER_BRIDGE_ERROR "question state is busy" + if [ -f "$RESOLUTION" ]; then + fm_pi_question_lock_release + return 1 + fi + if ! fm_pi_question_response_valid "$RESPONSE" "$REQUEST"; then + fm_pi_question_lock_release + resolve_failure invalid-response "answer was invalid" ASK_USER_MALFORMED_RESPONSE "response does not match the pending request" + fi + resolution_json=$(fm_pi_question_resolution_json "$TASK_ID" "$CALL_ID" answered "answered") \ + || fail ASK_USER_BRIDGE_ERROR "cannot encode resolution" + fm_pi_question_atomic_write "$resolution_json" "$RESOLUTION" \ + || fail ASK_USER_BRIDGE_ERROR "cannot publish resolution" + result=$(jq -cS '{answers: .answers}' "$RESPONSE") || fail ASK_USER_BRIDGE_ERROR "cannot encode tool result" + fm_pi_question_lock_release + fm_pi_question_append_status "$STATE" "$TASK_ID" \ + "resolved: Pi question answered [key=ask-$CALL_ID]" \ + || fail ASK_USER_BRIDGE_ERROR "cannot publish resolution status" + printf '%s\n' "$result" + exit 0 +} + +fail_from_resolution() { + local status + status=$(jq -er '.status' "$RESOLUTION" 2>/dev/null) \ + || fail ASK_USER_BRIDGE_ERROR "terminal resolution is malformed" + case "$status" in + cancelled) fail ASK_USER_CANCELLED "question was cancelled" ;; + timed-out) fail ASK_USER_TIMEOUT "question deadline expired" ;; + abandoned) fail ASK_USER_ABANDONED "question owner or bridge exited" ;; + invalid-request) fail ASK_USER_INVALID_REQUEST "request was invalidated during recovery" ;; + invalid-response) fail ASK_USER_MALFORMED_RESPONSE "response was invalidated during recovery" ;; + answered) fail ASK_USER_BRIDGE_ERROR "answered resolution exists without a consumable response" ;; + *) fail ASK_USER_BRIDGE_ERROR "unknown terminal resolution" ;; + esac +} + +wait_for_change() { + local seconds=$1 notifier_pid='' notifier_start timer_pid timer_start + if command -v fswatch >/dev/null 2>&1; then + fswatch -1 "$QUESTION_DIR" >/dev/null 2>&1 & + notifier_pid=$! + elif command -v inotifywait >/dev/null 2>&1; then + inotifywait -q -e close_write,moved_to,create "$QUESTION_DIR" >/dev/null 2>&1 & + notifier_pid=$! + else + sleep "$seconds" + return + fi + notifier_start=$(fm_pi_question_process_start "$notifier_pid") || { + kill "$notifier_pid" 2>/dev/null || true + wait "$notifier_pid" 2>/dev/null || true + sleep "$seconds" + return + } + ( + sleep "$seconds" + if fm_pi_question_process_matches "$notifier_pid" "$notifier_start"; then + kill "$notifier_pid" 2>/dev/null || true + fi + ) & + timer_pid=$! + timer_start=$(fm_pi_question_process_start "$timer_pid" 2>/dev/null || true) + wait "$notifier_pid" 2>/dev/null || true + if fm_pi_question_process_matches "$notifier_pid" "$notifier_start"; then + kill "$notifier_pid" 2>/dev/null || true + wait "$notifier_pid" 2>/dev/null || true + fi + if [ -n "$timer_start" ] && fm_pi_question_process_matches "$timer_pid" "$timer_start"; then + kill "$timer_pid" 2>/dev/null || true + fi + wait "$timer_pid" 2>/dev/null || true +} + +POLL=${FM_PI_ASK_POLL_SECONDS:-1} +if ! jq -en --arg value "$POLL" '($value | tonumber) > 0' >/dev/null 2>&1; then + POLL=1 +fi +while :; do + [ "$CANCELLED" -eq 0 ] || resolve_failure cancelled "was cancelled" ASK_USER_CANCELLED "question was cancelled" + [ ! -f "$RESOLUTION" ] || fail_from_resolution + if [ -f "$RESPONSE" ]; then + consume_response + fi + if ! fm_pi_question_owner_process_alive "$OWNER" owner; then + resolve_failure abandoned "was abandoned" ASK_USER_ABANDONED "owning Pi process exited" + fi + NOW=$(date +%s) + [ "$NOW" -lt "$DEADLINE_EPOCH" ] || resolve_failure timed-out "timed out" ASK_USER_TIMEOUT "question deadline expired" + wait_for_change "$POLL" +done diff --git a/bin/fm-pi-question-lib.sh b/bin/fm-pi-question-lib.sh new file mode 100755 index 0000000000..eb9aff912a --- /dev/null +++ b/bin/fm-pi-question-lib.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# Shared protocol, validation, locking, and atomic-file helpers for the +# Firstmate-owned Pi supervised-question bridge. +# +# The complete wire and state schema is owned by docs/pi-supervised-questions.md. +# Callers must validate task and call IDs before deriving paths from them. + +FM_PI_ASK_PROTOCOL_VERSION=1 +FM_PI_ASK_LOCK_ATTEMPTS=${FM_PI_ASK_LOCK_ATTEMPTS:-100} +FM_PI_ASK_LOCK_SLEEP=${FM_PI_ASK_LOCK_SLEEP:-0.05} +FM_PI_ASK_LOCK_DIR= +FM_PI_ASK_LOCK_PID= +FM_PI_ASK_LOCK_START= + +fm_pi_question_id_valid() { + local id=${1:-} LC_ALL=C + case "$id" in + ''|.*|-*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + [ "${#id}" -le 64 ] +} + +fm_pi_question_now_iso() { + jq -nr 'now | floor | todateiso8601' +} + +fm_pi_question_iso_epoch() { + jq -enr --arg value "$1" '$value | fromdateiso8601' +} + +fm_pi_question_process_start() { + local pid=$1 value + value=$(LC_ALL=C ps -o lstart= -p "$pid" 2>/dev/null) || return 1 + value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + [ -n "$value" ] || return 1 + printf '%s\n' "$value" +} + +fm_pi_question_process_matches() { + local pid=$1 expected=$2 observed + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + [ "$pid" -gt 1 ] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + observed=$(fm_pi_question_process_start "$pid") || return 1 + [ "$observed" = "$expected" ] +} + +fm_pi_question_request_valid() { + local request=$1 + jq -e --argjson version "$FM_PI_ASK_PROTOCOL_VERSION" ' + type == "object" + and ((keys_unsorted - ["protocolVersion", "taskId", "callId", "createdAt", "deadline", "questions"]) | length == 0) + and .protocolVersion == $version + and (.taskId | type == "string" and length >= 1 and length <= 64 and test("^[A-Za-z0-9_][A-Za-z0-9._-]*$")) + and (.callId | type == "string" and length >= 1 and length <= 64 and test("^[A-Za-z0-9_][A-Za-z0-9._-]*$")) + and (.createdAt | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) + and (.deadline | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) + and (.questions | type == "array" and length >= 1 and length <= 4) + and ([.questions[].id] | length == (unique | length)) + and all(.questions[]; + type == "object" + and ((keys_unsorted - ["id", "header", "question", "options"]) | length == 0) + and (.id | type == "string" and length >= 1 and length <= 64 and test("^[A-Za-z0-9_][A-Za-z0-9._-]*$")) + and (.header | type == "string" and utf8bytelength >= 1 and utf8bytelength <= 40) + and (.question | type == "string" and utf8bytelength >= 1 and utf8bytelength <= 500) + and (.options | type == "array" and length >= 2 and length <= 4) + and ([.options[].label] | length == (unique | length)) + and all(.options[]; + type == "object" + and ((keys_unsorted - ["label", "description"]) | length == 0) + and (.label | type == "string" and utf8bytelength >= 1 and utf8bytelength <= 100) + and (.description | type == "string" and utf8bytelength <= 500) + ) + ) + ' "$request" >/dev/null 2>&1 +} + +fm_pi_question_response_valid() { + local response=$1 request=$2 answered_at created_at deadline answered_epoch created_epoch deadline_epoch now + jq -e --argjson version "$FM_PI_ASK_PROTOCOL_VERSION" --slurpfile request "$request" ' + . as $response + | type == "object" + and ((keys_unsorted - ["protocolVersion", "taskId", "callId", "answeredAt", "answers"]) | length == 0) + and .protocolVersion == $version + and .taskId == $request[0].taskId + and .callId == $request[0].callId + and (.answeredAt | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) + and (.answers | type == "array" and length == ($request[0].questions | length)) + and ([range(0; ($response.answers | length)) as $index + | $response.answers[$index] as $answer + | $request[0].questions[$index] as $question + | ($answer | type == "object") + and (($answer | keys_unsorted) - ["questionId", "selectedLabel"] | length == 0) + and ($answer.questionId == $question.id) + and ($answer.selectedLabel | type == "string") + and any($question.options[]; .label == $answer.selectedLabel) + ] | all) + ' "$response" >/dev/null 2>&1 || return 1 + answered_at=$(jq -er '.answeredAt' "$response" 2>/dev/null) || return 1 + created_at=$(jq -er '.createdAt' "$request" 2>/dev/null) || return 1 + deadline=$(jq -er '.deadline' "$request" 2>/dev/null) || return 1 + answered_epoch=$(fm_pi_question_iso_epoch "$answered_at" 2>/dev/null) || return 1 + created_epoch=$(fm_pi_question_iso_epoch "$created_at" 2>/dev/null) || return 1 + deadline_epoch=$(fm_pi_question_iso_epoch "$deadline" 2>/dev/null) || return 1 + now=$(date +%s) + [ "$answered_epoch" -ge "$created_epoch" ] \ + && [ "$answered_epoch" -le "$deadline_epoch" ] \ + && [ "$answered_epoch" -le $((now + 60)) ] +} + +fm_pi_question_owner_valid() { + local owner=$1 request=$2 + jq -e --argjson version "$FM_PI_ASK_PROTOCOL_VERSION" --slurpfile request "$request" ' + type == "object" + and ((keys_unsorted - ["protocolVersion", "taskId", "callId", "ownerPid", "ownerStart", "bridgePid", "bridgeStart"]) | length == 0) + and .protocolVersion == $version + and .taskId == $request[0].taskId + and .callId == $request[0].callId + and (.ownerPid | type == "number" and floor == . and . > 1) + and (.bridgePid | type == "number" and floor == . and . > 1) + and (.ownerStart | type == "string" and length > 0 and length <= 128) + and (.bridgeStart | type == "string" and length > 0 and length <= 128) + ' "$owner" >/dev/null 2>&1 +} + +fm_pi_question_resolution_valid() { + local resolution=$1 task_id=$2 call_id=$3 resolved_at resolved_epoch now + jq -e \ + --argjson version "$FM_PI_ASK_PROTOCOL_VERSION" \ + --arg taskId "$task_id" \ + --arg callId "$call_id" ' + type == "object" + and ((keys_unsorted - ["protocolVersion", "taskId", "callId", "status", "resolvedAt", "detail"]) | length == 0) + and .protocolVersion == $version + and .taskId == $taskId + and .callId == $callId + and (.status == "answered" or .status == "cancelled" or .status == "timed-out" or .status == "abandoned" or .status == "invalid-request" or .status == "invalid-response") + and (.resolvedAt | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) + and (.detail | type == "string" and utf8bytelength >= 1 and utf8bytelength <= 120 and ((contains("\n") or contains("\r")) | not)) + ' "$resolution" >/dev/null 2>&1 || return 1 + resolved_at=$(jq -er '.resolvedAt' "$resolution" 2>/dev/null) || return 1 + resolved_epoch=$(fm_pi_question_iso_epoch "$resolved_at" 2>/dev/null) || return 1 + now=$(date +%s) + [ "$resolved_epoch" -le $((now + 60)) ] +} + +fm_pi_question_owner_process_alive() { + local owner=$1 role=$2 pid start + case "$role" in + owner) + pid=$(jq -er '.ownerPid' "$owner" 2>/dev/null) || return 1 + start=$(jq -er '.ownerStart' "$owner" 2>/dev/null) || return 1 + ;; + bridge) + pid=$(jq -er '.bridgePid' "$owner" 2>/dev/null) || return 1 + start=$(jq -er '.bridgeStart' "$owner" 2>/dev/null) || return 1 + ;; + *) return 1 ;; + esac + fm_pi_question_process_matches "$pid" "$start" +} + +fm_pi_question_atomic_write() { + local content=$1 destination=$2 directory tmp + directory=${destination%/*} + tmp=$(mktemp "$directory/.pi-question.XXXXXXXXXX") || return 1 + if ! printf '%s\n' "$content" > "$tmp" || ! chmod 0600 "$tmp" || ! mv -f "$tmp" "$destination"; then + rm -f "$tmp" + return 1 + fi +} + +fm_pi_question_lock_release() { + [ -n "$FM_PI_ASK_LOCK_DIR" ] || return 0 + if [ -f "$FM_PI_ASK_LOCK_DIR" ] && [ ! -L "$FM_PI_ASK_LOCK_DIR" ] \ + && [ "$(sed -n '1p' "$FM_PI_ASK_LOCK_DIR" 2>/dev/null || true)" = "$FM_PI_ASK_LOCK_PID" ] \ + && [ "$(sed -n '2p' "$FM_PI_ASK_LOCK_DIR" 2>/dev/null || true)" = "$FM_PI_ASK_LOCK_START" ]; then + rm -f "$FM_PI_ASK_LOCK_DIR" + fi + FM_PI_ASK_LOCK_DIR= + FM_PI_ASK_LOCK_PID= + FM_PI_ASK_LOCK_START= +} + +fm_pi_question_lock_acquire() { + local directory=$1 call_id=$2 lock attempt=0 pid start lock_pid lock_start candidate + fm_pi_question_id_valid "$call_id" || return 1 + lock="$directory/$call_id.lock" + while [ "$attempt" -lt "$FM_PI_ASK_LOCK_ATTEMPTS" ]; do + pid=$$ + start=$(fm_pi_question_process_start "$pid") || return 1 + candidate=$(mktemp "$directory/.pi-lock.XXXXXXXXXX") || return 1 + if ! printf '%s\n%s\n' "$pid" "$start" > "$candidate" || ! chmod 0600 "$candidate"; then + rm -f "$candidate" + return 1 + fi + if ln "$candidate" "$lock" 2>/dev/null; then + rm -f "$candidate" + FM_PI_ASK_LOCK_DIR=$lock + FM_PI_ASK_LOCK_PID=$pid + FM_PI_ASK_LOCK_START=$start + return 0 + fi + rm -f "$candidate" + if [ -f "$lock" ] && [ ! -L "$lock" ]; then + lock_pid=$(sed -n '1p' "$lock" 2>/dev/null || true) + lock_start=$(sed -n '2p' "$lock" 2>/dev/null || true) + if ! fm_pi_question_process_matches "$lock_pid" "$lock_start"; then + rm -f "$lock" + continue + fi + fi + sleep "$FM_PI_ASK_LOCK_SLEEP" + attempt=$((attempt + 1)) + done + return 1 +} + +fm_pi_question_resolution_json() { + local task_id=$1 call_id=$2 status=$3 detail=$4 resolved_at + resolved_at=$(fm_pi_question_now_iso) || return 1 + jq -cnS \ + --argjson protocolVersion "$FM_PI_ASK_PROTOCOL_VERSION" \ + --arg taskId "$task_id" \ + --arg callId "$call_id" \ + --arg status "$status" \ + --arg resolvedAt "$resolved_at" \ + --arg detail "$detail" \ + '{protocolVersion: $protocolVersion, taskId: $taskId, callId: $callId, status: $status, resolvedAt: $resolvedAt, detail: $detail}' +} + +fm_pi_question_append_status() { + local state=$1 task_id=$2 line=$3 status_file + fm_pi_question_id_valid "$task_id" || return 1 + status_file="$state/$task_id.status" + if [ -e "$status_file" ] && { [ ! -f "$status_file" ] || [ -L "$status_file" ]; }; then + return 1 + fi + printf '%s\n' "$line" >> "$status_file" +} + +fm_pi_question_task_is_pi() { + local state=$1 task_id=$2 meta harness + fm_pi_question_id_valid "$task_id" || return 1 + meta="$state/$task_id.meta" + [ -f "$meta" ] && [ ! -L "$meta" ] || return 1 + harness=$(sed -n 's/^harness=//p' "$meta" | tail -1) + [ "$harness" = pi ] +} + +fm_pi_question_prepare_directory() { + local state=$1 task_id=$2 root directory + fm_pi_question_id_valid "$task_id" || return 1 + root="$state/questions" + directory="$root/$task_id" + [ ! -L "$root" ] && [ ! -L "$directory" ] || return 1 + mkdir -p "$directory" || return 1 + chmod 0700 "$root" "$directory" || return 1 + printf '%s\n' "$directory" +} diff --git a/bin/fm-pi-question-recover.sh b/bin/fm-pi-question-recover.sh new file mode 100755 index 0000000000..c737d5d66a --- /dev/null +++ b/bin/fm-pi-question-recover.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Reconcile pending Pi supervised questions after process death or deadline expiry. +# Usage: FM_HOME= fm-pi-question-recover.sh --all +# FM_HOME= fm-pi-question-recover.sh +# FM_HOME= fm-pi-question-recover.sh --cancel +# Recovery is idempotent and never answers a question. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ "${1:-}" = -h ] || [ "${1:-}" = --help ]; then + sed -n '2,7p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +fi +# shellcheck source=bin/fm-pi-question-lib.sh +. "$SCRIPT_DIR/fm-pi-question-lib.sh" + +fail() { + printf 'error: %s\n' "$1" >&2 + exit "${2:-1}" +} + +[ -n "${FM_HOME:-}" ] || fail "FM_HOME must be explicit" 2 +case "$FM_HOME" in /*) : ;; *) fail "FM_HOME must be absolute" 2 ;; esac +[ -d "$FM_HOME" ] || fail "FM_HOME is not a directory" 2 +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +[ -d "$STATE" ] && [ ! -L "$STATE" ] || fail "state directory is unavailable" + +MODE=${1:---all} +TASK_FILTER= +DETAIL= +case "$MODE" in + --all) + [ "$#" -eq 1 ] || fail "usage: fm-pi-question-recover.sh --all" 2 + ;; + --cancel) + [ "$#" -eq 2 ] || fail "usage: fm-pi-question-recover.sh --cancel " 2 + TASK_FILTER=$2 + DETAIL="was cancelled" + fm_pi_question_id_valid "$TASK_FILTER" || fail "invalid task ID" 2 + ;; + *) + [ "$#" -eq 1 ] || fail "usage: fm-pi-question-recover.sh " 2 + TASK_FILTER=$MODE + fm_pi_question_id_valid "$TASK_FILTER" || fail "invalid task ID" 2 + MODE=--task + ;; +esac + +ROOT="$STATE/questions" +[ -d "$ROOT" ] && [ ! -L "$ROOT" ] || exit 0 +if [ -n "$TASK_FILTER" ] && [ ! -d "$ROOT/$TASK_FILTER" ]; then + exit 0 +fi +command -v jq >/dev/null 2>&1 || fail "jq is required" + +resolve_request() { + local task_id=$1 call_id=$2 status=$3 detail=$4 directory resolution resolution_json + directory="$STATE/questions/$task_id" + resolution="$directory/$call_id.resolution.json" + fm_pi_question_lock_acquire "$directory" "$call_id" || return 1 + if [ -e "$resolution" ]; then + fm_pi_question_lock_release + return 0 + fi + resolution_json=$(fm_pi_question_resolution_json "$task_id" "$call_id" "$status" "$detail") || return 1 + fm_pi_question_atomic_write "$resolution_json" "$resolution" || return 1 + fm_pi_question_lock_release + if [ -f "$STATE/$task_id.meta" ]; then + fm_pi_question_append_status "$STATE" "$task_id" \ + "resolved: Pi question $detail [key=ask-$call_id]" || return 1 + fi +} + +ensure_resolution_status() { + local task_id=$1 call_id=$2 resolution=$3 status_file detail suffix + [ -f "$resolution" ] && [ ! -L "$resolution" ] || return 1 + fm_pi_question_resolution_valid "$resolution" "$task_id" "$call_id" || return 1 + status_file="$STATE/$task_id.status" + suffix="[key=ask-$call_id]" + if [ -f "$status_file" ] && awk -v suffix="$suffix" 'index($0, "resolved:") == 1 && index($0, suffix) > 0 { found=1 } END { exit !found }' "$status_file"; then + return 0 + fi + [ -f "$STATE/$task_id.meta" ] || return 0 + detail=$(jq -er '.detail' "$resolution") || return 1 + fm_pi_question_append_status "$STATE" "$task_id" \ + "resolved: Pi question $detail [key=ask-$call_id]" +} + +recover_task() { + local directory=$1 task_id request call_id owner resolution response deadline deadline_epoch now status detail + [ -d "$directory" ] && [ ! -L "$directory" ] || return 0 + task_id=${directory##*/} + fm_pi_question_id_valid "$task_id" || return 0 + for request in "$directory"/*.request.json; do + [ -f "$request" ] && [ ! -L "$request" ] || continue + call_id=${request##*/} + call_id=${call_id%.request.json} + fm_pi_question_id_valid "$call_id" || continue + owner="$directory/$call_id.owner.json" + resolution="$directory/$call_id.resolution.json" + response="$directory/$call_id.response.json" + if [ -e "$resolution" ]; then + ensure_resolution_status "$task_id" "$call_id" "$resolution" \ + || fail "cannot validate terminal Pi question $task_id/$call_id" + continue + fi + status= + detail= + if [ "$MODE" = --cancel ]; then + status=cancelled + detail=$DETAIL + elif ! fm_pi_question_request_valid "$request"; then + status=invalid-request + detail="request was invalid" + elif [ ! -f "$owner" ] || [ -L "$owner" ] || ! fm_pi_question_owner_valid "$owner" "$request"; then + status=abandoned + detail="was abandoned" + elif ! fm_pi_question_owner_process_alive "$owner" owner || ! fm_pi_question_owner_process_alive "$owner" bridge; then + status=abandoned + detail="was abandoned" + elif [ -f "$response" ] && ! fm_pi_question_response_valid "$response" "$request"; then + status=invalid-response + detail="answer was invalid" + else + deadline=$(jq -r '.deadline' "$request") + deadline_epoch=$(fm_pi_question_iso_epoch "$deadline" 2>/dev/null || printf '0') + now=$(date +%s) + if [ "$deadline_epoch" -le "$now" ]; then + status=timed-out + detail="timed out" + fi + fi + [ -n "$status" ] || continue + resolve_request "$task_id" "$call_id" "$status" "$detail" \ + || fail "cannot reconcile Pi question $task_id/$call_id" + done +} + +trap fm_pi_question_lock_release EXIT +if [ -n "$TASK_FILTER" ]; then + recover_task "$ROOT/$TASK_FILTER" +else + for directory in "$ROOT"/*; do + recover_task "$directory" + done +fi diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index cdeb03f04a..35a938fb40 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -18,7 +18,7 @@ # standalone with unchanged default behavior - other flows (fm-bootstrap.sh # install after consent, /updatefirstmate, the afk daemon, existing # tests) still call them directly. The one seam this script needed - -# bootstrap running its detect-only diagnostics without its five mutating +# bootstrap running its detect-only diagnostics without its six mutating # sweeps - is an opt-in FM_BOOTSTRAP_DETECT_ONLY=1 flag on fm-bootstrap.sh # itself (default unset/0 = unchanged behavior), not a fork. # @@ -63,7 +63,7 @@ # tasks-axi and quota-axi tool checks, and tasks-axi availability - none of # which mutate shared state and all of which are safe to compute from a second # session. -# Only the five mutating sweeps and the wake-queue drain are skipped. +# Only the six mutating sweeps and the wake-queue drain are skipped. # The context and fleet-state digests # below are always read-only, so they run unconditionally in both modes. # diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index fc177354be..7cc0028d1e 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -73,6 +73,10 @@ # written by this script; outside the worktree to avoid pi's trust gate) # __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home # __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home +# Pi ship and scout launches additionally receive the explicit supervised +# interaction envelope: FM_INTERACTION_MODE=supervised, FM_HOME, FM_TASK_ID, +# and the absolute FM_ASK_BRIDGE path. Pi secondmates retain their existing +# primary-shaped launch; attended-primary experiments belong to a later phase. # Per-harness turn-end hooks are installed automatically; some live outside the worktree. # grok uses a firstmate-owned global hook under ${GROK_HOME:-$HOME/.grok}/hooks # plus a gitignored .fm-grok-turnend worktree pointer and a state token. @@ -410,6 +414,11 @@ if [ "$KIND" = secondmate ] && [ -z "$ARG3" ]; then fi fi +if [ "$HARNESS" = pi ] && [ "$KIND" != secondmate ] && ! command -v jq >/dev/null 2>&1; then + echo "error: jq is required for the Pi supervised-question bridge" >&2 + exit 1 +fi + secondmate_registry_value() { local id=$1 key=$2 reg line value reg="$DATA/secondmates.md" @@ -1043,6 +1052,12 @@ LAUNCH=${LAUNCH//__TURNEND__/$sq_turnend} LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} +if [ "$HARNESS" = pi ] && [ "$KIND" != secondmate ]; then + sq_home=$(shell_quote "$FM_HOME") + sq_task_id=$(shell_quote "$ID") + sq_ask_bridge=$(shell_quote "$FM_ROOT/bin/fm-pi-ask-bridge.sh") + LAUNCH="FM_INTERACTION_MODE=supervised FM_HOME=$sq_home FM_TASK_ID=$sq_task_id FM_ASK_BRIDGE=$sq_ask_bridge $LAUNCH" +fi if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 9d84f4d6cf..9f59c19ff7 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -34,6 +34,10 @@ # device. It refuses and preserves task state when that proof fails; otherwise # it removes the task's check, trust record, PR sidecar, publication record, and # quarantine entries with the rest of the volatile state. +# Before killing an endpoint, teardown closes every pending Pi supervised +# question through fm-pi-question-recover.sh --cancel. It removes that task's +# private state/questions// directory only after ordinary teardown safety +# checks pass and the endpoint cleanup completes. # Orca tasks use the same safety checks, then close the recorded terminal and # remove the recorded worktree through `orca worktree rm`; teardown never guesses # an Orca target from ambient CLI state. @@ -127,6 +131,7 @@ PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) TASK_TMP=$(grep '^tasktmp=' "$META" | cut -d= -f2- || true) ORCA_WORKTREE_ID=$(fm_meta_get "$META" orca_worktree_id) ORCA_PATH_MATCH_VERIFIED=0 +HARNESS=$(grep '^harness=' "$META" | tail -1 | cut -d= -f2- || true) KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship @@ -942,7 +947,7 @@ validate_firstmate_home_children_removal() { } cleanup_firstmate_home_children() { - local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc + local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_harness child_orca_worktree_id child_return_rc sub_state="$home/state" [ -d "$sub_state" ] || return 0 for child_meta in "$sub_state"/*.meta; do @@ -953,6 +958,7 @@ cleanup_firstmate_home_children() { child_kind=$(meta_value "$child_meta" kind) [ -n "$child_kind" ] || child_kind=ship child_backend=$(fm_backend_of_meta "$child_meta") + child_harness=$(meta_value "$child_meta" harness) if [ "$child_backend" = orca ]; then child_t=$(meta_value "$child_meta" terminal) else @@ -965,6 +971,10 @@ cleanup_firstmate_home_children() { fi fi if [ -n "$child_t" ]; then + if [ "$child_harness" = pi ]; then + FM_HOME="$home" FM_STATE_OVERRIDE="$sub_state" \ + "$SCRIPT_DIR/fm-pi-question-recover.sh" --cancel "$child_id" || return 1 + fi if [ "$child_backend" = zellij ]; then # Zellij titles are scoped by the owning home tag, so forced secondmate # cleanup must verify child tabs as that child home, not the parent. @@ -1082,6 +1092,14 @@ if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then fi fi +# Close a blocked Pi tool call before its owning endpoint is killed or its +# worktree is returned. The recovery helper is idempotent for non-Pi tasks and +# for calls that already reached a terminal resolution. +if [ "$HARNESS" = pi ]; then + FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" \ + "$SCRIPT_DIR/fm-pi-question-recover.sh" --cancel "$ID" || exit 1 +fi + # Best-effort: drop the local task branch so the shared repo does not accumulate refs. if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then if [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then @@ -1137,6 +1155,9 @@ fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" +if fm_task_id_path_safe "$ID"; then + rm -rf "$STATE/questions/$ID" +fi if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi diff --git a/docs/configuration.md b/docs/configuration.md index bbe5ca4f35..f90e4e7e8f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ The shared orchestrator behavior lives in [`AGENTS.md`](../AGENTS.md) - edit it This section is the single owner of the top-level operational-home layout; producer script headers and their help own exact child-file fields and mutation contracts. The tracked code root contains the shared instruction, skill, documentation, workflow, and `bin/` surfaces, while each effective `FM_HOME` contains private operational directories. `data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, learnings, backlog, briefs, and scout reports. -`state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, and generated X-mode artifacts. +`state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, private Pi supervised-question records, watcher and wake-queue coordination, away-mode state, and generated X-mode artifacts. `config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the guarded exceptions in `AGENTS.md`. `bin/fm-spawn.sh` owns the base task-metadata fields it emits, while the runtime-backend section below owns backend-specific fields and selector interpretation. @@ -22,6 +22,7 @@ Wake, watcher, away-mode, and X-specific state mechanics remain with their named `docs/sessionstart-nudge.md` owns the native session-open adapter mechanics that nudge the digest command. `AGENTS.md` retains the run-once and read-once operator rules, lock-refusal safety, installation consent, and direct-report recovery boundaries because those facts apply at every session start. Ordinary dead-direct-report recovery is owned by `stuck-crewmate-recovery`, while persistent-secondmate recovery is owned by `secondmate-provisioning`. +The complete Pi supervised-question wire schema, private state paths, and recovery lifecycle are owned by [`docs/pi-supervised-questions.md`](pi-supervised-questions.md). ## Backlog backend (.tasks.toml / config/backlog-backend) @@ -390,6 +391,8 @@ FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watc FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm is attached to an existing healthy watcher cycle FM_OPENCODE_ARM_READY_TIMEOUT_MS=12000 # milliseconds the OpenCode primary watcher plugin waits for an arm attempt to report started, healthy, wake, or failure FM_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached +FM_PI_ASK_MAX_WAIT_SECS=86400 # maximum future deadline accepted by a Pi supervised-question bridge +FM_PI_ASK_POLL_SECONDS=1 # bounded poll fallback while a Pi question bridge waits for filesystem notification FM_WATCH_ARM_RETIRE_TIMEOUT_MS=1000 # milliseconds Pi/OpenCode wait for an unready successor arm to exit before abandoning retries FM_WATCH_REARM_RETRY_BASE_MS=250 # Pi/OpenCode adapter base delay for continuity restoration retries FM_WATCH_REARM_RETRY_MAX_MS=4000 # Pi/OpenCode adapter cap for exponential continuity retry delay diff --git a/docs/pi-supervised-questions.md b/docs/pi-supervised-questions.md new file mode 100644 index 0000000000..71a543bcb4 --- /dev/null +++ b/docs/pi-supervised-questions.md @@ -0,0 +1,186 @@ +# Pi supervised questions + +This document is the single owner of Firstmate's Pi supervised-question protocol version 1. +The generic Pi capability package owns the matching model-facing `ask_user_question` schema and bridge client. +Firstmate owns only the launch envelope, private state, answer publication, wake lifecycle, recovery, and cancellation described here. + +## Launch envelope + +`bin/fm-spawn.sh` adds the following variables to every Pi ship and scout launch: + +```text +FM_INTERACTION_MODE=supervised +FM_HOME=/absolute/path/to/operational/home +FM_TASK_ID= +FM_ASK_BRIDGE=/absolute/path/to/firstmate/bin/fm-pi-ask-bridge.sh +``` + +Supervised mode is explicit and is never inferred from an interactive terminal. +Pi secondmates retain their existing primary-shaped launch because attended-primary behavior belongs to the separately sequenced primary experiment. +Pi ship and scout spawn refuses before worktree creation when `jq` is unavailable because the Firstmate-owned protocol validator depends on it. + +The generic extension spawns `FM_ASK_BRIDGE`, writes one request object to its standard input, propagates its abort signal to the bridge process, and waits for one response object on standard output. +The bridge reserves standard output for the exact tool return and writes bounded errors to standard error with a stable `ASK_USER_*` code. + +## Identifier and text bounds + +Task, call, and question IDs contain 1 to 64 ASCII letters, digits, dots, underscores, or dashes, and the first character is not a dot or dash. +Each call contains 1 to 4 questions. +Each question contains 2 to 4 options and has a unique question ID within the call. +Each question header is 1 to 40 UTF-8 bytes. +Each question body is 1 to 500 UTF-8 bytes. +Each option label is 1 to 100 UTF-8 bytes and is unique within its question. +Each option description is 0 to 500 UTF-8 bytes. + +## Request schema + +Requests use this exact version 1 shape with no additional fields: + +```json +{ + "protocolVersion": 1, + "taskId": "task-r1", + "callId": "call-a1", + "createdAt": "2026-07-19T12:00:00Z", + "deadline": "2026-07-19T12:30:00Z", + "questions": [ + { + "id": "delivery", + "header": "Delivery", + "question": "Which delivery path should continue?", + "options": [ + {"label": "Direct PR", "description": "Open a PR without the full validation pipeline."}, + {"label": "No mistakes", "description": "Run the full validation pipeline before review."} + ] + } + ] +} +``` + +`createdAt` and `deadline` are UTC RFC 3339 second timestamps. +The deadline must follow creation, must still be in the future at publication, and must not exceed the bridge's bounded wait limit. +`FM_PI_ASK_MAX_WAIT_SECS` defaults to 86400 seconds and exists for controlled tests and deployments that need a shorter envelope. + +## Response schema + +Firstmate writes this exact version 1 response shape with no additional fields: + +```json +{ + "protocolVersion": 1, + "taskId": "task-r1", + "callId": "call-a1", + "answeredAt": "2026-07-19T12:05:00Z", + "answers": [ + {"questionId": "delivery", "selectedLabel": "No mistakes"} + ] +} +``` + +There is exactly one answer for each question in request order. +Each question ID must match its request entry and each selected label must exactly match one listed option label. +The bridge emits only `{"answers":[...]}` so the blocked Pi tool call receives the model-facing result without protocol metadata. + +## Private state paths + +For task `` and call ``, Firstmate uses: + +```text +state/questions//.request.json +state/questions//.response.json +state/questions//.owner.json +state/questions//.resolution.json +state/questions//.lock +``` + +The `questions` root and task directory are mode `0700`. +JSON files are mode `0600`. +Request and response publication uses a same-directory temporary file followed by an atomic rename. +The mode-`0600` lock file serializes answer publication against timeout, cancellation, and recovery. +It is published with a same-directory hard link only after its process ID and process start identity are complete, so a dead lock can be reclaimed without trusting PID reuse or exposing an ownerless-lock crash window. + +The private owner object binds the request to both the Pi process that owns the in-memory tool call and the bridge process waiting for the answer. +It is versioned and contains `protocolVersion`, `taskId`, `callId`, `ownerPid`, `ownerStart`, `bridgePid`, and `bridgeStart`. + +The private resolution object contains `protocolVersion`, `taskId`, `callId`, `status`, `resolvedAt`, and bounded `detail`. +Terminal statuses are `answered`, `cancelled`, `timed-out`, `abandoned`, `invalid-request`, and `invalid-response`. +Once a resolution exists, the guarded answer helper refuses every later answer for that call ID. + +## Lifecycle + +After publishing the owner and request atomically, the bridge appends: + +```text +needs-decision: Pi question request state/questions//.request.json [key=ask-] +``` + +Status text contains only validated IDs and a relative private path. +The question body stays in the mode-`0600` request file. + +Firstmate reads the request and publishes an answer only through: + +```sh +printf '%s\n' '{"answers":[{"questionId":"delivery","selectedLabel":"No mistakes"}]}' \ + | FM_HOME=/absolute/home bin/fm-pi-answer.sh task-r1 call-a1 +``` + +The helper requires an explicit `FM_HOME` and validates task ownership, Pi harness ownership, request and call identity, protocol version, freshness, owner liveness, bridge liveness, answer order, and option labels. +It refuses malformed, duplicate, expired, resolved, abandoned, or otherwise stale answers. +It never injects chat into the blocked agent turn. + +The bridge waits on a filesystem event when `fswatch` or `inotifywait` is available and always retains a bounded poll fallback. +`FM_PI_ASK_POLL_SECONDS` defaults to one second and exists for tests. +Before every poll it checks cancellation, response publication, owning Pi process identity, and deadline. + +After a valid response, the bridge atomically writes `status=answered`, appends the matching status event, and emits the exact tool result: + +```text +resolved: Pi question answered [key=ask-] +``` + +An abort signal writes `status=cancelled` and appends a matching keyed `resolved` event. +A passed deadline writes `status=timed-out` and appends a matching keyed `resolved` event. +A dead owning Pi process or dead bridge is not resumable because the original tool call lived only in that process. +Recovery writes `status=abandoned`, refuses late answers for the old call ID, and requires a resumed agent to reissue the question with a new call ID. +Reusing any prior call ID is always rejected, even when the prior request was byte-identical. + +`bin/fm-bootstrap.sh` runs idempotent recovery only in the lock-owning mutating startup path. +Detect-only startup never changes question state. +`bin/fm-teardown.sh` cancels pending questions before killing an endpoint and removes that task's private question directory only after normal teardown safety checks pass. + +## Verification evidence + +On 2026-07-19, the implementation was verified against Pi 0.80.7 and jq 1.7.1 on macOS. +The exact commands were: + +```sh +pi --version +jq --version +bash tests/fm-pi-supervised-question.test.sh +bin/fm-lint.sh +``` + +The version output was: + +```text +0.80.7 +jq-1.7.1-apple +``` + +The behavior test covers a published crew question, wake status, guarded Firstmate answer, exact tool return, matching resolution, process death with a pending request, stale-answer refusal, reissue under a new call ID, malformed answers, timeout, cancellation, startup recovery, and launch-envelope wiring. +The recorded behavior-test output was: + +```text +ok - Pi bridge publishes a crew question, accepts one guarded answer, returns exact tool JSON, and resolves once +ok - guarded and direct malformed answers fail without leaving the tool call polling +ok - process death abandons the old call, rejects late answers, and permits only a new-call reissue +ok - owner death, timeout, and cancellation all terminate with durable resolution +ok - startup recovery abandons dead Pi calls only in the mutating lock-owning path +ok - Pi launch, startup, and teardown wiring preserve the supervised-question lifecycle +``` + +The recorded lint output was: + +```text +fm-lint.sh: ShellCheck 0.11.0 (pinned 0.11.0) +``` diff --git a/tests/fm-pi-supervised-question.test.sh b/tests/fm-pi-supervised-question.test.sh new file mode 100755 index 0000000000..abd135a144 --- /dev/null +++ b/tests/fm-pi-supervised-question.test.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +# End-to-end behavior tests for the Firstmate-owned Pi supervised-question bridge. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-pi-supervised-question.XXXXXX") +FM_TEST_CLEANUP_DIRS+=("$TMP_ROOT") +BRIDGE="$ROOT/bin/fm-pi-ask-bridge.sh" +ANSWER="$ROOT/bin/fm-pi-answer.sh" +RECOVER="$ROOT/bin/fm-pi-question-recover.sh" +CLASSIFY="$ROOT/bin/fm-classify-lib.sh" +LIVE_PIDS=() + +cleanup_processes() { + local pid command + for pid in "${LIVE_PIDS[@]:-}"; do + case "$pid" in ''|*[!0-9]*) continue ;; esac + command=$(ps -o command= -p "$pid" 2>/dev/null || true) + case "$command" in + *fm-pi-ask-bridge.sh*|*owner-wrapper.sh*) kill "$pid" 2>/dev/null || true ;; + esac + done + fm_test_cleanup +} +trap cleanup_processes EXIT + +file_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f %Lp "$1" + else + stat -c %a "$1" + fi +} + +forget_pid() { + local forgotten=$1 pid existing + existing="${LIVE_PIDS[*]:-}" + LIVE_PIDS=() + for pid in $existing; do + [ "$pid" = "$forgotten" ] || LIVE_PIDS+=("$pid") + done +} + +make_home() { + local name=$1 task_id=$2 home + home="$TMP_ROOT/$name" + mkdir -p "$home/state" + printf 'harness=pi\n' > "$home/state/$task_id.meta" + printf '%s\n' "$home" +} + +make_request() { + local destination=$1 task_id=$2 call_id=$3 deadline_offset=${4:-30} created deadline + created=$(jq -nr 'now | floor | todateiso8601') + deadline=$(jq -nr --argjson offset "$deadline_offset" 'now + $offset | floor | todateiso8601') + jq -cnS \ + --arg taskId "$task_id" \ + --arg callId "$call_id" \ + --arg createdAt "$created" \ + --arg deadline "$deadline" \ + '{ + protocolVersion: 1, + taskId: $taskId, + callId: $callId, + createdAt: $createdAt, + deadline: $deadline, + questions: [ + { + id: "route", + header: "Route", + question: "Which route should continue?", + options: [ + {label: "Direct PR", description: "Open a direct pull request."}, + {label: "No mistakes", description: "Run the full validation pipeline."} + ] + } + ] + }' > "$destination" +} + +wait_for_file() { + local file=$1 attempts=${2:-200} count=0 + while [ "$count" -lt "$attempts" ]; do + [ -f "$file" ] && return 0 + sleep 0.02 + count=$((count + 1)) + done + fail "timed out waiting for $file" +} + +wait_for_grep() { + local needle=$1 file=$2 attempts=${3:-200} count=0 + while [ "$count" -lt "$attempts" ]; do + if [ -f "$file" ] && grep -F -- "$needle" "$file" >/dev/null; then + return 0 + fi + sleep 0.02 + count=$((count + 1)) + done + fail "timed out waiting for '$needle' in $file" +} + +wait_for_process() { + local pid=$1 timeout_steps=${2:-250} count=0 status + while kill -0 "$pid" 2>/dev/null; do + [ "$count" -lt "$timeout_steps" ] || fail "process $pid did not exit" + sleep 0.02 + count=$((count + 1)) + done + wait "$pid" 2>/dev/null + status=$? + forget_pid "$pid" + return "$status" +} + +start_bridge() { + local home=$1 task_id=$2 input=$3 output=$4 error=$5 + FM_HOME="$home" \ + FM_TASK_ID="$task_id" \ + FM_INTERACTION_MODE=supervised \ + FM_ASK_BRIDGE="$BRIDGE" \ + FM_PI_ASK_POLL_SECONDS=0.02 \ + "$BRIDGE" < "$input" > "$output" 2> "$error" & + BRIDGE_PID=$! + LIVE_PIDS+=("$BRIDGE_PID") +} + +answer_json() { + local label=$1 + jq -cn --arg label "$label" '{answers: [{questionId: "route", selectedLabel: $label}]}' +} + +test_answer_resumes_same_tool_call() { + local task_id=crew-question call_id=call-one home input output error request response resolution out open + home=$(make_home answer "$task_id") + input="$home/input.json" + output="$home/output.json" + error="$home/error.log" + request="$home/state/questions/$task_id/$call_id.request.json" + response="$home/state/questions/$task_id/$call_id.response.json" + resolution="$home/state/questions/$task_id/$call_id.resolution.json" + make_request "$input" "$task_id" "$call_id" + + start_bridge "$home" "$task_id" "$input" "$output" "$error" + wait_for_file "$request" + wait_for_file "$home/state/$task_id.status" + assert_grep "needs-decision: Pi question request state/questions/$task_id/$call_id.request.json [key=ask-$call_id]" \ + "$home/state/$task_id.status" "bridge did not publish the bounded decision wake" + open=$(bash -c '. "$1"; status_open_decisions "$2"' _ "$CLASSIFY" "$home/state/$task_id.status") + assert_contains "$open" "ask-$call_id" "status fold did not retain the Pi call identity" + [ "$(file_mode "$home/state/questions")" = 700 ] || fail "question root is not mode 0700" + [ "$(file_mode "$request")" = 600 ] || fail "request is not mode 0600" + + answer_json "No mistakes" | FM_HOME="$home" "$ANSWER" "$task_id" "$call_id" >/dev/null \ + || fail "guarded Firstmate answer failed" + wait_for_process "$BRIDGE_PID" || fail "bridge did not complete after a valid answer" + out=$(cat "$output") + [ "$out" = '{"answers":[{"questionId":"route","selectedLabel":"No mistakes"}]}' ] || fail "bridge returned the wrong tool result: $out" + [ ! -s "$error" ] || fail "successful bridge wrote an error: $(cat "$error")" + assert_present "$response" "guarded helper did not publish the response" + [ "$(jq -r '.status' "$resolution")" = answered ] || fail "bridge did not record answered resolution" + assert_grep "resolved: Pi question answered [key=ask-$call_id]" \ + "$home/state/$task_id.status" "bridge did not append the matching resolved event" + open=$(bash -c '. "$1"; status_open_decisions "$2"' _ "$CLASSIFY" "$home/state/$task_id.status") + [ -z "$open" ] || fail "matching Pi resolution did not close the keyed decision: $open" + + set +e + out=$(answer_json "No mistakes" | FM_HOME="$home" "$ANSWER" "$task_id" "$call_id" 2>&1) + status=$? + set -e + [ "$status" -ne 0 ] || fail "guarded helper accepted a stale duplicate answer" + assert_contains "$out" "already resolved" "stale duplicate answer lacked a clear refusal" + grep -vF "resolved: Pi question answered [key=ask-$call_id]" "$home/state/$task_id.status" > "$home/status-without-resolution" + mv "$home/status-without-resolution" "$home/state/$task_id.status" + FM_HOME="$home" "$RECOVER" "$task_id" || fail "recovery could not repair a missing terminal status event" + [ "$(grep -Fc "resolved: Pi question answered [key=ask-$call_id]" "$home/state/$task_id.status")" = 1 ] \ + || fail "recovery did not restore exactly one matching resolved event" + set +e + pass "Pi bridge publishes a crew question, accepts one guarded answer, returns exact tool JSON, and resolves once" +} + +test_malformed_answers_fail_boundedly() { + local task_id=malformed call_id=bad-direct home input output error request response out status + home=$(make_home malformed "$task_id") + input="$home/input.json" + output="$home/output.json" + error="$home/error.log" + request="$home/state/questions/$task_id/$call_id.request.json" + response="$home/state/questions/$task_id/$call_id.response.json" + make_request "$input" "$task_id" "$call_id" + start_bridge "$home" "$task_id" "$input" "$output" "$error" + wait_for_file "$request" + + set +e + out=$(answer_json "Missing label" | FM_HOME="$home" "$ANSWER" "$task_id" "$call_id" 2>&1) + status=$? + set -e + [ "$status" -ne 0 ] || fail "guarded helper accepted an unlisted option label" + assert_contains "$out" "exactly one listed label" "malformed helper answer lacked a clear refusal" + assert_absent "$response" "rejected helper answer still published a response" + + jq -cn '{protocolVersion: 1, taskId: "malformed", callId: "bad-direct", answeredAt: "not-a-time", answers: []}' > "$response" + chmod 0600 "$response" + set +e + wait_for_process "$BRIDGE_PID" + status=$? + set -e + [ "$status" -ne 0 ] || fail "bridge accepted a malformed direct response" + assert_grep "ASK_USER_MALFORMED_RESPONSE" "$error" "bridge did not fail with the malformed-response code" + [ "$(jq -r '.status' "$home/state/questions/$task_id/$call_id.resolution.json")" = invalid-response ] || fail "bridge did not terminally resolve a malformed response" + pass "guarded and direct malformed answers fail without leaving the tool call polling" +} + +test_process_death_recovery_and_reissue() { + local task_id=recovery old_call=old-call new_call=new-call home old_input old_output old_error old_request out status + home=$(make_home recovery "$task_id") + old_input="$home/old-input.json" + old_output="$home/old-output.json" + old_error="$home/old-error.log" + old_request="$home/state/questions/$task_id/$old_call.request.json" + make_request "$old_input" "$task_id" "$old_call" 60 + start_bridge "$home" "$task_id" "$old_input" "$old_output" "$old_error" + wait_for_file "$old_request" + + kill -KILL "$BRIDGE_PID" + wait "$BRIDGE_PID" 2>/dev/null || true + forget_pid "$BRIDGE_PID" + FM_HOME="$home" "$RECOVER" "$task_id" || fail "recovery helper could not abandon a dead bridge" + [ "$(jq -r '.status' "$home/state/questions/$task_id/$old_call.resolution.json")" = abandoned ] || fail "dead bridge request was not marked abandoned" + set +e + out=$(answer_json "Direct PR" | FM_HOME="$home" "$ANSWER" "$task_id" "$old_call" 2>&1) + status=$? + set -e + [ "$status" -ne 0 ] || fail "late answer was accepted after process death" + assert_contains "$out" "already resolved" "late answer refusal did not identify stale resolution" + + make_request "$home/new-input.json" "$task_id" "$new_call" 60 + start_bridge "$home" "$task_id" "$home/new-input.json" "$home/new-output.json" "$home/new-error.log" + wait_for_file "$home/state/questions/$task_id/$new_call.request.json" + answer_json "Direct PR" | FM_HOME="$home" "$ANSWER" "$task_id" "$new_call" >/dev/null \ + || fail "reissued question under a new call ID was not answerable" + wait_for_process "$BRIDGE_PID" || fail "reissued question did not resume" + [ "$(cat "$home/new-output.json")" = '{"answers":[{"questionId":"route","selectedLabel":"Direct PR"}]}' ] || fail "reissued question returned the wrong tool result" + + set +e + FM_HOME="$home" FM_TASK_ID="$task_id" FM_INTERACTION_MODE=supervised FM_ASK_BRIDGE="$BRIDGE" \ + "$BRIDGE" < "$home/new-input.json" > "$home/reused-output" 2> "$home/reused-error" + status=$? + set -e + [ "$status" -ne 0 ] || fail "bridge accepted a reused call ID" + assert_grep "ASK_USER_STALE_CALL" "$home/reused-error" "reused call ID lacked a stable refusal" + pass "process death abandons the old call, rejects late answers, and permits only a new-call reissue" +} + +test_owner_death_timeout_and_cancellation() { + local task_id=terminal home wrapper owner_pid bridge_pid status count + home=$(make_home terminal "$task_id") + + make_request "$home/owner-input.json" "$task_id" owner-death 30 + wrapper="$home/owner-wrapper.sh" + cat > "$wrapper" <<'SH' +#!/usr/bin/env bash +"$BRIDGE" < "$INPUT" > "$OUTPUT" 2> "$ERROR" & +child=$! +printf '%s\n' "$child" > "$BRIDGE_PID_FILE" +wait "$child" +SH + chmod +x "$wrapper" + env FM_HOME="$home" FM_TASK_ID="$task_id" FM_INTERACTION_MODE=supervised FM_ASK_BRIDGE="$BRIDGE" \ + FM_PI_ASK_POLL_SECONDS=0.02 BRIDGE="$BRIDGE" INPUT="$home/owner-input.json" \ + OUTPUT="$home/owner-output" ERROR="$home/owner-error" BRIDGE_PID_FILE="$home/owner-bridge.pid" \ + "$wrapper" & + owner_pid=$! + LIVE_PIDS+=("$owner_pid") + wait_for_file "$home/state/questions/$task_id/owner-death.request.json" + wait_for_file "$home/owner-bridge.pid" + bridge_pid=$(cat "$home/owner-bridge.pid") + LIVE_PIDS+=("$bridge_pid") + kill -TERM "$owner_pid" + wait "$owner_pid" 2>/dev/null || true + forget_pid "$owner_pid" + wait_for_file "$home/state/questions/$task_id/owner-death.resolution.json" + count=0 + while kill -0 "$bridge_pid" 2>/dev/null && [ "$count" -lt 200 ]; do + sleep 0.02 + count=$((count + 1)) + done + forget_pid "$bridge_pid" + [ "$(jq -r '.status' "$home/state/questions/$task_id/owner-death.resolution.json")" = abandoned ] || fail "owner process death did not abandon the pending request" + assert_grep "ASK_USER_ABANDONED" "$home/owner-error" "owner death lacked the stable abandoned error" + + make_request "$home/timeout-input.json" "$task_id" timeout-call 1 + start_bridge "$home" "$task_id" "$home/timeout-input.json" "$home/timeout-output" "$home/timeout-error" + set +e + wait_for_process "$BRIDGE_PID" 300 + status=$? + set -e + [ "$status" -ne 0 ] || fail "expired question exited successfully" + [ "$(jq -r '.status' "$home/state/questions/$task_id/timeout-call.resolution.json")" = timed-out ] || fail "expired question did not record timed-out resolution" + + make_request "$home/cancel-input.json" "$task_id" cancel-call 30 + start_bridge "$home" "$task_id" "$home/cancel-input.json" "$home/cancel-output" "$home/cancel-error" + wait_for_file "$home/state/questions/$task_id/cancel-call.request.json" + wait_for_grep "needs-decision: Pi question request state/questions/$task_id/cancel-call.request.json [key=ask-cancel-call]" "$home/state/$task_id.status" + kill -TERM "$BRIDGE_PID" + set +e + wait_for_process "$BRIDGE_PID" + status=$? + set -e + [ "$status" -ne 0 ] || fail "cancelled question exited successfully" + [ "$(jq -r '.status' "$home/state/questions/$task_id/cancel-call.resolution.json")" = cancelled ] || fail "abort signal did not record cancelled resolution" + assert_grep "resolved: Pi question was cancelled [key=ask-cancel-call]" \ + "$home/state/$task_id.status" "cancelled question did not close its keyed status" + + make_request "$home/teardown-input.json" "$task_id" teardown-call 30 + start_bridge "$home" "$task_id" "$home/teardown-input.json" "$home/teardown-output" "$home/teardown-error" + wait_for_file "$home/state/questions/$task_id/teardown-call.request.json" + wait_for_grep "needs-decision: Pi question request state/questions/$task_id/teardown-call.request.json [key=ask-teardown-call]" "$home/state/$task_id.status" + FM_HOME="$home" "$RECOVER" --cancel "$task_id" || fail "teardown cancellation could not publish resolution" + set +e + wait_for_process "$BRIDGE_PID" + status=$? + set -e + [ "$status" -ne 0 ] || fail "teardown-cancelled bridge exited successfully" + assert_grep "ASK_USER_CANCELLED" "$home/teardown-error" "teardown cancellation did not stop the blocked bridge" + pass "owner death, timeout, and cancellation all terminate with durable resolution" +} + +test_startup_recovery_respects_read_only_mode() { + local task_id=startup call_id=startup-call home input output error request + home=$(make_home startup "$task_id") + input="$home/input.json" + output="$home/output" + error="$home/error" + request="$home/state/questions/$task_id/$call_id.request.json" + make_request "$input" "$task_id" "$call_id" 60 + start_bridge "$home" "$task_id" "$input" "$output" "$error" + wait_for_file "$request" + kill -KILL "$BRIDGE_PID" + wait "$BRIDGE_PID" 2>/dev/null || true + forget_pid "$BRIDGE_PID" + + FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_BOOTSTRAP_DETECT_ONLY=1 \ + "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1 + assert_absent "$home/state/questions/$task_id/$call_id.resolution.json" \ + "detect-only bootstrap mutated a pending Pi question" + + FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1 + [ "$(jq -r '.status' "$home/state/questions/$task_id/$call_id.resolution.json")" = abandoned ] \ + || fail "lock-owning bootstrap did not recover a dead pending Pi question" + pass "startup recovery abandons dead Pi calls only in the mutating lock-owning path" +} + +test_recovery_honors_state_override() { + local task_id=override call_id=override-call home state request resolution + home="$TMP_ROOT/override-home" + state="$TMP_ROOT/override-state" + request="$state/questions/$task_id/$call_id.request.json" + resolution="$state/questions/$task_id/$call_id.resolution.json" + mkdir -p "$home" "$state/questions/$task_id" + printf 'harness=pi\n' > "$state/$task_id.meta" + make_request "$request" "$task_id" "$call_id" + + FM_HOME="$home" FM_STATE_OVERRIDE="$state" "$RECOVER" --cancel "$task_id" \ + || fail "recovery ignored the effective state override" + [ "$(jq -r '.status' "$resolution")" = cancelled ] \ + || fail "state-override recovery did not cancel the pending request" + assert_grep "resolved: Pi question was cancelled [key=ask-$call_id]" \ + "$state/$task_id.status" "state-override recovery did not append the matching resolution" + assert_absent "$home/state" "state-override recovery wrote to the default home state" + pass "recovery and teardown use the effective state directory" +} + +test_launch_envelope_wiring() { + local source teardown + source=$(cat "$ROOT/bin/fm-spawn.sh") + teardown=$(cat "$ROOT/bin/fm-teardown.sh") + assert_contains "$source" "FM_INTERACTION_MODE=supervised FM_HOME=" "Pi crew launch lacks supervised mode and home" + assert_contains "$source" "FM_TASK_ID=\$sq_task_id" "Pi crew launch lacks the task ID" + assert_contains "$source" "FM_ASK_BRIDGE=\$sq_ask_bridge" "Pi crew launch lacks the absolute bridge helper" + # shellcheck disable=SC2016 + assert_contains "$source" '"$HARNESS" = pi ] && [ "$KIND" != secondmate' "Phase 5 launch variables escaped Pi crew scope" + # shellcheck disable=SC2016 + assert_contains "$teardown" 'fm-pi-question-recover.sh" --cancel "$ID"' "teardown does not cancel a pending Pi question" + # shellcheck disable=SC2016 + assert_contains "$teardown" '[ "$HARNESS" = pi ]' "teardown cancellation escaped Pi crew scope" + # shellcheck disable=SC2016 + assert_contains "$teardown" 'rm -rf "$STATE/questions/$ID"' "teardown does not remove terminal Pi question state" + pass "Pi launch, startup, and teardown wiring preserve the supervised-question lifecycle" +} + +test_answer_resumes_same_tool_call +test_malformed_answers_fail_boundedly +test_process_death_recovery_and_reissue +test_owner_death_timeout_and_cancellation +test_startup_recovery_respects_read_only_mode +test_recovery_honors_state_override +test_launch_envelope_wiring diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index af1b1c15f4..8f23f732d1 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -1025,7 +1025,7 @@ const hooks = await mod.FmPrimaryWatchArm({ const event = { event: { type: "session.idle", properties: { sessionID: "session-test" } } }; writeFileSync(`${process.env.FM_HOME}/state/.lock`, "999999\n"); await hooks.event(event); -await new Promise((resolve) => setTimeout(resolve, 120)); +await globalThis.__firstmateOpenCodeWatchArm.ensureArmed("session-test", client); if (existsSync(process.env.FM_ARM_LOG)) { console.error("watch arm ran without owning the session lock"); process.exit(1);