From 1cd30e99016885b7fa62ecfc25a8ee2718d7c6a5 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 28 Sep 2026 17:27:57 -0700 Subject: [PATCH 01/97] Codex: record the effective profile with each seat (#1504) * Record the effective profile path with each seat * Use selected profile for rollout discovery * Normalize profile paths in resume tests --- .../types/codex/codex-record-session.sh | 35 ++++++++++++--- scripts/drivers/types/codex/template.md | 2 +- scripts/lib/role-session.sh | 4 +- tests/test_codex_resume.bats | 44 ++++++++++++++++--- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/scripts/drivers/types/codex/codex-record-session.sh b/scripts/drivers/types/codex/codex-record-session.sh index 843dce0f4..41e29d0b4 100755 --- a/scripts/drivers/types/codex/codex-record-session.sh +++ b/scripts/drivers/types/codex/codex-record-session.sh @@ -5,8 +5,9 @@ # otherwise send-side only and never runs actas-claim, so without this a codex # role would have no role-session record and could never be resumed (spawn would # always boot it fresh). This is the codex-side equivalent: the codex actas flow -# calls it, and it writes the record so a later spawn/resume brings the role back -# into its thread. +# calls it, and it writes the thread plus the effective CODEX_HOME so a later +# spawn/resume brings the role back into its thread and profile. The /clear +# recovery in self-fix.sh calls this same script after #1470 rebinds the seat. # # Usage: codex-record-session.sh [project] # @@ -160,6 +161,28 @@ if [ "$probe_ran" = "1" ]; then [ -n "$thread" ] || exit 0 fi +# Resolve the effective profile before either fallback can infer a thread. The +# rollout index belongs to CODEX_HOME, not necessarily to the process HOME. +# Codex defaults to $HOME/.codex when CODEX_HOME is unset; resolve either +# spelling to a physical absolute path so discovery and the stored destination +# use the same profile. A missing or malformed directory is not safe to publish +# as a delivery destination, so leave the previous record untouched. +codex_home="${CODEX_HOME:-}" +if [ -z "$codex_home" ]; then + [ -n "${HOME:-}" ] || exit 0 + codex_home="$HOME/.codex" +fi +case "$codex_home" in *[[:cntrl:]]*) exit 0 ;; esac +[ -d "$codex_home" ] || exit 0 +codex_home="$(agmsg_canonical_path "$codex_home")" +# Keep the absolute path in the cross-platform form used by Node consumers; +# Git Bash's physical /c/... spelling is normalized to C:/... on Windows. +codex_home="$(agmsg_normalize_project_path "$codex_home")" +case "$codex_home" in + /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; + *) exit 0 ;; +esac + if [ -z "$thread" ]; then # No app-server to ask, or it could not be reached -- a codex session outside # monitor mode, a missing Node, a server that is not answering. The rollout scan @@ -167,10 +190,8 @@ if [ -z "$thread" ]; then # single-rollout case it always did, and on a project with history it records # nothing, which is what happens today. # - # ${HOME:-} so an unset HOME under `set -u` is a silent no-op (empty -> the - # dir check below fails -> fresh), not an unbound-variable abort (nit). - sessions_dir="${HOME:-}/.codex/sessions" - if [ -n "${HOME:-}" ] && [ -d "$sessions_dir" ]; then + sessions_dir="$codex_home/sessions" + if [ -d "$sessions_dir" ]; then # Distinct thread ids whose session_meta cwd (canonicalized -- codex records # the physical cwd while agmsg may hold a symlinked path, #160) matches the # project, among the most recent rollouts. Exactly one => unambiguously ours. @@ -212,7 +233,7 @@ fi # as-is. The project is recorded in its canonical (physical) form so records # carry one path spelling regardless of how the caller spelled the argument. agmsg_role_session_load "$TEAM" "$AGENT" 2>/dev/null || true -agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" || true +agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" "$codex_home" || true # The Codex actas flow reaches this script instead of actas-claim.sh. Publish # the same seat request here so a resumed seat's dispatcher has an authority diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 2877f0382..631887950 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -18,7 +18,7 @@ Do not use POSIX `'"'"'` quote splicing in PowerShell, and do not use escaped do If argument starts with "actas" followed by an agent name: 1. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" __AGENT_TYPE__`. If `` is not listed, join with `~/.agents/skills/__SKILL_NAME__/scripts/join.sh __AGENT_TYPE__ "$(pwd)"`. -2. Record the Codex thread so a later spawn can resume it: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. +2. Record this Codex thread and its effective profile directory so a later spawn can resume it and route notices to the right profile: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. 3. Use the role as the active FROM; monitor delivery is routed only to its recorded thread. diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index e00f926f8..71057b1c1 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -137,9 +137,10 @@ agmsg_role_session_load() { # from the type manifest. Empty when unknown. # project= the resolved project root # owner= the actas owner token written by actas-claim +# codex_home= the effective absolute Codex profile directory # updated_at= best-effort timestamp (empty if date(1) unavailable) agmsg_role_session_record() { - local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" + local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" codex_home="${7:-}" [ -n "$team" ] && [ -n "$agent" ] && [ -n "$bare_sid" ] || return 0 local path dir tmp ts named_ref="" named_epoch="" named_at="" _agmsg_role_session_path_into "$team" "$agent" @@ -163,6 +164,7 @@ agmsg_role_session_record() { printf 'type=%s\n' "$type" printf 'project=%s\n' "$project" [ -z "$owner" ] || printf 'owner=%s\n' "$owner" + [ -z "$codex_home" ] || printf 'codex_home=%s\n' "$codex_home" printf 'updated_at=%s\n' "$ts" [ -z "$named_ref" ] || printf 'named_ref=%s\n' "$named_ref" [ -z "$named_ref" ] || printf 'named_epoch=%s\n' "$named_epoch" diff --git a/tests/test_codex_resume.bats b/tests/test_codex_resume.bats index 7a4d1a830..4a3738b64 100644 --- a/tests/test_codex_resume.bats +++ b/tests/test_codex_resume.bats @@ -14,6 +14,8 @@ setup() { export RUN_DIR="$SKILL_DIR/run" mkdir -p "$RUN_DIR" export CODEX_SESSIONS="$HOME/.codex/sessions" + export CODEX_HOME="$HOME/.codex" + mkdir -p "$CODEX_HOME" } teardown() { teardown_test_env; } @@ -68,21 +70,51 @@ recorded_uuid() { agmsg_role_session_uuid "$1" "$2" } -@test "codex record: prefers CODEX_THREAD_ID (unambiguous env path)" { - local proj; proj="$(mktemp -d)" - CODEX_THREAD_ID="env-thread-1" \ +@test "codex record: stores the thread and effective profile path" { + local proj explicit_home expected_home; proj="$(mktemp -d)" + explicit_home="$TEST_SKILL_DIR/codex profile" + mkdir -p "$explicit_home" "$HOME/.codex" + CODEX_HOME="$explicit_home" CODEX_THREAD_ID="env-thread-1" \ bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" [ "$(recorded_uuid team alice)" = "env-thread-1" ] - # type is recorded as codex. source "$SKILL_DIR/scripts/lib/role-session.sh" [ "$(agmsg_role_session_get team alice type)" = "codex" ] + expected_home="$(cd "$explicit_home" && pwd -P)" + # Match the recorder's cross-platform path spelling (not raw Git Bash /c/...). + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/resolve-project.sh" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] + + # Unset CODEX_HOME uses the same default Codex uses, recorded as an absolute + # path rather than leaving a later reader to infer it from its own HOME. + env -u CODEX_HOME CODEX_THREAD_ID="env-thread-2" \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + [ "$(recorded_uuid team alice)" = "env-thread-2" ] + expected_home="$(cd "$HOME/.codex" && pwd -P)" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: falls back to the unique matching-cwd rollout when env is unset" { - local proj; proj="$(mktemp -d)" + local proj explicit_home expected_home; proj="$(mktemp -d)" + explicit_home="$TEST_SKILL_DIR/profile-B" + mkdir -p "$explicit_home" + # A matching rollout in the default profile must not outrank the selected + # profile's own unique matching rollout. + CODEX_SESSIONS="$HOME/.codex/sessions" + make_rollout "wrong-profile-uuid" "$proj" + CODEX_SESSIONS="$explicit_home/sessions" make_rollout "fallback-uuid" "$proj" - ( unset CODEX_THREAD_ID; bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) + CODEX_HOME="$explicit_home" env -u CODEX_THREAD_ID \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" [ "$(recorded_uuid team alice)" = "fallback-uuid" ] + source "$SKILL_DIR/scripts/lib/role-session.sh" + expected_home="$(cd "$explicit_home" && pwd -P)" + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/resolve-project.sh" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: records NOTHING when two recent rollouts share the cwd (ambiguous)" { From f2e134db73a31b9d8c3bd53bc9696a28d2b2d9ad Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 28 Sep 2026 22:12:38 -0700 Subject: [PATCH 02/97] agmsgd beta: skeleton (schema, owner/lifecycle, control socket, status, daemon.sh CLI) (#1505) ## Scope agmsgd beta skeleton: install.db schema (meta, daemon_owner, daemon_intent, daemon_start_attempts), executor liveness (pid + boot id), the daemon_owner CAS lifecycle, completion-record verification (install_id + digest against run/install-manifest.json), per-cycle drift watch (moved manifest / VERSION bump / in-place edit under scripts/), the control socket (stop/status only), log rotation, status.mjs (the owner/intent decision table, narrowed to the 3 beta-owned tables), main.mjs, the fixed bootstrap pair (agmsgd-launch.sh + agmsgd), and the top-level scripts/daemon.sh CLI (start/stop/status/enable/disable; resident registration for launchd/systemd --user/Task Scheduler). Also extracted the #963 detector (collectInstallBaseline / installChangedAgainst) out of scripts/internal/remote-sync.mjs into scripts/internal/install-baseline.mjs, with remote-sync.mjs re-exporting both names unchanged; tests/remote_sync_engine.test.mjs passes without modification (115/115). All plain .mjs, no build step, no external packages. ## Stop sequence verification The focused stop-sequence test passed 20 consecutive runs without reproducing the intermittent hang after closing inherited file descriptors in both the lock-holding sqlite3 child and the launcher child, and removing the duplicate sqlite3 .quit write. These are candidate causes; the root cause is not established. ## Spawn environment CODEX_HOME is intentionally inherited by spawned Codex seats because codex-record-session.sh records the active profile for resume, and the spawned Codex must use that same profile. ## Tested - .github/scripts/check-enforced-assertions.sh: 621 unenforceable assertions, at the existing baseline; passed. - Focused assertions in tests/test_agmsgd_daemon_sh.bats: 4/4 passed; tests/test_agmsgd_entrypoint.bats: 1/1 passed; tests/test_agmsgd_launch.bats: 1/1 passed. - The stop-sequence test in tests/test_agmsgd_daemon_sh.bats passed 20 consecutive runs. - launchd/systemd/schtasks commands are overridable (AGMSGD_LAUNCHCTL/AGMSGD_SYSTEMCTL/AGMSGD_SCHTASKS); automated tests never call the real resident-manager binaries, only a fake stand-in. Real registration is exercised by hand only, and always torn down right after. ## Not yet verified - Linux (systemd --user) and Windows (Task Scheduler / schtasks XML) resident-registration code paths are written and shellchecked but not yet run on real Linux/Windows hardware. --- scripts/daemon.sh | 530 ++++++++++++++++++++++++++ scripts/daemon/agmsgd | 147 +++++++ scripts/daemon/agmsgd-launch.sh | 122 ++++++ scripts/daemon/control.mjs | 193 ++++++++++ scripts/daemon/db.mjs | 48 +++ scripts/daemon/executor.mjs | 92 +++++ scripts/daemon/lifecycle.mjs | 187 +++++++++ scripts/daemon/log.mjs | 43 +++ scripts/daemon/main.mjs | 144 +++++++ scripts/daemon/owner.mjs | 130 +++++++ scripts/daemon/schema.sql | 79 ++++ scripts/daemon/status.mjs | 194 ++++++++++ scripts/internal/install-baseline.mjs | 104 +++++ scripts/internal/remote-sync.mjs | 102 +---- tests/agmsgd_control.test.mjs | 144 +++++++ tests/agmsgd_db.test.mjs | 69 ++++ tests/agmsgd_executor.test.mjs | 30 ++ tests/agmsgd_lifecycle.test.mjs | 177 +++++++++ tests/agmsgd_log.test.mjs | 33 ++ tests/agmsgd_main.test.mjs | 154 ++++++++ tests/agmsgd_owner.test.mjs | 155 ++++++++ tests/agmsgd_status.test.mjs | 101 +++++ tests/test_agmsgd_control.bats | 6 + tests/test_agmsgd_daemon_sh.bats | 365 ++++++++++++++++++ tests/test_agmsgd_db.bats | 6 + tests/test_agmsgd_entrypoint.bats | 116 ++++++ tests/test_agmsgd_executor.bats | 6 + tests/test_agmsgd_launch.bats | 130 +++++++ tests/test_agmsgd_lifecycle.bats | 6 + tests/test_agmsgd_log.bats | 6 + tests/test_agmsgd_main.bats | 6 + tests/test_agmsgd_owner.bats | 6 + tests/test_agmsgd_status.bats | 6 + tests/test_spawn.bats | 6 +- 34 files changed, 3548 insertions(+), 95 deletions(-) create mode 100644 scripts/daemon.sh create mode 100755 scripts/daemon/agmsgd create mode 100755 scripts/daemon/agmsgd-launch.sh create mode 100644 scripts/daemon/control.mjs create mode 100644 scripts/daemon/db.mjs create mode 100644 scripts/daemon/executor.mjs create mode 100644 scripts/daemon/lifecycle.mjs create mode 100644 scripts/daemon/log.mjs create mode 100644 scripts/daemon/main.mjs create mode 100644 scripts/daemon/owner.mjs create mode 100644 scripts/daemon/schema.sql create mode 100644 scripts/daemon/status.mjs create mode 100644 scripts/internal/install-baseline.mjs create mode 100644 tests/agmsgd_control.test.mjs create mode 100644 tests/agmsgd_db.test.mjs create mode 100644 tests/agmsgd_executor.test.mjs create mode 100644 tests/agmsgd_lifecycle.test.mjs create mode 100644 tests/agmsgd_log.test.mjs create mode 100644 tests/agmsgd_main.test.mjs create mode 100644 tests/agmsgd_owner.test.mjs create mode 100644 tests/agmsgd_status.test.mjs create mode 100644 tests/test_agmsgd_control.bats create mode 100644 tests/test_agmsgd_daemon_sh.bats create mode 100644 tests/test_agmsgd_db.bats create mode 100644 tests/test_agmsgd_entrypoint.bats create mode 100644 tests/test_agmsgd_executor.bats create mode 100644 tests/test_agmsgd_launch.bats create mode 100644 tests/test_agmsgd_lifecycle.bats create mode 100644 tests/test_agmsgd_log.bats create mode 100644 tests/test_agmsgd_main.bats create mode 100644 tests/test_agmsgd_owner.bats create mode 100644 tests/test_agmsgd_status.bats diff --git a/scripts/daemon.sh b/scripts/daemon.sh new file mode 100644 index 000000000..eb6ad7f1b --- /dev/null +++ b/scripts/daemon.sh @@ -0,0 +1,530 @@ +#!/usr/bin/env bash +# agmsgd's CLI ("agmsg daemon start|stop|status|enable|disable"). +# Written to take the subcommand as its own first argument so +# the future `agmsg` dispatcher (a separate PR/design, decided 2026-09-29) +# can wrap this file directly without restructuring it. Every message this +# file prints therefore already says `agmsg daemon ...`, the form users +# will actually type once that dispatcher exists. +# +# No Node is required for `status` on the fast path: it reads +# install.db directly with sqlite3 first, and only shells out to Node +# (status.mjs) for the fuller decision text when Node is available and +# the record even suggests it's worth asking. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +INSTALL_DB="$SKILL_DIR/run/install.db" +LOCK_DB="$SKILL_DIR/run/install-op.lock.db" +LAUNCHER="$SCRIPT_DIR/daemon/agmsgd-launch.sh" + +# Overridable so tests never register anything into the REAL resident +# manager (the real gui launchd domain, the real systemd --user, the real +# Task Scheduler) -- a test that killed a hung process outright, bypassing +# its own teardown, left exactly one real launchd unit behind on this +# machine. Tests point these at fake stand-ins; real registration is +# exercised only by hand and always torn down right after. +AGMSGD_LAUNCHCTL="${AGMSGD_LAUNCHCTL:-launchctl}" +AGMSGD_SYSTEMCTL="${AGMSGD_SYSTEMCTL:-systemctl}" +AGMSGD_SCHTASKS="${AGMSGD_SCHTASKS:-schtasks}" + +_usage() { + cat >&2 <<'EOF' +usage: agmsg daemon start|stop|status|enable|disable +EOF +} + +_require_install_db() { + if [ ! -f "$INSTALL_DB" ]; then + echo "agmsg daemon: run/install.db does not exist -- run install.sh first" >&2 + exit 1 + fi +} + +# Runs $1 (one or more SQL statements against install.db's tables, +# unqualified names -- see below) inside the operation lock. Optional +# remaining arguments name one resident-manager action that must share the +# same lock (enable/register or disable/unregister). +# +# The EXCLUSIVE lock belongs to $LOCK_DB (an empty file, PR 2's own +# design -- the lock IS the file, nothing is ever written into it +# directly). $1's statements target install.db, so install.db is ATTACHed +# into the SAME connection/transaction that holds the lock: a bare +# `sqlite3 "$LOCK_DB" "...UPDATE daemon_intent..."` would run that UPDATE +# against $LOCK_DB itself, which has no such table at all -- caught +# exactly this way while testing (`no such table: daemon_intent` even +# though install.db plainly has the table). ATTACH keeps the lock where +# PR 2 says it lives while still writing to the file that actually holds +# the data, in one real cross-file transaction. +_install_generation() { + local install_id manifest_path_sql manifest_id manifest_gen + install_id="$(sqlite3 "$INSTALL_DB" "SELECT install_id FROM meta;" 2>/dev/null)" || return 1 + manifest_path_sql="$(printf '%s' "$SKILL_DIR/run/install-manifest.json" | sed "s/'/''/g")" + IFS='|' read -r manifest_id manifest_gen < <(sqlite3 :memory: "SELECT json_extract(CAST(readfile('$manifest_path_sql') AS TEXT), '\$.install_id') || '|' || json_extract(CAST(readfile('$manifest_path_sql') AS TEXT), '\$.gen');" 2>/dev/null) || return 1 + [ -n "$install_id" ] && [ "$manifest_id" = "$install_id" ] && [ -n "$manifest_gen" ] || return 1 + printf '%s:%s' "$install_id" "$manifest_gen" +} + +# Hold the SQLite installation lock across the database change and the +# resident-manager operation. The install generation is captured before +# acquisition and rechecked while the lock is held, so an upgrade between +# those points cannot register an obsolete launcher. +_with_op_lock() { + local statements="$1" expected_generation current_generation ready applied lock_pid attach_path lock_dir + local AGMSGD_LOCK_INSTALL_ID + shift + expected_generation="$(_install_generation)" || { + echo "agmsg daemon: cannot read a complete install generation" >&2 + return 1 + } + AGMSGD_LOCK_INSTALL_ID="${expected_generation%%:*}" + lock_dir="$(mktemp -d "$SKILL_DIR/run/daemon-op-lock.XXXXXX")" || return 1 + mkfifo "$lock_dir/in" "$lock_dir/out" + sqlite3 -batch "$LOCK_DB" < "$lock_dir/in" > "$lock_dir/out" 3>&- 4>&- & + lock_pid=$! + exec 8> "$lock_dir/in" + exec 9< "$lock_dir/out" + attach_path="$(printf '%s' "$INSTALL_DB" | sed "s/'/''/g")" + printf '%s\n' '.bail on' "ATTACH DATABASE '$attach_path' AS installdb;" 'BEGIN EXCLUSIVE;' '.print LOCKED' >&8 + if ! IFS= read -r -t 10 ready <&9 || [ "$ready" != "LOCKED" ]; then + printf 'ROLLBACK;\n.quit\n' >&8 2>/dev/null || true + exec 8>&- + exec 9<&- + wait "$lock_pid" 2>/dev/null || true + rm -f "$lock_dir/in" "$lock_dir/out" + rmdir "$lock_dir" + echo "agmsg daemon: could not acquire the install operation lock" >&2 + return 1 + fi + local generation_row generation_marker manifest_sql + manifest_sql="$(printf '%s' "$SKILL_DIR/run/install-manifest.json" | sed "s/'/''/g")" + printf '%s\n' "SELECT CASE WHEN json_extract(CAST(readfile('$manifest_sql') AS TEXT), '\$.install_id') = (SELECT install_id FROM installdb.meta) THEN (SELECT install_id FROM installdb.meta) || ':' || json_extract(CAST(readfile('$manifest_sql') AS TEXT), '\$.gen') ELSE '' END;" '.print GENERATION_END' >&8 + IFS= read -r -t 10 generation_row <&9 || generation_row="" + IFS= read -r -t 10 generation_marker <&9 || generation_marker="" + current_generation="$generation_row" + if [ "$generation_marker" != "GENERATION_END" ] || [ "$current_generation" != "$expected_generation" ]; then + printf 'ROLLBACK;\n.quit\n' >&8 + exec 8>&- + exec 9<&- + wait "$lock_pid" 2>/dev/null || true + rm -f "$lock_dir/in" "$lock_dir/out" + rmdir "$lock_dir" + echo "agmsg daemon: install generation changed while acquiring the operation lock; try again" >&2 + return 1 + fi + if [ -n "$statements" ]; then + printf '%s\n.print APPLIED\n' "$statements" >&8 + if ! IFS= read -r -t 10 applied <&9 || [ "$applied" != "APPLIED" ]; then + printf 'ROLLBACK;\n.quit\n' >&8 2>/dev/null || true + exec 8>&- + exec 9<&- + wait "$lock_pid" 2>/dev/null || true + rm -f "$lock_dir/in" "$lock_dir/out" + rmdir "$lock_dir" + echo "agmsg daemon: install database update failed" >&2 + return 1 + fi + fi + local action_status=0 + if [ "$#" -gt 0 ]; then + "$@" || action_status=$? + fi + if [ "$action_status" -eq 0 ]; then + printf 'COMMIT;\n' >&8 + else + printf 'ROLLBACK;\n' >&8 + fi + printf '.quit\n' >&8 + exec 8>&- + exec 9<&- + wait "$lock_pid" || action_status=1 + rm -f "$lock_dir/in" "$lock_dir/out" + rmdir "$lock_dir" + return "$action_status" +} + +# A short, stable id for this install's resident-manager unit name, +# derived from the install anchor (realpath + install_id). Beta uses two +# of the three (root path, install_id); the record DB's own path is +# already implied by being run/install.db under this same root, so it is +# not a third independent input here. +_unit_id() { + local install_id="${AGMSGD_LOCK_INSTALL_ID:-}" + if [ -z "$install_id" ]; then + install_id="$(sqlite3 "$INSTALL_DB" "SELECT install_id FROM meta;" 2>/dev/null)" + fi + printf '%s:%s' "$SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12 +} + +_os() { + case "$(uname -s)" in + Darwin) echo darwin ;; + Linux) echo linux ;; + MINGW*|MSYS*|CYGWIN*) echo windows ;; + *) echo unknown ;; + esac +} + +# --------------------------------------------------------------------------- +# Resident registration. One function per OS; enable/ +# disable call whichever applies. Only the darwin path has been run for +# real in this environment -- linux (systemd --user) and windows (Task +# Scheduler) use the same contract and are shellchecked, but need +# their own hands-on confirmation on those platforms, flagged in the PR +# rather than claimed here. +# --------------------------------------------------------------------------- + +_launchd_label() { printf 'cc.agmsg.agmsgd.%s' "$(_unit_id)"; } +_launchd_plist_path() { printf '%s/Library/LaunchAgents/%s.plist' "$HOME" "$(_launchd_label)"; } + +_register_darwin() { + local node_path="$1" label plist + label="$(_launchd_label)" + plist="$(_launchd_plist_path)" + mkdir -p "$(dirname "$plist")" + cat > "$plist" < + + + + Label$label + ProgramArguments + + $LAUNCHER + + RunAtLoad + KeepAlive + + SuccessfulExit + + ThrottleInterval60 + StandardErrorPath$SKILL_DIR/run/agmsgd.stderr.log + + +EOF + "$AGMSGD_LAUNCHCTL" bootstrap "gui/$(id -u)" "$plist" 2>/dev/null || "$AGMSGD_LAUNCHCTL" load "$plist" +} + +_unregister_darwin() { + local label plist + label="$(_launchd_label)" + plist="$(_launchd_plist_path)" + if [ -f "$plist" ]; then + if ! "$AGMSGD_LAUNCHCTL" bootout "gui/$(id -u)/$label" 2>/dev/null && ! "$AGMSGD_LAUNCHCTL" unload "$plist" 2>/dev/null; then + echo "agmsg daemon: could not unregister launchd service $label" >&2 + return 1 + fi + rm -f "$plist" + fi +} + +_systemd_unit_path() { printf '%s/.config/systemd/user/agmsgd-%s.service' "$HOME" "$(_unit_id)"; } + +# NOT YET RUN ON LINUX in this environment -- configured with +# (Restart=on-failure, RestartSec=30s, StartLimitIntervalSec=600, +# StartLimitBurst=5) and shellchecked only. +_register_linux() { + local unit_path unit_name + unit_path="$(_systemd_unit_path)" + unit_name="$(basename "$unit_path")" + mkdir -p "$(dirname "$unit_path")" + cat > "$unit_path" <&2 + return 1 + fi + rm -f "$unit_path" + "$AGMSGD_SYSTEMCTL" --user daemon-reload + fi +} + +_schtasks_name() { printf 'agmsgd-%s' "$(_unit_id)"; } + +_service_registered() { + case "$(_os)" in + darwin) [ -f "$(_launchd_plist_path)" ] ;; + linux) [ -f "$(_systemd_unit_path)" ] ;; + windows) [ -f "$SKILL_DIR/run/$(_schtasks_name).xml" ] ;; + *) return 1 ;; + esac +} + +_start_registered_service() { + case "$(_os)" in + darwin) "$AGMSGD_LAUNCHCTL" kickstart "gui/$(id -u)/$(_launchd_label)" ;; + linux) "$AGMSGD_SYSTEMCTL" --user start "$(basename "$(_systemd_unit_path)")" ;; + windows) "$AGMSGD_SCHTASKS" /Run /TN "$(_schtasks_name)" ;; + *) echo "agmsg daemon start: unsupported resident manager" >&2; return 1 ;; + esac +} + +_start_unregistered_launcher() { + bash "$LAUNCHER" /dev/null 2>&1 3>&- 4>&- & + disown 2>/dev/null || true +} + +# NOT YET RUN ON WINDOWS in this environment -- written to the Windows +# Task Scheduler contract (LogonTrigger + RestartOnFailure via XML, since plain +# `/Create` flags do not set restart-on-failure) and shellchecked only. +_register_windows() { + local node_path="$1" name xml + name="$(_schtasks_name)" + xml="$SKILL_DIR/run/${name}.xml" + local native_launcher + native_launcher="$(cygpath -w "$LAUNCHER" 2>/dev/null || printf '%s' "$LAUNCHER")" + local xml_utf8="$xml.utf8" + cat > "$xml_utf8" < + + + true + + + + bash.exe + "$native_launcher" + + + + + PT1M + 3 + + + +EOF + { printf '\xff\xfe'; iconv -f UTF-8 -t UTF-16LE "$xml_utf8"; } > "$xml" + rm -f "$xml_utf8" + "$AGMSGD_SCHTASKS" /Create /TN "$name" /XML "$xml" /F +} + +_unregister_windows() { + local name xml + name="$(_schtasks_name)" + xml="$SKILL_DIR/run/$name.xml" + if [ ! -f "$xml" ]; then + return 0 + fi + if ! "$AGMSGD_SCHTASKS" /Delete /TN "$name" /F; then + echo "agmsg daemon: could not delete scheduled task $name" >&2 + return 1 + fi + rm -f "$xml" +} + +_register() { + case "$(_os)" in + darwin) _register_darwin "$1" ;; + linux) _register_linux "$1" ;; + windows) _register_windows "$1" ;; + *) echo "agmsg daemon: unsupported OS for resident registration" >&2; return 1 ;; + esac +} + +_unregister() { + case "$(_os)" in + darwin) _unregister_darwin ;; + linux) _unregister_linux ;; + windows) _unregister_windows ;; + *) return 0 ;; + esac +} + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + +# Confirms Node by actually running it, the same check +# agmsgd-launch.sh itself does -- enable/start both need this before +# recording node_path/node_version. +_resolve_node() { + local candidate="${1:-node}" resolved + resolved="$(command -v "$candidate" 2>/dev/null)" || return 1 + "$resolved" -e ' + const [maj, min] = process.versions.node.split(".").map(Number); + if (maj < 22 || (maj === 22 && min < 13)) process.exit(1); + require("node:sqlite"); + ' 2>/dev/null || return 1 + printf '%s' "$resolved" +} + +_wait_for_ready() { + local target_op_gen="$1" waited=0 + while [ "$waited" -lt 100 ]; do + local state op_gen + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + op_gen="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;" 2>/dev/null || true)" + if [ "$state" = "ready" ] && [ "$op_gen" = "$target_op_gen" ]; then + return 0 + fi + waited=$((waited + 1)) + sleep 0.1 + done + return 1 +} + +cmd_start() { + _require_install_db + local state + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;")" + if [ "$state" = "ready" ] && node "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" >/dev/null 2>&1; then + echo "agmsg daemon start: already running" + return 0 + fi + + local new_op_gen + _with_op_lock "UPDATE daemon_intent SET desired = 'on', op_gen = op_gen + 1, set_by = 'start', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" + new_op_gen="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;")" + if _service_registered; then + if ! _start_registered_service; then + echo "agmsg daemon start: the registered service manager could not start agmsgd" >&2 + return 1 + fi + else + _start_unregistered_launcher + fi + if _wait_for_ready "$new_op_gen"; then + echo "agmsg daemon start: running" + else + echo "agmsg daemon start: did not become ready in time -- check 'agmsg daemon status'" >&2 + return 1 + fi +} + +# Best-effort courtesy nudge over the control socket -- NOT what confirms +# a stop; the caller's own polling of daemon_owner.state does that. A hard +# 5s ceiling: relying on the socket's own close/error events alone left +# this able to hang the whole command indefinitely if the daemon never +# answers (observed while testing: a daemon that had already started +# stepping aside for an unrelated reason at the same moment left this +# connection open with neither event firing). +_request_stop_over_socket() { + local socket="$1" + [ -n "$socket" ] || return 0 + node -e ' + const net = require("node:net"); + const s = net.createConnection(process.argv[1]); + const done = () => { try { s.destroy(); } catch {} process.exit(0); }; + setTimeout(done, 5000); + s.on("connect", () => { + s.write(JSON.stringify({type:"hello",protocol:1,role:"control"}) + "\n"); + s.write(JSON.stringify({type:"stop"}) + "\n"); + }); + s.on("close", done); + s.on("error", done); + ' "$socket" 2>/dev/null || true +} + +cmd_stop() { + _require_install_db + # Stop takes no operation lock. + local desired + desired="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || true)" + if [ "$desired" != "on" ]; then + echo "agmsg daemon stop: already stopped" + return 0 + fi + sqlite3 "$INSTALL_DB" "UPDATE daemon_intent SET desired = 'off', op_gen = op_gen + 1, set_by = 'stop', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" + local socket + socket="$(sqlite3 "$INSTALL_DB" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null || true)" + _request_stop_over_socket "$socket" + local waited=0 + while [ "$waited" -lt 100 ]; do + local state + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + [ "$state" = "none" ] && { echo "agmsg daemon stop: stopped"; return 0; } + waited=$((waited + 1)) + sleep 0.1 + done + echo "agmsg daemon stop: requested, but it has not confirmed stopping yet -- check 'agmsg daemon status'" >&2 + return 1 +} + +cmd_status() { + _require_install_db + local node_path + node_path="$(_resolve_node node 2>/dev/null || true)" + if [ -n "$node_path" ]; then + "$node_path" "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" + return $? + fi + # K14 fallback: no usable Node at all -- a minimal, honest read straight + # from install.db rather than the fuller status.mjs decision text. + local state desired + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || echo "unknown")" + desired="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || echo "unknown")" + echo "agmsg daemon status: state=$state intent=$desired (no usable Node -- a fuller check needs 'agmsg daemon enable')" +} + +cmd_enable() { + _require_install_db + local node_path + node_path="$(_resolve_node node)" || { + echo "agmsg daemon enable: no usable Node found (need >= 22.13.0 with node:sqlite). Install one and try again." >&2 + return 1 + } + local node_version + node_version="$("$node_path" -e 'console.log(process.versions.node)')" + _with_op_lock " + UPDATE meta SET node_path = '$(printf '%s' "$node_path" | sed "s/'/''/g")', node_version = '$node_version'; + UPDATE daemon_intent SET desired = 'on'; + " _register "$node_path" || { + if ! _with_op_lock "" _unregister; then + echo "agmsg daemon enable: could not clean up the partially registered service" >&2 + fi + return 1 + } + cmd_start +} + +cmd_disable() { + _require_install_db + _with_op_lock "UPDATE daemon_intent SET desired = 'off', op_gen = op_gen + 1, set_by = 'disable', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" _unregister + local socket + socket="$(sqlite3 "$INSTALL_DB" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null || true)" + _request_stop_over_socket "$socket" + local waited=0 state + while [ "$waited" -lt 100 ]; do + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + [ "$state" = "none" ] && break + waited=$((waited + 1)) + sleep 0.1 + done + if [ "$state" != "none" ]; then + echo "agmsg daemon disable: service was unregistered but agmsgd has not confirmed stopping -- check 'agmsg daemon status'" >&2 + return 1 + fi + echo "agmsg daemon disable: agmsgd turned off. Codex messages will go through the existing bridge again." + echo "agmsg daemon disable: any Codex session started while agmsgd was in use needs to be restarted to get its bridge back." +} + +case "${1:-}" in + start) cmd_start ;; + stop) cmd_stop ;; + status) cmd_status ;; + enable) cmd_enable ;; + disable) cmd_disable ;; + *) _usage; exit 2 ;; +esac diff --git a/scripts/daemon/agmsgd b/scripts/daemon/agmsgd new file mode 100755 index 000000000..dc8ab5673 --- /dev/null +++ b/scripts/daemon/agmsgd @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// agmsgd's entrypoint, executed by agmsgd-launch.sh. +// +// FIXED BOOTSTRAP, on purpose, same reasoning as agmsgd-launch.sh: this +// file does NOT import any other scripts/daemon/*.mjs module until AFTER +// it has, itself, under the install-op lock, confirmed (1) an install +// generation exists, (2) ITS OWN bytes are exactly what the completion +// record says they should be, (3) its own BOOTSTRAP_VERSION matches the +// record, and (4) the full scripts/ tree matches the record too. Every +// check below is therefore self-contained -- duplicated, deliberately, +// from lifecycle.mjs's own digest logic rather than importing it, because +// importing it would be importing "another module" before step 4 has +// even run. +// +// install.sh extracts this exact line (`^const BOOTSTRAP_VERSION = `, +// PR 2, 2026-09-29) and cross-checks it against agmsgd-launch.sh's own +// `^BOOTSTRAP_VERSION=` line before writing either into the completion +// record's bootstrap_version field. +const BOOTSTRAP_VERSION = 1; + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { DatabaseSync } from "node:sqlite"; + +function fail(code, message) { + process.stderr.write(`agmsgd: ${message}\n`); + process.exit(code); +} + +function collectScriptFiles(installRoot) { + const files = []; + const root = join(installRoot, "scripts"); + function walk(dir, relPrefix) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) throw new Error(`symlink under scripts/: ${rel}`); + if (entry.isDirectory()) walk(abs, rel); + else if (entry.isFile()) files.push(rel); + } + } + walk(root, "scripts"); + files.sort(); + return files; +} + +function sha256File(installRoot, relPath) { + return createHash("sha256").update(readFileSync(join(installRoot, relPath))).digest("hex"); +} + +async function main() { + const [installRoot, desired, opGenStr] = process.argv.slice(2); + if (!installRoot || !desired || !opGenStr) { + fail(75, "usage: agmsgd (only agmsgd-launch.sh should invoke this)"); + } + const expectedOpGen = Number(opGenStr); + + const lockPath = join(installRoot, "run", "install-op.lock.db"); + const manifestPath = join(installRoot, "run", "install-manifest.json"); + const dbPath = join(installRoot, "run", "install.db"); + + const lockDb = new DatabaseSync(lockPath); + lockDb.exec("BEGIN EXCLUSIVE;"); + let manifest; + let mainModule; + try { + // 1. An install generation exists at all. + if (!existsSync(manifestPath)) { + fail(0, "no completion record -- install.sh needs to run again"); + } + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + + // 2. This file's own digest is in the record, at its own path. + const selfPath = "scripts/daemon/agmsgd"; + const selfEntry = manifest.files.find((f) => f.path === selfPath); + if (!selfEntry) fail(75, `completion record has no entry for ${selfPath}`); + const selfDigest = sha256File(installRoot, selfPath); + if (selfDigest !== selfEntry.digest) { + fail(75, "this file's own digest does not match the completion record"); + } + + // 3. Bootstrap version matches; the launcher checks its own version too. + if (manifest.bootstrap_version !== BOOTSTRAP_VERSION) { + fail(75, `bootstrap version mismatch: this file is ${BOOTSTRAP_VERSION}, record has ${manifest.bootstrap_version}`); + } + + // 4. install_id: install.db's meta is the source of truth (PR 2); the + // manifest carries only a copy. + const installDb = new DatabaseSync(dbPath, { readOnly: true }); + let recordedInstallId; + try { + recordedInstallId = installDb.prepare("SELECT install_id FROM meta").get().install_id; + } finally { + installDb.close(); + } + if (recordedInstallId !== manifest.install_id) { + fail(75, `install_id mismatch: install.db has ${recordedInstallId ?? "(none)"}, manifest has ${manifest.install_id}`); + } + + // 5. Full digest verification: every file the manifest lists, no extras, + // no missing files, and no symlinks. + if (manifest.digest_algo !== "sha256") { + fail(75, `unsupported digest_algo: ${manifest.digest_algo}`); + } + let onDisk; + try { + onDisk = collectScriptFiles(installRoot); + } catch (error) { + fail(75, `digest verification failed: ${error.message}`); + } + const expected = [...manifest.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const onDiskSet = new Set(onDisk); + const expectedPaths = expected.map((f) => f.path); + const expectedSet = new Set(expectedPaths); + const missing = expectedPaths.filter((p) => !onDiskSet.has(p)); + if (missing.length > 0) fail(75, `digest verification failed: missing file(s): ${missing.join(", ")}`); + const extra = onDisk.filter((p) => !expectedSet.has(p)); + if (extra.length > 0) fail(75, `digest verification failed: unexpected file(s): ${extra.join(", ")}`); + for (const { path, digest } of expected) { + if (sha256File(installRoot, path) !== digest) fail(75, `digest verification failed: digest mismatch: ${path}`); + } + + // All checks passed -- now, and only now, import the rest of this + // component. main.mjs's own module graph (owner/control/lifecycle/ + // log/status) is loaded here, still under the lock; release it only + // after the component is fully loaded. + mainModule = await import("./main.mjs"); + } finally { + lockDb.exec("COMMIT;"); + lockDb.close(); + } + + const db = new DatabaseSync(dbPath); + db.exec(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "schema.sql"), "utf8")); + await mainModule.main(db, { + installRoot, + manifest, + manifestText: readFileSync(manifestPath, "utf8"), + expectedDesired: desired, + expectedOpGen, + version: manifest.version, + }); +} + +main(); diff --git a/scripts/daemon/agmsgd-launch.sh b/scripts/daemon/agmsgd-launch.sh new file mode 100755 index 000000000..82e3dd9e9 --- /dev/null +++ b/scripts/daemon/agmsgd-launch.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# agmsgd's launcher. This is what the resident manager +# (launchd / systemd --user / Task Scheduler) actually starts. +# +# FIXED BOOTSTRAP, on purpose: this file's own bytes are checked against +# the completion record the same way scripts/daemon/agmsgd's are, so it +# must never drift version to version except by an +# explicit bootstrap-version bump recorded there. It therefore does NOT +# source scripts/lib/*.sh (those can and do change release to release) -- +# only external tools (sqlite3) and bash builtins. +# +# install.sh extracts this exact line (`^BOOTSTRAP_VERSION=`, PR 2, +# 2026-09-29) and cross-checks it against scripts/daemon/agmsgd's +# own `const BOOTSTRAP_VERSION = ` line before writing either into the +# completion record's bootstrap_version field. The line's shape must not +# change without install.sh's own extraction changing with it. +set -euo pipefail + +BOOTSTRAP_VERSION=1 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +INSTALL_DB="$SKILL_DIR/run/install.db" +MANIFEST="$SKILL_DIR/run/install-manifest.json" +LOCK_DB="$SKILL_DIR/run/install-op.lock.db" + +# Records a start attempt without claiming ownership. Best-effort: if the +# reason cannot be stored in install.db, it still reaches stderr. +_agmsgd_launch_record_attempt() { + local reason="$1" + if [ -f "$INSTALL_DB" ]; then + sqlite3 "$INSTALL_DB" \ + "INSERT INTO daemon_start_attempts (at, reason, executor_pid) VALUES (strftime('%Y-%m-%dT%H:%M:%fZ','now'), '$(printf '%s' "$reason" | sed "s/'/''/g")', $$);" \ + 2>/dev/null || true + fi + echo "agmsgd-launch: $reason" >&2 +} + +# Non-blocking probe of run/install-op.lock.db's BEGIN EXCLUSIVE (PR 2) -- +# an empty file this process never writes to, only locks against, exactly +# like lifecycle.mjs's own Node-side probe (kept in sync with it +# deliberately: same busy_timeout=0-then-BEGIN-EXCLUSIVE idiom, so bash and +# Node can never disagree about whether the lock is held). +_agmsgd_launch_lock_held() { + [ -f "$LOCK_DB" ] || return 1 + ! sqlite3 -cmd "PRAGMA busy_timeout=0;" "$LOCK_DB" "BEGIN EXCLUSIVE; ROLLBACK;" >/dev/null 2>&1 +} + +# Reads the completion record's bootstrap_version and compares it against +# this file's own $BOOTSTRAP_VERSION: both the launcher +# and the Node entrypoint check their own compiled-in constant against the +# SAME recorded value under the operation lock). Uses json_extract over +# readfile() -- the same idiom scripts/identities.sh already uses for team +# config.json -- rather than jq, which is not on this launcher's +# guaranteed PATH. Caller passes the already-confirmed-to-exist manifest +# path; this does not decide completeness, only the version match. +_agmsgd_launch_check_bootstrap_version() { + local manifest_path="$1" recorded + recorded="$(sqlite3 :memory: " + WITH raw(json) AS (SELECT CAST(readfile('$(printf '%s' "$manifest_path" | sed "s/'/''/g")') AS TEXT)) + SELECT json_extract(json, '\$.bootstrap_version') FROM raw; + " 2>/dev/null)" + [ "$recorded" = "$BOOTSTRAP_VERSION" ] +} + +# 1. install.db must be readable, or we must not assume `on`. +if [ ! -f "$INSTALL_DB" ]; then + echo "agmsgd-launch: run/install.db does not exist -- nothing to start" >&2 + exit 0 +fi +DESIRED="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null)" || { + _agmsgd_launch_record_attempt "install.db could not be read" + exit 0 +} + +# 2. Not `on` -> nothing to do, silently. +if [ "$DESIRED" != "on" ]; then + exit 0 +fi +OP_GEN="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;" 2>/dev/null)" + +# 3. Completion record state. +if [ -f "$MANIFEST" ]; then + : # complete -- proceed +elif [ -f "$MANIFEST.prev" ]; then + if _agmsgd_launch_lock_held; then + echo "agmsgd-launch: an install/uninstall is in progress" >&2 + exit 75 + fi + _agmsgd_launch_record_attempt "a previous update never completed -- run install.sh again" + exit 0 +else + _agmsgd_launch_record_attempt "no completion record -- install.sh needs to run again" + exit 0 +fi + +_agmsgd_launch_check_bootstrap_version "$MANIFEST" || { + _agmsgd_launch_record_attempt "bootstrap version mismatch" + exit 75 +} + +# 4. Node: resolve, then ACTUALLY RUN it; availability is confirmed by +# execution (version + node:sqlite loads), not by trusting the recorded +# path/version strings. +NODE_PATH="$(sqlite3 "$INSTALL_DB" "SELECT node_path FROM meta;" 2>/dev/null)" +if [ -z "$NODE_PATH" ] || [ ! -x "$NODE_PATH" ]; then + _agmsgd_launch_record_attempt "no usable Node recorded (run 'agmsg daemon enable' again)" + exit 0 +fi +NODE_CHECK_OUTPUT="$("$NODE_PATH" -e ' + const [maj, min] = process.versions.node.split(".").map(Number); + if (maj < 22 || (maj === 22 && min < 13)) { console.error("too old: " + process.versions.node); process.exit(1); } + require("node:sqlite"); + console.log(process.versions.node); +' 2>&1)" || { + _agmsgd_launch_record_attempt "recorded Node failed the version/node:sqlite check: $NODE_CHECK_OUTPUT" + exit 0 +} + +# 5. Hand off. `exec -a agmsgd` reads the entrypoint file exactly once, +# here, reading the entrypoint file only once. +exec -a agmsgd "$NODE_PATH" "$SCRIPT_DIR/agmsgd" "$SKILL_DIR" "$DESIRED" "$OP_GEN" diff --git a/scripts/daemon/control.mjs b/scripts/daemon/control.mjs new file mode 100644 index 000000000..8259c4476 --- /dev/null +++ b/scripts/daemon/control.mjs @@ -0,0 +1,193 @@ +// The UNIX control socket, narrowed to beta's own two +// requests: `stop` and `status`. Beta has no seat registration, no notice +// stream, no hook `turn` -- every connection is one-shot: connect, `hello`, +// exactly one request, exactly one response, close. +// +// Framing: one line, one JSON object. 1 MiB ceiling counted in +// BYTES from the first byte of the connection, checked before a newline +// ever arrives -- not after decoding to a string, since a byte count and a +// JS string's UTF-16 code-unit count are not the same number for non-ASCII +// input. Duplicate keys (including nested) are rejected via the existing +// parseStrictJson (scripts/internal/strict-jsonl.mjs) rather than a second +// implementation of the same rule. + +import { createServer } from "node:net"; +import { chmodSync, unlinkSync } from "node:fs"; +import { parseStrictJson } from "../internal/strict-jsonl.mjs"; + +const MAX_FRAME_BYTES = 1024 * 1024; +export const PROTOCOL_VERSION = 1; + +function writeLine(socket, obj) { + const line = `${JSON.stringify(obj)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) { + // Cannot happen for beta's tiny fixed responses; guarded anyway so a + // future response shape cannot silently violate the ceiling + // on what THIS daemon sends, not just what it accepts. + socket.destroy(); + return; + } + socket.write(line); +} + +// One connection's framing state machine: accumulate bytes, cut at each +// newline, enforce the ceiling on the UNTERMINATED prefix so an attacker +// (or a bug) cannot hold the connection open forever with a +// never-terminated multi-megabyte line. +// +// `onLine` is async (it awaits the caller's stop/status handler). Lines +// extracted from a single 'data' chunk are queued and processed ONE AT A +// TIME, each fully awaited before the next starts -- a socket is a byte +// stream, not a message queue, and more than one complete line can arrive +// in a single chunk (e.g. a client that pipelines two requests without +// waiting for the first response). Calling onLine for each without +// awaiting would let a later line's synchronous prefix (like the "one +// request per connection" rejection and its socket.end()) run and close +// the connection WHILE an earlier line's response is still pending -- +// observed directly: a pipelined second request's rejection reached the +// client before the first request's real answer did, and `socket.end()` +// then discarded that still-pending answer entirely. +function makeLineReader(onLine, onOverflow) { + let buf = Buffer.alloc(0); + let processing = Promise.resolve(); + let destroyed = false; + function overflowOnce() { + if (destroyed) return; + destroyed = true; + onOverflow(); + } + return function onData(chunk) { + buf = Buffer.concat([buf, chunk]); + for (;;) { + const nl = buf.indexOf(0x0a); + if (nl === -1) { + if (buf.length > MAX_FRAME_BYTES) overflowOnce(); + return; + } + const lineBuf = buf.subarray(0, nl); + buf = buf.subarray(nl + 1); + if (lineBuf.length > MAX_FRAME_BYTES) { + overflowOnce(); + return; + } + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(lineBuf); + } catch { + overflowOnce(); + return; + } + processing = processing.then(() => (destroyed ? undefined : onLine(text))); + } + }; +} + +// `handlers.onStop()` / `handlers.onStatus()` are async and each return the +// plain object to send back (e.g. {ok: true} / the status payload) -- this +// file owns none of the actual stop/status behavior, only the socket. +export function createControlServer(socketPath, handlers) { + const server = createServer((socket) => { + let helloSeen = false; + // Set the moment this connection sends its one response and calls + // socket.end() (or is destroyed) -- every queued line after that point + // must be a silent no-op. Writing (even an error) after end() throws + // ERR_STREAM_WRITE_AFTER_END; discovered by a pipelined-second-request + // test where that throw discarded the FIRST (real, already-sent-for) + // response along with the write it broke on. + let closing = false; + function finish(responseObj) { + if (closing) return; + closing = true; + writeLine(socket, responseObj); + socket.end(); + } + + const reader = makeLineReader( + async (line) => { + if (closing) return; + let msg; + try { + msg = parseStrictJson(line); + } catch { + finish({ type: "error", reason: "invalid JSON framing" }); + return; + } + if (!helloSeen) { + if (msg?.type !== "hello" || msg.protocol !== PROTOCOL_VERSION) { + finish({ type: "error", reason: "protocol mismatch" }); + return; + } + if (msg.role !== "control") { + finish({ type: "error", reason: "role must be control" }); + return; + } + helloSeen = true; + writeLine(socket, { type: "hello_ok" }); + return; + } + // Beta connections are one-shot -- exactly one request follows + // `hello`. `finish()` above guards against a SECOND queued line + // reaching this far at all (the `closing` check at the top of this + // function returns before this point once the first request has + // set `closing`), so there is no separate "already handled" branch + // to write here. + try { + if (msg?.type === "stop") { + finish({ type: "stop_ok", ...(await handlers.onStop()) }); + } else if (msg?.type === "status") { + finish({ type: "status", ...(await handlers.onStatus()) }); + } else { + finish({ type: "error", reason: `unknown request type: ${msg?.type}` }); + } + } catch (error) { + finish({ type: "error", reason: error.message }); + } + }, + () => { + closing = true; + socket.destroy(); // frame too large -- cut the connection + }, + ); + socket.on("data", reader); + socket.on("error", () => { + // A client that drops mid-frame is not this server's problem to + // report anywhere -- there is no notice/seat state to reconcile in + // beta's one-shot model. + }); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => { + server.removeListener("error", reject); + try { + chmodSync(socketPath, 0o600); + } catch (error) { + server.close(); + reject(error); + return; + } + server.on("error", () => { + // Errors on already-accepted connections are handled per-socket + // above; this catches only listener-level errors after startup, + // which -- once listening -- have nowhere useful to propagate to + // in a bare fire-and-forget server object. Logged by the caller's + // own log.mjs if it chooses to listen for this event separately. + }); + resolve({ + server, + close: () => + new Promise((resolveClose) => { + server.close(() => { + try { + unlinkSync(socketPath); + } catch { + // best-effort + } + resolveClose(); + }); + }), + }); + }); + }); +} diff --git a/scripts/daemon/db.mjs b/scripts/daemon/db.mjs new file mode 100644 index 000000000..1225f4675 --- /dev/null +++ b/scripts/daemon/db.mjs @@ -0,0 +1,48 @@ +// Opens run/install.db with node:sqlite and applies scripts/daemon/schema.sql +// (idempotent -- see that file). One place so every Node component that +// touches install.db agrees on how it is opened and migrated. + +import { DatabaseSync } from "node:sqlite"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const SCHEMA_PATH = join(dirname(fileURLToPath(import.meta.url)), "schema.sql"); + +// `readonly: true` opens the DB without applying the schema and without the +// ability to write -- for a reader (status.mjs) that must never create the +// tables a stopped/never-started daemon would otherwise leave missing, and +// must never be the thing that turns "not installed yet" into "installed, +// empty" just by looking. +export function openInstallDb(path, { readonly = false } = {}) { + const db = new DatabaseSync(path, { readOnly: readonly, allowExtension: false }); + db.exec("PRAGMA busy_timeout = 5000;"); + if (!readonly) { + db.exec(readFileSync(SCHEMA_PATH, "utf8")); + } + return db; +} + +// Runs `fn(db)` inside a single BEGIN IMMEDIATE transaction, committing on +// return and rolling back on throw. BEGIN IMMEDIATE (not the default +// deferred BEGIN) takes the write lock up front. A deferred transaction +// that starts with a read and +// upgrades to a write partway through can hit SQLITE_BUSY at the upgrade +// point instead of at the start, which is a worse failure to reason about +// under contention. +export function withImmediateTransaction(db, fn) { + db.exec("BEGIN IMMEDIATE;"); + try { + const result = fn(db); + db.exec("COMMIT;"); + return result; + } catch (error) { + try { + db.exec("ROLLBACK;"); + } catch { + // The connection may already be unusable (e.g. the process is about + // to exit); the original error is what matters to the caller. + } + throw error; + } +} diff --git a/scripts/daemon/executor.mjs b/scripts/daemon/executor.mjs new file mode 100644 index 000000000..6c1810a07 --- /dev/null +++ b/scripts/daemon/executor.mjs @@ -0,0 +1,92 @@ +// The (pid, start-time evidence, boot id) triple that stands in for "this +// process, and not some later process that reused its pid." Shared +// between owner.mjs (which records the triple when it takes ownership) and +// status.mjs (which reads it back to answer "living / confirmed dead / +// cannot tell"). +// +// This small, cross-cutting helper is shared by both files, just as +// install-baseline.mjs is shared by the startup verifier and daemon. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { platform, uptime } from "node:os"; + +// A boot-scoped id: the same value for every process on this machine +// started since the current boot, and a DIFFERENT value after a reboot -- +// the property needed to tell "this executor" from "a later process +// that reused the same pid" across a restart. It is a boot EPOCH TIMESTAMP +// (seconds), not the UUID form some platforms also expose; either serves +// the same comparison purpose. +// +// linux: /proc/stat's `btime` line is the kernel's own authoritative boot +// epoch. darwin: `sysctl -n kern.boottime` reports the same thing in a +// different format. Neither drifts between calls. +// +// Anything else (win32 included): no simple authoritative file to read +// without shelling out to something heavier (wmic/PowerShell) for every +// check. Approximate instead, from Date.now() minus os.uptime(), rounded to +// the nearest second to absorb the two calls' own timing jitter. This can +// disagree by a handful of seconds between two calls in the SAME boot +// (uptime() does not always advance in lockstep with wall-clock sleep/wake +// accounting) -- callers must tolerate small drift on this path and must +// NOT expect exact equality the way linux/darwin's kernel-reported values +// give. +export function bootId() { + const plat = platform(); + if (plat === "linux") { + const stat = readFileSync("/proc/stat", "utf8"); + const line = stat.split("\n").find((l) => l.startsWith("btime ")); + if (line) { + const sec = line.slice("btime ".length).trim(); + if (/^\d+$/.test(sec)) return sec; + } + // /proc/stat without a btime line has not been observed; fall through + // to the approximate path rather than throw, since a boot id that is + // merely approximate is still useful (see caller contract below). + } else if (plat === "darwin") { + try { + const out = execFileSync("sysctl", ["-n", "kern.boottime"], { + encoding: "utf8", + }); + const m = out.match(/sec\s*=\s*(\d+)/); + if (m) return m[1]; + } catch { + // fall through to the approximate path + } + } + return String(Math.round(Date.now() / 1000 - uptime())); +} + +// This process's own executor triple, as of the call. `role` and `pid` are +// the caller's own; `bootId` is captured fresh (see above). +export function currentExecutor() { + return { pid: process.pid, bootId: bootId() }; +} + +// True/false/null for "living" / "confirmed dead" / "cannot tell", given a +// PREVIOUSLY captured executor triple `{pid, bootId}`. +// +// A bare `kill(pid, 0)` succeeding is NOT enough on its own -- pids recycle, +// and a live process at that number could be a stranger that started after +// the recorded one exited. The bootId compare is what this file exists for: +// if the boot has changed since the triple was captured, no pid from that +// boot can possibly still be running, so the answer is a confident `false` +// (confirmed dead) without even checking the pid -- this is the one case +// where a stale boot id is itself the proof, not a reason to say "cannot +// tell". +export function isAlive({ pid, bootId: capturedBootId }) { + if (bootId() !== capturedBootId) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + // EPERM: a process at that pid exists but this user cannot signal it -- + // that is itself proof it is alive (Node only reports it as a + // permission fact, but a permission-denied answer to `kill(pid, 0)` + // never happens for a pid that does not exist). Anything else is a + // genuine "cannot tell" (e.g. a platform where signal 0 is unsupported). + if (error?.code === "EPERM") return true; + return null; + } +} diff --git a/scripts/daemon/lifecycle.mjs b/scripts/daemon/lifecycle.mjs new file mode 100644 index 000000000..8cdd7e1be --- /dev/null +++ b/scripts/daemon/lifecycle.mjs @@ -0,0 +1,187 @@ +// Completion-record verification and update detection. +// +// The manifest itself (run/install-manifest.json, its .prev, and the +// install-op lock at run/install-op.lock.db) is PR 2's own format -- this +// file only reads it. Confirmed shape (2026-09-29): +// { install_id, gen, version, created_at, digest_algo: "sha256", +// files: [{ path, digest }, ...] } +// `path` is relative to installRoot (the SKILL_DIR), `files` sorted by path. + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { collectInstallBaseline, installChangedAgainst } from "../internal/install-baseline.mjs"; + +const MANIFEST_NAME = "install-manifest.json"; +const LOCK_NAME = "install-op.lock.db"; + +// True if some other process currently holds run/install-op.lock.db's +// BEGIN EXCLUSIVE (PR 2). Non-blocking: `busy_timeout = 0` makes a +// contended BEGIN EXCLUSIVE fail immediately instead of waiting, so this +// never stalls waiting for the lock it is only trying to observe. +function isInstallLockHeld(installRoot) { + const lockPath = join(installRoot, "run", LOCK_NAME); + if (!existsSync(lockPath)) return false; + const db = new DatabaseSync(lockPath); + try { + db.exec("PRAGMA busy_timeout = 0;"); + try { + db.exec("BEGIN EXCLUSIVE;"); + db.exec("ROLLBACK;"); + return false; + } catch { + return true; + } + } finally { + db.close(); + } +} + +// Reads the completion record and distinguishes three states: 'complete' +// (manifest present -- carries it), 'updating' +// (no manifest, .prev present, lock held), 'crashed' (no manifest, .prev +// present, lock free -- an update that died mid-way), 'missing' (neither +// file -- installed by an install.sh old enough to never have written one). +export function readCompletionState(installRoot) { + const manifestPath = join(installRoot, "run", MANIFEST_NAME); + const prevPath = `${manifestPath}.prev`; + if (existsSync(manifestPath)) { + return { + state: "complete", + manifest: JSON.parse(readFileSync(manifestPath, "utf8")), + manifestText: readFileSync(manifestPath, "utf8"), + }; + } + if (existsSync(prevPath)) { + return { state: isInstallLockHeld(installRoot) ? "updating" : "crashed" }; + } + return { state: "missing" }; +} + +// Enumerates every regular file under /scripts, sorted by +// path relative to installRoot (matching the manifest's own path form and +// sort order). A symlink ANYWHERE under scripts/ is a verification +// failure, not a skip. +function collectScriptFiles(installRoot) { + const files = []; + const root = join(installRoot, "scripts"); + function walk(dir, relPrefix) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) { + throw new Error(`symlink under scripts/: ${rel}`); + } else if (entry.isDirectory()) { + walk(abs, rel); + } else if (entry.isFile()) { + files.push(rel); + } + } + } + walk(root, "scripts"); + files.sort(); + return files; +} + +function sha256File(installRoot, relPath) { + const data = readFileSync(join(installRoot, relPath)); + return createHash("sha256").update(data).digest("hex"); +} + +// install.db's meta.install_id is the source of truth (install.sh, PR 2); +// the manifest carries only a copy. A mismatch means the manifest belongs +// to a different install than the one this install.db actually is -- +// verified separately from the digest check below, and checked first, +// since a digest match against the wrong install's manifest proves nothing. +export function verifyInstallId(db, manifest) { + const row = db.prepare("SELECT install_id FROM meta").get(); + if (row.install_id !== manifest.install_id) { + return { + ok: false, + reason: `install_id mismatch: install.db has ${row.install_id ?? "(none)"}, manifest has ${manifest.install_id}`, + }; + } + return { ok: true }; +} + +// Full startup verification: every file the manifest lists +// must exist with a matching digest, no extra files, no missing files, no +// symlinks. Returns {ok: true} or {ok: false, reason}. Never throws for an +// ordinary mismatch -- only collectScriptFiles's symlink case surfaces as +// a caught, reported mismatch rather than an uncaught exception. +export function verifyDigest(installRoot, manifest) { + if (manifest.digest_algo !== "sha256") { + return { ok: false, reason: `unsupported digest_algo: ${manifest.digest_algo}` }; + } + let onDisk; + try { + onDisk = collectScriptFiles(installRoot); + } catch (error) { + return { ok: false, reason: error.message }; + } + const expected = [...manifest.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const onDiskSet = new Set(onDisk); + const expectedPaths = expected.map((f) => f.path); + const expectedSet = new Set(expectedPaths); + + const missing = expectedPaths.filter((p) => !onDiskSet.has(p)); + if (missing.length > 0) return { ok: false, reason: `missing file(s): ${missing.join(", ")}` }; + + const extra = onDisk.filter((p) => !expectedSet.has(p)); + if (extra.length > 0) return { ok: false, reason: `unexpected file(s): ${extra.join(", ")}` }; + + for (const { path, digest } of expected) { + const actual = sha256File(installRoot, path); + if (actual !== digest) return { ok: false, reason: `digest mismatch: ${path}` }; + } + return { ok: true }; +} + +// Returns true iff the completion record is no longer 'complete' since +// `lastKnownManifestText` was captured (an install/uninstall started). +function completionRecordChanged(installRoot, lastKnownManifestText) { + const now = readCompletionState(installRoot); + return now.state !== "complete" || now.manifestText !== lastKnownManifestText; +} + +// Captured once, right after a successful startup verification, and handed +// to watchForDrift() every cycle thereafter. +// `scriptsBaseline` is null when collectInstallBaseline itself could not +// observe the tree (see that function's own doc) -- watchForDrift treats a +// null baseline the same way collectInstallBaseline's own caller always +// has: the digest half of the check is simply off, not a false positive. +export async function captureWatchState(installRoot, manifest, manifestText) { + return { + manifestText, + version: manifest.version, + scriptsBaseline: await collectInstallBaseline(join(installRoot, "scripts")), + }; +} + +// The running daemon's per-cycle re-check: stand +// aside if EITHER (a) the completion record stopped being 'complete' (an +// install/uninstall started), OR (b) the VERSION string or any file under +// scripts/ changed IN PLACE without the manifest moving at all -- the #963 +// case, detected via install-baseline.mjs against the baseline +// captureWatchState took at startup. Returns {changed: false} or +// {changed: true, reason}. +export async function watchForDrift(installRoot, watchState) { + if (completionRecordChanged(installRoot, watchState.manifestText)) { + return { changed: true, reason: "completion record is no longer complete" }; + } + let currentVersion; + try { + currentVersion = readFileSync(join(installRoot, "VERSION"), "utf8").trim(); + } catch { + return { changed: true, reason: "VERSION file could not be read" }; + } + if (currentVersion !== watchState.version) { + return { changed: true, reason: `VERSION changed: ${watchState.version} -> ${currentVersion}` }; + } + if (watchState.scriptsBaseline) { + const changedPath = await installChangedAgainst(join(installRoot, "scripts"), watchState.scriptsBaseline); + if (changedPath) return { changed: true, reason: `scripts/ changed in place: ${changedPath}` }; + } + return { changed: false }; +} diff --git a/scripts/daemon/log.mjs b/scripts/daemon/log.mjs new file mode 100644 index 000000000..32843e321 --- /dev/null +++ b/scripts/daemon/log.mjs @@ -0,0 +1,43 @@ +// run/agmsgd.log, rotated at 1 MiB, one prior generation kept +// (run/agmsgd.log.1). Records startup/stop/step-aside reasons and +// channel counts/failures ONLY -- never message bodies. + +import { appendFileSync, existsSync, renameSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const MAX_BYTES = 1024 * 1024; + +export function logPath(installRoot) { + return join(installRoot, "run", "agmsgd.log"); +} + +// Appends one line (a trailing newline is added), rotating first if the +// file has already reached MAX_BYTES. Best-effort: a logging failure must +// never be why the daemon itself fails an operation, so this never throws +// -- it falls back to stderr, once, rather than silently dropping the line. +export function logLine(installRoot, text) { + const path = logPath(installRoot); + try { + let size = 0; + try { + size = statSync(path).size; + } catch { + size = 0; // file does not exist yet: nothing to rotate + } + if (size >= MAX_BYTES) { + try { + renameSync(path, `${path}.1`); + } catch { + // best-effort: if the rename fails, keep appending to the same + // file rather than lose the line + } + } + appendFileSync(path, `${new Date().toISOString()} ${text}\n`); + } catch (error) { + process.stderr.write(`agmsgd: could not write to ${path}: ${error.message}\n`); + } +} + +export function logExists(installRoot) { + return existsSync(logPath(installRoot)); +} diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs new file mode 100644 index 000000000..0cf626002 --- /dev/null +++ b/scripts/daemon/main.mjs @@ -0,0 +1,144 @@ +// Starts, runs, and stops one agmsgd process. Ties +// owner.mjs / control.mjs / lifecycle.mjs / log.mjs / status.mjs together; +// none of the actual CAS/socket/verification logic lives here. +// +// Split into separately-callable pieces on purpose: shutdown is handled +// in one place and in a known order. +// startup() / pollOnce() / gracefulStop() are each independently testable +// without wiring real signal handlers or a real interval timer, which +// main() (the actual CLI entrypoint) only assembles. +// +// channelHooks (an array of {stop()}) is beta's plug point for the Codex +// queue channel -- a separate PR/component this file does not own. Empty +// in this PR; gracefulStop() already calls stop() on every registered +// hook, in the correct place in the shutdown order, so that PR only needs +// to register its hook, not restructure this file. + +import { takeOwnership, markReady, markStopping, revertToNone, stopNormally } from "./owner.mjs"; +import { createControlServer } from "./control.mjs"; +import { captureWatchState, watchForDrift } from "./lifecycle.mjs"; +import { logLine } from "./log.mjs"; +import { classify } from "./status.mjs"; + +export const POLL_INTERVAL_MS = 5000; + +// Takes ownership and binds the control socket. Returns +// {ok: true, gen, controlHandle} or {ok: false, reason} -- a refusal from +// takeOwnership OR a bind failure, both reported the same shape so the +// caller (main()) can log+exit either without a separate branch. A bind +// failure additionally reverts daemon_owner back to 'none' (the +// step-2 failure path) before returning. +export async function startup(db, { installRoot, expectedDesired, expectedOpGen, version, handlers }) { + const owned = takeOwnership(db, { installRoot, expectedDesired, expectedOpGen, version }); + if (!owned.ok) return owned; + + let controlHandle; + try { + controlHandle = await createControlServer(owned.socket, handlers); + } catch (error) { + revertToNone(db, owned.gen, "bind_failed"); + logLine(installRoot, `startup: bind failed for gen ${owned.gen}: ${error.message}`); + return { ok: false, reason: `bind failed: ${error.message}` }; + } + markReady(db, owned.gen); + logLine(installRoot, `startup: gen ${owned.gen} ready at ${owned.socket}`); + return { ok: true, gen: owned.gen, controlHandle }; +} + +// One poll cycle. Returns +// {action: "continue"} | {action: "step_aside", reason} | +// {action: "stop", reason} -- callers act on the verdict; this function +// itself performs no shutdown steps. +export async function pollOnce(db, installRoot, { gen, expectedOpGen, watchState }) { + const drift = await watchForDrift(installRoot, watchState); + if (drift.changed) { + return { action: "step_aside", reason: "stepped_aside_for_update" }; + } + const intent = db.prepare("SELECT op_gen FROM daemon_intent").get(); + if (intent.op_gen !== expectedOpGen) { + // An explicit operation happened since + // this process started; step aside regardless of what desired says + // now, so an old process from an off->on->off sequence never keeps + // running just because desired flipped back to on again later. + return { action: "stop", reason: "normal" }; + } + return { action: "continue" }; +} + +// The one place shutdown happens: mark stopping -> stop +// every registered channel -> close the control socket -> record the +// normal stop. `reason` becomes daemon_owner.last_end_reason (status.mjs +// pattern-matches "stepped_aside_for_update" specifically; anything else +// falls through to its generic "stopped" text). +export async function gracefulStop(db, installRoot, gen, controlHandle, channelHooks, reason) { + markStopping(db, gen); + for (const hook of channelHooks) { + try { + await hook.stop(); + } catch (error) { + logLine(installRoot, `shutdown: a channel's stop() threw: ${error.message}`); + } + } + await controlHandle.close(); + stopNormally(db, gen, reason); + logLine(installRoot, `shutdown: gen ${gen} stopped (${reason})`); +} + +// The actual CLI entrypoint: wires startup(), a real interval timer for +// pollOnce(), real SIGTERM handling, and control.mjs's stop/status +// handlers, onto db/manifest values the caller (agmsgd's bootstrap) has +// already verified. Exit codes: 0 for a declined +// start or a normal/SIGTERM stop, 75 for stepping aside for an update, 1 +// for a bind failure or any other unexpected error. +export async function main(db, { installRoot, manifest, manifestText, expectedDesired, expectedOpGen, version }) { + const channelHooks = []; // beta's Codex-queue channel plugs in here, separately. + let controlHandle; + let gen; + let stopping = false; + + async function doStop(reason) { + if (stopping) return; + stopping = true; + clearInterval(timer); + await gracefulStop(db, installRoot, gen, controlHandle, channelHooks, reason); + process.exit(reason === "stepped_aside_for_update" ? 75 : 0); + } + + const started = await startup(db, { + installRoot, + expectedDesired, + expectedOpGen, + version, + handlers: { + onStop: async () => { + // Fires from inside a control-socket request; the response itself + // must still go out before the process exits, so this only + // schedules the stop rather than awaiting it inline. + setImmediate(() => doStop("normal")); + return { gen }; + }, + onStatus: async () => classify({ owner: { gen, state: "ready", version }, intent: {}, alive: true, reachable: true }), + }, + }); + if (!started.ok) { + logLine(installRoot, `startup declined: ${started.reason}`); + db.prepare("INSERT INTO daemon_start_attempts (at, reason, executor_pid) VALUES (?, ?, ?)").run( + new Date().toISOString(), + started.reason, + process.pid, + ); + process.exit(started.reason.startsWith("bind failed") ? 1 : 0); + return; + } + gen = started.gen; + controlHandle = started.controlHandle; + + process.on("SIGTERM", () => doStop("normal")); + + const watchState = await captureWatchState(installRoot, manifest, manifestText); + const timer = setInterval(async () => { + if (stopping) return; + const verdict = await pollOnce(db, installRoot, { gen, expectedOpGen, watchState }); + if (verdict.action !== "continue") await doStop(verdict.reason); + }, POLL_INTERVAL_MS); +} diff --git a/scripts/daemon/owner.mjs b/scripts/daemon/owner.mjs new file mode 100644 index 000000000..48d4e0235 --- /dev/null +++ b/scripts/daemon/owner.mjs @@ -0,0 +1,130 @@ +// daemon_owner compare-and-swap lifecycle. +// +// daemon_intent itself is written only by scripts/daemon.sh (the explicit +// CLI operations, directly via sqlite3 -- bash cannot call into this file), +// never from here. This file only READS daemon_intent, to condition taking +// ownership on it still matching what the launcher observed. +// +// The install-op lock (run/install-op.lock.db, PR 2) is a SEPARATE +// mechanism the entrypoint holds only while loading code; by the time +// takeOwnership() runs, that lock has already been +// released. daemon_owner's own gen-conditioned CAS is self-contained and +// needs no external lock. + +import { join } from "node:path"; +import { currentExecutor, isAlive } from "./executor.mjs"; +import { withImmediateTransaction } from "./db.mjs"; + +export function readOwner(db) { + return db.prepare("SELECT * FROM daemon_owner").get(); +} + +export function readIntent(db) { + return db.prepare("SELECT * FROM daemon_intent").get(); +} + +function socketPath(installRoot, gen) { + return join(installRoot, "run", `agmsgd.${gen}.sock`); +} + +// Read daemon_owner, and if the current owner is +// state='none' or confirmed dead, take the next gen and move to +// 'starting' -- ALSO requiring, in the same read, that daemon_intent still +// matches what the caller (the launcher) already observed. Returns +// `{ok: true, gen, socket}` for the caller to go bind, or +// `{ok: false, reason}` -- the caller (agmsgd's entrypoint) is responsible +// for writing daemon_start_attempts and exiting 0 on a refusal; this +// function only decides, it does not log. +// +// `version` is this process's own build/version string, recorded in +// daemon_owner.version once ownership is taken. +export function takeOwnership(db, { installRoot, expectedDesired, expectedOpGen, version }) { + return withImmediateTransaction(db, () => { + const intent = readIntent(db); + if (intent.desired !== expectedDesired || intent.op_gen !== expectedOpGen) { + return { ok: false, reason: "intent changed since the launcher read it" }; + } + + const owner = readOwner(db); + let refuse = null; + if (owner.state !== "none") { + if (owner.executor_pid == null) { + refuse = "owner state is not none but has no recorded executor"; + } else { + const alive = isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); + if (alive === true) refuse = "current owner is alive"; + else if (alive === null) refuse = "current owner's liveness cannot be determined"; + // alive === false (confirmed dead): fall through, this call takes over. + } + } + if (refuse) return { ok: false, reason: refuse }; + + const gen = owner.gen + 1; + const executor = currentExecutor(); + const socket = socketPath(installRoot, gen); + const startedAt = new Date().toISOString(); + db.prepare( + `UPDATE daemon_owner SET gen = ?, state = 'starting', + executor_pid = ?, executor_started_at = ?, executor_boot_id = ?, + socket = ?, version = ?, started_at = ?, + last_end_reason = NULL, last_end_at = NULL, last_end_gen = NULL + WHERE gen = ?`, + ).run(gen, executor.pid, startedAt, executor.bootId, socket, version, startedAt, owner.gen); + return { ok: true, gen, socket }; + }); +} + +// Step 3: bind succeeded -- move 'starting' -> 'ready', conditioned on gen +// A no-op-safe false return (rather than throwing) if some +// other actor already moved this gen off 'starting' -- that should not +// happen in beta (single-threaded event loop, nothing else CASes this gen), +// but the condition costs nothing and documents the invariant. +export function markReady(db, gen) { + const result = db + .prepare("UPDATE daemon_owner SET state = 'ready' WHERE gen = ? AND state = 'starting'") + .run(gen); + return result.changes > 0; +} + +// Bind failed: move back to 'none', +// conditioned on gen, and record why via last_end (this IS this gen's own +// CAS back to none, so last_end is the right place to record completion). +export function revertToNone(db, gen, reason) { + const at = new Date().toISOString(); + const result = db + .prepare( + `UPDATE daemon_owner SET state = 'none', socket = NULL, + last_end_reason = ?, last_end_at = ?, last_end_gen = ? + WHERE gen = ? AND state = 'starting'`, + ) + .run(reason, at, gen, gen); + return result.changes > 0; +} + +// Normal stop: this gen's own +// CAS from any running state to 'none', with last_end. Callers (main.mjs) +// must have already stopped accepting control requests, closed the +// listening socket, and confirmed their own child process group is empty +// BEFORE calling this -- this function only performs the final state +// transition, it does not do any of that stopping itself. +export function stopNormally(db, gen, reason = "normal") { + const at = new Date().toISOString(); + const result = db + .prepare( + `UPDATE daemon_owner SET state = 'none', socket = NULL, + last_end_reason = ?, last_end_at = ?, last_end_gen = ? + WHERE gen = ? AND state != 'none'`, + ) + .run(reason, at, gen, gen); + return result.changes > 0; +} + +// Called at the top of main.mjs's transition into 'stopping', before doing +// the actual shutdown work -- lets a reader (status.mjs) see the daemon is +// mid-shutdown rather than still 'ready'. +export function markStopping(db, gen) { + const result = db + .prepare("UPDATE daemon_owner SET state = 'stopping' WHERE gen = ? AND state = 'ready'") + .run(gen); + return result.changes > 0; +} diff --git a/scripts/daemon/schema.sql b/scripts/daemon/schema.sql new file mode 100644 index 000000000..d93c837c4 --- /dev/null +++ b/scripts/daemon/schema.sql @@ -0,0 +1,79 @@ +-- agmsgd beta schema for run/install.db. +-- +-- Applied identically from both bash (scripts/daemon.sh, via `sqlite3 < +-- this file`) and Node (owner.mjs, via node:sqlite exec()) -- one file so the +-- two sides can never drift into different table shapes for the same DB. +-- Every statement is idempotent: safe to re-run on every invocation, by +-- either side, in any order. +-- +-- These are the only four of the six beta tables this component owns. +-- beta_codex_queue and beta_codex_seat belong to the separate Codex channel +-- component and are created there, not here. +-- +-- meta: one row. install_id is the SOURCE OF TRUTH for this install's id +-- (written by install.sh, PR 2); run/install-manifest.json carries only a +-- COPY of it, and the entrypoint's startup verification confirms the two +-- still agree. schema_version is written by +-- install/agmsgd; node_path and node_version are written ONLY by the +-- enable/disable CLI (never by agmsgd itself). +CREATE TABLE IF NOT EXISTS meta ( + schema_version INTEGER NOT NULL, + install_id TEXT, + node_path TEXT, + node_version TEXT +); +INSERT INTO meta (schema_version, install_id, node_path, node_version) + SELECT 1, NULL, NULL, NULL WHERE NOT EXISTS (SELECT 1 FROM meta); + +-- daemon_owner: always exactly one row (never deleted, never a second row). +-- gen increases monotonically and is never reused. executor_* is the +-- (pid, start-time evidence, boot id) triple used to tell a live executor +-- from a reused pid. last_end is written ONLY by the owning gen's own CAS +-- when it transitions itself to state='none' (bind failure, stepped aside +-- for an update, or a normal stop) -- never by a start attempt that was +-- refused, and never by any reader (status/doctor). +CREATE TABLE IF NOT EXISTS daemon_owner ( + gen INTEGER NOT NULL, + state TEXT NOT NULL, + executor_pid INTEGER, + executor_started_at TEXT, + executor_boot_id TEXT, + socket TEXT, + version TEXT, + started_at TEXT, + last_end_reason TEXT, + last_end_at TEXT, + last_end_gen INTEGER, + node_path TEXT, + node_version TEXT +); +INSERT INTO daemon_owner (gen, state) + SELECT 0, 'none' WHERE NOT EXISTS (SELECT 1 FROM daemon_owner); + +-- daemon_intent: always exactly one row. desired starts NULL ("intent +-- unconfirmed", a row that was never written) and is +-- changed ONLY by an explicit operation (start/stop/enable/disable/ +-- uninstall) to the literal 'on' or 'off' -- never by a SIGTERM or an +-- OS/resident-manager restart, and never defaulted to 'off' at schema +-- creation: that would make a never-touched install indistinguishable +-- from one somebody deliberately disabled (status.mjs's classify() tells +-- them apart, but only if this row does not pre-decide it). op_gen +-- increases by 1 on every explicit operation (inside the operation lock +-- for start/enable/disable/uninstall; without the lock for stop). +CREATE TABLE IF NOT EXISTS daemon_intent ( + desired TEXT, + set_by TEXT, + set_at TEXT, + op_gen INTEGER NOT NULL +); +INSERT INTO daemon_intent (desired, set_by, set_at, op_gen) + SELECT NULL, NULL, NULL, 0 WHERE NOT EXISTS (SELECT 1 FROM daemon_intent); + +-- daemon_start_attempts: append-only history for display only (never a +-- safety-critical read). Written by the launcher and by a start attempt that +-- was refused ownership -- never touches daemon_owner. +CREATE TABLE IF NOT EXISTS daemon_start_attempts ( + at TEXT NOT NULL, + reason TEXT NOT NULL, + executor_pid INTEGER +); diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs new file mode 100644 index 000000000..a11079ffa --- /dev/null +++ b/scripts/daemon/status.mjs @@ -0,0 +1,194 @@ +// The daemon status decision table is scoped to beta's install.db +// (meta / daemon_owner / daemon_intent / daemon_start_attempts only). +// seat_route, sync_status, stuck, and ctrl_state belong to a later release +// and are reported as unsupported wherever a caller lists all categories, +// not as "0 rows". +// +// Callable two ways: +// - as a library: classify({owner, intent, alive}) -- the pure decision, +// used by control.mjs's onStatus handler (running INSIDE the daemon +// that already knows its own state) and by tests. +// - as a CLI (`node status.mjs `): reads install.db +// read-only, checks the recorded executor's liveness itself (works +// whether or not the daemon is actually running), and additionally +// tries the control socket if the record says 'ready' -- a record +// that says ready but a socket that refuses the connection is the +// "ready, but does not connect" case, which only an +// external prober (not the daemon answering about itself) can ever +// observe. Prints one JSON line to stdout; exit code follows the +// table's own exit column. + +import { existsSync, realpathSync } from "node:fs"; +import { createConnection } from "node:net"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isAlive } from "./executor.mjs"; +import { openInstallDb } from "./db.mjs"; +import { PROTOCOL_VERSION } from "./control.mjs"; + +const STARTING_STOPPING_GRACE_MS = 30_000; + +// The pure decision. `now` is injectable for tests. +export function classify({ owner, intent, alive, reachable }, now = Date.now()) { + const desired = intent?.desired ?? null; + + if (owner.state === "ready") { + if (reachable) { + return { + text: `agmsgd is running (gen ${owner.gen}, version ${owner.version ?? "unknown"})`, + exitCode: 0, + gen: owner.gen, + }; + } + return { + text: "agmsgd's record says ready, but it does not answer on its control socket", + exitCode: 1, + gen: owner.gen, + }; + } + + if (owner.state === "starting" || owner.state === "stopping") { + const startedAt = owner.started_at ? Date.parse(owner.started_at) : NaN; + const withinGrace = Number.isFinite(startedAt) && now - startedAt < STARTING_STOPPING_GRACE_MS; + const ok = alive === true && withinGrace; + return { + text: `agmsgd is ${owner.state} (executor ${alive === true ? "alive" : alive === false ? "confirmed dead" : "cannot be determined"}, started ${owner.started_at ?? "unknown"})`, + exitCode: ok ? 0 : 1, + gen: owner.gen, + }; + } + + // owner.state === "none" from here on. + if (desired === "off") { + return { + text: `agmsgd is not in use (turned off deliberately${intent.set_at ? ` at ${intent.set_at}` : ""})`, + exitCode: 0, + gen: owner.gen, + }; + } + if (owner.gen === 0) { + return { text: "agmsgd has never been started", exitCode: 0, gen: 0 }; + } + if (owner.last_end_reason === "bind_failed") { + return { + text: `agmsgd failed to start (${owner.last_end_reason}, ${owner.last_end_at ?? "unknown time"}, gen ${owner.last_end_gen})`, + exitCode: 1, + gen: owner.gen, + }; + } + if (owner.last_end_reason === "stepped_aside_for_update") { + return { + text: `agmsgd stopped for an update (${owner.last_end_at ?? "unknown time"}). The new version has not started yet.`, + exitCode: 1, + gen: owner.gen, + }; + } + if (desired === "on") { + return { text: "agmsgd is stopped (intent is on)", exitCode: 1, gen: owner.gen }; + } + return { + text: "agmsgd is stopped. No intent to use it is recorded.", + exitCode: 1, + gen: owner.gen, + }; +} + +// Reads install.db read-only and checks the recorded executor's liveness -- +// works whether or not agmsgd is actually running. +export function readOwnerAndIntentReadOnly(installRoot) { + const db = openInstallDb(join(installRoot, "run", "install.db"), { readonly: true }); + try { + const owner = db.prepare("SELECT * FROM daemon_owner").get(); + const intent = db.prepare("SELECT * FROM daemon_intent").get(); + const alive = + owner.state === "none" || owner.executor_pid == null + ? null + : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); + return { owner, intent, alive }; + } finally { + db.close(); + } +} + +// Attempts one hello+status round trip against the recorded socket, with a +// short deadline -- a prober must never hang the CLI command it backs. +function probeSocket(socketPath, timeoutMs = 2000) { + return new Promise((resolve) => { + const socket = createConnection(socketPath); + let buf = ""; + const done = (result) => { + socket.destroy(); + resolve(result); + }; + const timer = setTimeout(() => done(false), timeoutMs); + socket.on("connect", () => { + socket.write(`${JSON.stringify({ type: "hello", protocol: PROTOCOL_VERSION, role: "control" })}\n`); + socket.write(`${JSON.stringify({ type: "status" })}\n`); + }); + socket.on("data", (chunk) => { + buf += chunk.toString("utf8"); + if (buf.includes('"type":"status"')) { + clearTimeout(timer); + done(true); + } + }); + socket.on("error", () => { + clearTimeout(timer); + done(false); + }); + }); +} + +async function main() { + const installRoot = process.argv[2]; + if (!installRoot) { + process.stderr.write("usage: status.mjs \n"); + process.exit(2); + } + let owner, intent, alive; + try { + ({ owner, intent, alive } = readOwnerAndIntentReadOnly(installRoot)); + } catch (error) { + // An input that can be detected as an error is reported at + // that entry point, with a nonzero exit -- not a raw stack trace, and + // not folded into "never started" (which is itself a legitimate 0). + process.stderr.write(`agmsgd status: could not read install.db: ${error.message}\n`); + process.exit(1); + } + const reachable = owner.state === "ready" && owner.socket ? await probeSocket(owner.socket) : false; + const result = classify({ owner, intent, alive, reachable }); + + // The node:sqlite experimental-feature warning is surfaced here, always + // -- not hidden, not treated as a failure. + // + // `process.exitCode = ...` and returning, NOT `process.exit(...)`: when + // stdout is a pipe rather than a TTY (exactly what capturing this + // command's output does -- a test runner, `agmsg daemon status | + // ...`), the write above is buffered, and `process.exit()` tears the + // process down before that buffer flushes -- observed directly: this + // printed nothing and still exited 0 under bats, where stdout is + // captured through a pipe, while running the identical command directly + // in an interactive terminal (a TTY, where the same write is + // unbuffered) looked completely fine. Setting exitCode and letting the + // event loop drain naturally waits for the flush first. + process.stdout.write( + `${JSON.stringify({ ...result, node_sqlite_experimental: true, node_version: process.version })}\n`, + ); + process.exitCode = result.exitCode; +} + +// `fileURLToPath(import.meta.url)` is realpath'd by Node's own module +// loader (symlinks resolved); `process.argv[1]` is whatever the caller +// passed literally and is NOT realpath'd by Node. On any system where the +// temp/working directory itself sits behind a symlink -- macOS's +// /var -> /private/var is the common case -- a bare `===` between the two +// never matches, so this "am I the CLI entry" guard silently fails and +// main() never runs: the process still exits 0 (nothing here throws), but +// prints nothing beyond node:sqlite's own top-level-import warning. +// Observed exactly this way (bats invokes this file through a path under +// /var/folders while import.meta.url resolves through /private/var). +// realpathSync both sides so the comparison is meaningful regardless of +// which one the caller happened to pass. +if (existsSync(process.argv[1] ?? "") && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/internal/install-baseline.mjs b/scripts/internal/install-baseline.mjs new file mode 100644 index 000000000..ae7012ec8 --- /dev/null +++ b/scripts/internal/install-baseline.mjs @@ -0,0 +1,104 @@ +import { createHash } from "node:crypto"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; + +// The installation can be updated while an engine runs (#963). watch.sh +// already detects that and stands down on its own; the engine used to find +// out only by executing a half-written driver script -- and only when its +// timing was unlucky enough to hit the write window (observed live: a +// mid-rewrite storage-sync-driver.sh failed to parse, and the fatal named a +// syntax error instead of the update). +// +// What is proof here went through review twice, and both cuts were the same +// disease: an observable standing in for the fact. "mtime newer than the +// engine's start clock" stands down every engine under a pre-existing +// future mtime (clock skew, an archive with preserved timestamps). +// "mtime changed against a baseline" stands down on a touch, a +// metadata-only correction, a same-content re-copy -- the code in memory +// and on disk still identical, and a stood-down sync engine does not come +// back by itself. The fact that matters is CONTENT: the engine must stand +// down exactly when the bytes on disk are no longer the bytes it started +// from. So the baseline holds a content digest per file; a path or mtime +// difference merely nominates a file for re-reading, and only a digest +// that actually differs -- or a path that did not exist at start, a new +// install artifact -- is proof. A benign mtime change is remembered so the +// file is not re-read every cycle; content is the identity, the mtime is +// only a cheap change hint. +// +// The failure direction is deliberate, in both phases: an OBSERVATION +// failure is not evidence. +// - Baseline phase: one unreadable directory, one failed stat, one +// unreadable file and the whole detector is disabled (null), not +// partially armed. A partial baseline would recreate the false +// positive: a pre-existing file unreadable at start and readable later +// would look newly added. Disabled means exactly today's behavior. +// - Check phase: an entry that cannot be listed, stat'ed, or read is +// skipped and proves nothing. A rewrite that lands different bytes +// under the exact baseline mtime defeats the hint and is never +// re-read -- a miss, and a miss degrades to today's behavior, which +// is the safe side. A file DELETED by an update is deliberately not +// proof on its own; a real update always rewrites something. +// +// Extracted from remote-sync.mjs: this file is +// the one place the #963 detector lives now. remote-sync.mjs re-exports +// these two names unchanged, so `runLoop`'s own behavior there is +// untouched, and agmsgd's lifecycle.mjs imports the same two functions +// directly for its own per-cycle drift check. +export async function collectInstallBaseline(rootDir, dependencies = {}) { + const readdirCall = dependencies.readdirCall ?? readdir; + const statCall = dependencies.statCall ?? stat; + const readFileCall = dependencies.readFileCall ?? readFile; + const baseline = new Map(); + const pending = [rootDir]; + while (pending.length > 0) { + const dir = pending.pop(); + let entries; + try { entries = await readdirCall(dir, { withFileTypes: true }); } + catch { return null; } // incomplete observation: the detector stays OFF + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { pending.push(path); continue; } + if (!entry.isFile()) continue; // links and specials are not install artifacts + let stats, bytes; + try { + stats = await statCall(path); + bytes = await readFileCall(path); + } catch { return null; } // incomplete observation: the detector stays OFF + baseline.set(path, { + mtimeMs: stats.mtimeMs, + digest: createHash("sha256").update(bytes).digest("hex"), + }); + } + } + return baseline; +} + +export async function installChangedAgainst(rootDir, baseline, dependencies = {}) { + const readdirCall = dependencies.readdirCall ?? readdir; + const statCall = dependencies.statCall ?? stat; + const readFileCall = dependencies.readFileCall ?? readFile; + const pending = [rootDir]; + while (pending.length > 0) { + const dir = pending.pop(); + let entries; + try { entries = await readdirCall(dir, { withFileTypes: true }); } + catch { continue; } // unreadable now: no evidence either way + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { pending.push(path); continue; } + if (!entry.isFile()) continue; + let stats; + try { stats = await statCall(path); } + catch { continue; } // vanished or unreadable: no evidence + const known = baseline.get(path); + if (known === undefined) return path; // did not exist at start: a new install artifact + if (stats.mtimeMs === known.mtimeMs) continue; // no hint of change + let digest; + try { digest = createHash("sha256").update(await readFileCall(path)).digest("hex"); } + catch { continue; } // could not re-read: no evidence + if (digest !== known.digest) return path; // the bytes actually changed + known.mtimeMs = stats.mtimeMs; // benign touch: remember it, content is the identity + } + } + return null; +} diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index 24b04d6cd..eaf6d2e36 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -3427,100 +3427,16 @@ export async function cycle(config, { pushLimit, pullLimit }, dependencies = {}) // cadence: after any retryable failure the loop always backs off (exponential, // capped), so a machine that can't reach the server never hot-loops even while // catch-up would otherwise skip the wait. -// The installation can be updated while an engine runs (#963). watch.sh -// already detects that and stands down on its own; the engine used to find -// out only by executing a half-written driver script -- and only when its -// timing was unlucky enough to hit the write window (observed live: a -// mid-rewrite storage-sync-driver.sh failed to parse, and the fatal named a -// syntax error instead of the update). +// The #963 install-changed detector now lives in install-baseline.mjs +// re-exported here, unchanged, so this +// file's own runLoop below keeps working exactly as it did. // -// What is proof here went through review twice, and both cuts were the same -// disease: an observable standing in for the fact. "mtime newer than the -// engine's start clock" stands down every engine under a pre-existing -// future mtime (clock skew, an archive with preserved timestamps). -// "mtime changed against a baseline" stands down on a touch, a -// metadata-only correction, a same-content re-copy -- the code in memory -// and on disk still identical, and a stood-down sync engine does not come -// back by itself. The fact that matters is CONTENT: the engine must stand -// down exactly when the bytes on disk are no longer the bytes it started -// from. So the baseline holds a content digest per file; a path or mtime -// difference merely nominates a file for re-reading, and only a digest -// that actually differs -- or a path that did not exist at start, a new -// install artifact -- is proof. A benign mtime change is remembered so the -// file is not re-read every cycle; content is the identity, the mtime is -// only a cheap change hint. -// -// The failure direction is deliberate, in both phases: an OBSERVATION -// failure is not evidence. -// - Baseline phase: one unreadable directory, one failed stat, one -// unreadable file and the whole detector is disabled (null), not -// partially armed. A partial baseline would recreate the false -// positive: a pre-existing file unreadable at start and readable later -// would look newly added. Disabled means exactly today's behavior. -// - Check phase: an entry that cannot be listed, stat'ed, or read is -// skipped and proves nothing. A rewrite that lands different bytes -// under the exact baseline mtime defeats the hint and is never -// re-read -- a miss, and a miss degrades to today's behavior, which -// is the safe side. A file DELETED by an update is deliberately not -// proof on its own; a real update always rewrites something. -export async function collectInstallBaseline(rootDir, dependencies = {}) { - const readdirCall = dependencies.readdirCall ?? readdir; - const statCall = dependencies.statCall ?? stat; - const readFileCall = dependencies.readFileCall ?? readFile; - const baseline = new Map(); - const pending = [rootDir]; - while (pending.length > 0) { - const dir = pending.pop(); - let entries; - try { entries = await readdirCall(dir, { withFileTypes: true }); } - catch { return null; } // incomplete observation: the detector stays OFF - for (const entry of entries) { - const path = join(dir, entry.name); - if (entry.isDirectory()) { pending.push(path); continue; } - if (!entry.isFile()) continue; // links and specials are not install artifacts - let stats, bytes; - try { - stats = await statCall(path); - bytes = await readFileCall(path); - } catch { return null; } // incomplete observation: the detector stays OFF - baseline.set(path, { - mtimeMs: stats.mtimeMs, - digest: createHash("sha256").update(bytes).digest("hex"), - }); - } - } - return baseline; -} - -export async function installChangedAgainst(rootDir, baseline, dependencies = {}) { - const readdirCall = dependencies.readdirCall ?? readdir; - const statCall = dependencies.statCall ?? stat; - const readFileCall = dependencies.readFileCall ?? readFile; - const pending = [rootDir]; - while (pending.length > 0) { - const dir = pending.pop(); - let entries; - try { entries = await readdirCall(dir, { withFileTypes: true }); } - catch { continue; } // unreadable now: no evidence either way - for (const entry of entries) { - const path = join(dir, entry.name); - if (entry.isDirectory()) { pending.push(path); continue; } - if (!entry.isFile()) continue; - let stats; - try { stats = await statCall(path); } - catch { continue; } // vanished or unreadable: no evidence - const known = baseline.get(path); - if (known === undefined) return path; // did not exist at start: a new install artifact - if (stats.mtimeMs === known.mtimeMs) continue; // no hint of change - let digest; - try { digest = createHash("sha256").update(await readFileCall(path)).digest("hex"); } - catch { continue; } // could not re-read: no evidence - if (digest !== known.digest) return path; // the bytes actually changed - known.mtimeMs = stats.mtimeMs; // benign touch: remember it, content is the identity - } - } - return null; -} +// `export { X } from "mod"` alone does NOT bind X as a local name in THIS +// module (verified: it re-exports for importers of remote-sync.mjs but +// leaves a bare reference to X here as `undefined`) -- runLoop below calls +// these by their bare names, so the import is needed too. +import { collectInstallBaseline, installChangedAgainst } from "./install-baseline.mjs"; +export { collectInstallBaseline, installChangedAgainst }; export async function runLoop(config, options, dependencies = {}) { const cycleCall = dependencies.cycleCall ?? cycle; diff --git a/tests/agmsgd_control.test.mjs b/tests/agmsgd_control.test.mjs new file mode 100644 index 000000000..10c7db20c --- /dev/null +++ b/tests/agmsgd_control.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createControlServer, PROTOCOL_VERSION } from "../scripts/daemon/control.mjs"; + +function socketDir() { + return mkdtempSync(join(tmpdir(), "agmsgd-control-test-")); +} + +// Sends `lines` (already-terminated strings, or a raw prefix with no +// trailing newline for the overflow case) and collects every line the +// server sends back before the connection closes. +function talk(socketPath, rawBytesOrLines) { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath); + const received = []; + let buf = ""; + socket.on("connect", () => { + const payload = Array.isArray(rawBytesOrLines) ? rawBytesOrLines.join("") : rawBytesOrLines; + socket.write(payload); + }); + socket.on("data", (chunk) => { + buf += chunk.toString("utf8"); + }); + socket.on("close", () => { + for (const line of buf.split("\n")) { + if (line.length > 0) received.push(JSON.parse(line)); + } + resolve(received); + }); + socket.on("error", reject); + }); +} + +test("control server: hello -> status is a clean one-shot round trip", async () => { + const dir = socketDir(); + const socketPath = join(dir, "c.sock"); + try { + const handle = await createControlServer(socketPath, { + onStop: async () => ({ gen: 1 }), + onStatus: async () => ({ state: "ready", gen: 1 }), + }); + const lines = [ + `${JSON.stringify({ type: "hello", protocol: PROTOCOL_VERSION, role: "control" })}\n`, + `${JSON.stringify({ type: "status" })}\n`, + ]; + const received = await talk(socketPath, lines); + assert.deepEqual(received, [ + { type: "hello_ok" }, + { type: "status", state: "ready", gen: 1 }, + ]); + await handle.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("control server: hello -> stop round trip calls onStop and reports its result", async () => { + const dir = socketDir(); + const socketPath = join(dir, "c.sock"); + try { + let stopCalled = false; + const handle = await createControlServer(socketPath, { + onStop: async () => { + stopCalled = true; + return { gen: 3 }; + }, + onStatus: async () => ({}), + }); + const lines = [ + `${JSON.stringify({ type: "hello", protocol: PROTOCOL_VERSION, role: "control" })}\n`, + `${JSON.stringify({ type: "stop" })}\n`, + ]; + const received = await talk(socketPath, lines); + assert.equal(stopCalled, true); + assert.deepEqual(received, [{ type: "hello_ok" }, { type: "stop_ok", gen: 3 }]); + await handle.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("control server rejects a wrong protocol, a wrong role, a duplicate-key frame, and a second request on one connection", async () => { + const dir = socketDir(); + const socketPath = join(dir, "c.sock"); + try { + const handle = await createControlServer(socketPath, { + onStop: async () => ({}), + onStatus: async () => ({}), + }); + + let received = await talk(socketPath, [`${JSON.stringify({ type: "hello", protocol: 999, role: "control" })}\n`]); + assert.equal(received[0].type, "error"); + assert.match(received[0].reason, /protocol/); + + received = await talk(socketPath, [`${JSON.stringify({ type: "hello", protocol: PROTOCOL_VERSION, role: "session" })}\n`]); + assert.equal(received[0].type, "error"); + assert.match(received[0].reason, /role/); + + // A hand-built frame with a duplicate key -- parseStrictJson must + // reject this even though JSON.parse alone would accept it silently. + received = await talk(socketPath, ['{"type":"hello","protocol":1,"role":"control","protocol":1}\n']); + assert.equal(received[0].type, "error"); + + // A second request pipelined onto the same connection after the first + // has already been answered: the connection is already ending by + // then, so it gets silently dropped rather than a second response -- + // the point of the test is that this must NOT disturb or lose the + // FIRST (real) response, which it did before this was fixed. + const twoRequests = [ + `${JSON.stringify({ type: "hello", protocol: PROTOCOL_VERSION, role: "control" })}\n`, + `${JSON.stringify({ type: "status" })}\n`, + `${JSON.stringify({ type: "status" })}\n`, + ]; + received = await talk(socketPath, twoRequests); + assert.deepEqual(received, [{ type: "hello_ok" }, { type: "status" }]); + + await handle.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("control server cuts a connection that exceeds the 1 MiB frame ceiling before a newline arrives", async () => { + const dir = socketDir(); + const socketPath = join(dir, "c.sock"); + try { + const handle = await createControlServer(socketPath, { + onStop: async () => ({}), + onStatus: async () => ({}), + }); + const oversized = "x".repeat(1024 * 1024 + 10); // no trailing newline + const received = await talk(socketPath, [oversized]); + // The connection is destroyed outright -- no response line at all, + // just a close. + assert.deepEqual(received, []); + await handle.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_db.test.mjs b/tests/agmsgd_db.test.mjs new file mode 100644 index 000000000..3bee749ae --- /dev/null +++ b/tests/agmsgd_db.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { openInstallDb, withImmediateTransaction } from "../scripts/daemon/db.mjs"; + +test("openInstallDb applies the schema idempotently; readOnly never migrates or writes", () => { + const dir = mkdtempSync(join(tmpdir(), "agmsgd-db-test-")); + const path = join(dir, "install.db"); + try { + const db = openInstallDb(path); + for (const table of ["meta", "daemon_owner", "daemon_intent"]) { + const row = db.prepare(`SELECT count(*) AS n FROM ${table}`).get(); + assert.equal(row.n, 1, `${table} must have exactly one row after first open`); + } + assert.equal( + db.prepare("SELECT count(*) AS n FROM daemon_start_attempts").get().n, + 0, + "daemon_start_attempts is append-only history with no seed row", + ); + db.close(); + + // Re-opening (a second component, or a restart) must not add rows or + // fail on the already-existing schema. + const db2 = openInstallDb(path); + assert.equal(db2.prepare("SELECT count(*) AS n FROM daemon_owner").get().n, 1); + db2.close(); + + // A readOnly open of a DB that was never created must not create it + // (readOnly + a missing file is an error, not a silent bootstrap) and + // must refuse to write to one that exists. + const neverCreated = join(dir, "never.db"); + assert.throws(() => openInstallDb(neverCreated, { readonly: true })); + + const ro = openInstallDb(path, { readonly: true }); + assert.throws(() => ro.exec("INSERT INTO daemon_start_attempts (at, reason) VALUES ('x','y')")); + ro.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("withImmediateTransaction commits on success and rolls back on throw", () => { + const dir = mkdtempSync(join(tmpdir(), "agmsgd-db-test-")); + const path = join(dir, "install.db"); + try { + const db = openInstallDb(path); + withImmediateTransaction(db, () => { + db.exec("INSERT INTO daemon_start_attempts (at, reason) VALUES ('t1','ok')"); + }); + assert.equal(db.prepare("SELECT count(*) AS n FROM daemon_start_attempts").get().n, 1); + + assert.throws(() => + withImmediateTransaction(db, () => { + db.exec("INSERT INTO daemon_start_attempts (at, reason) VALUES ('t2','bad')"); + throw new Error("boom"); + }), + ); + assert.equal( + db.prepare("SELECT count(*) AS n FROM daemon_start_attempts").get().n, + 1, + "the row inserted before the throw must be rolled back", + ); + db.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_executor.test.mjs b/tests/agmsgd_executor.test.mjs new file mode 100644 index 000000000..faded4ab6 --- /dev/null +++ b/tests/agmsgd_executor.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { bootId, currentExecutor, isAlive } from "../scripts/daemon/executor.mjs"; + +test("bootId is stable across calls and isAlive tells living/dead/wrong-boot apart", () => { + assert.equal(bootId(), bootId(), "bootId must not change within the same boot"); + + const self = currentExecutor(); + assert.equal(isAlive(self), true, "this process's own pid must read as alive"); + + // A pid that has already exited, at the CURRENT boot id: must read as + // confirmed dead (ESRCH), not "cannot tell". + const dead = spawnSync(process.execPath, ["-e", "process.exit(0)"]); + assert.equal(dead.status, 0); + assert.equal( + isAlive({ pid: dead.pid, bootId: self.bootId }), + false, + "an exited pid at the current boot id must read as confirmed dead", + ); + + // A boot id that does not match the current one must short-circuit to + // confirmed dead even for this process's own (very much alive) pid -- + // no pid from a different boot can still be running. + assert.equal( + isAlive({ pid: self.pid, bootId: "0" }), + false, + "a mismatched boot id must read as confirmed dead regardless of the pid", + ); +}); diff --git a/tests/agmsgd_lifecycle.test.mjs b/tests/agmsgd_lifecycle.test.mjs new file mode 100644 index 000000000..5bee06a68 --- /dev/null +++ b/tests/agmsgd_lifecycle.test.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; +import { + captureWatchState, + readCompletionState, + verifyDigest, + verifyInstallId, + watchForDrift, +} from "../scripts/daemon/lifecycle.mjs"; +import { openInstallDb } from "../scripts/daemon/db.mjs"; + +function sha256(text) { + return createHash("sha256").update(text).digest("hex"); +} + +function makeInstall() { + const root = mkdtempSync(join(tmpdir(), "agmsgd-lifecycle-test-")); + mkdirSync(join(root, "run"), { recursive: true }); + mkdirSync(join(root, "scripts", "lib"), { recursive: true }); + writeFileSync(join(root, "scripts", "team.sh"), "team\n"); + writeFileSync(join(root, "scripts", "lib", "storage.sh"), "storage\n"); + const files = [ + { path: "scripts/team.sh", digest: sha256("team\n") }, + { path: "scripts/lib/storage.sh", digest: sha256("storage\n") }, + ]; + const manifest = { + install_id: "test-install", + gen: 1, + version: "0.0.0-test", + created_at: new Date().toISOString(), + digest_algo: "sha256", + files, + }; + writeFileSync(join(root, "run", "install-manifest.json"), JSON.stringify(manifest)); + writeFileSync(join(root, "VERSION"), `${manifest.version}\n`); + return { root, manifest }; +} + +test("readCompletionState: missing / complete / crashed / updating", () => { + const { root, manifest } = makeInstall(); + try { + const complete = readCompletionState(root); + assert.equal(complete.state, "complete"); + assert.deepEqual(complete.manifest, manifest); + + // Simulate "install started" (rename to .prev, no new record yet). + rmSync(join(root, "run", "install-manifest.json.prev"), { force: true }); + renameSync( + join(root, "run", "install-manifest.json"), + join(root, "run", "install-manifest.json.prev"), + ); + assert.equal(readCompletionState(root).state, "crashed", "no lock held -> update died mid-way"); + + // Now hold the install-op lock -- must read as "updating", not "crashed". + const lockPath = join(root, "run", "install-op.lock.db"); + const holder = new DatabaseSync(lockPath); + holder.exec("BEGIN EXCLUSIVE;"); + try { + assert.equal(readCompletionState(root).state, "updating"); + } finally { + holder.exec("ROLLBACK;"); + holder.close(); + } + + rmSync(join(root, "run", "install-manifest.json.prev")); + assert.equal(readCompletionState(root).state, "missing", "no manifest, no .prev"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("verifyDigest: clean install passes; a missing file, an extra file, an edited file, and a symlink all fail", () => { + const { root, manifest } = makeInstall(); + try { + assert.deepEqual(verifyDigest(root, manifest), { ok: true }); + + // Edited file: digest mismatch. + + writeFileSync(join(root, "scripts", "team.sh"), "tampered\n"); + assert.equal(verifyDigest(root, manifest).ok, false); + writeFileSync(join(root, "scripts", "team.sh"), "team\n"); // restore + + // Missing file. + unlinkSync(join(root, "scripts", "lib", "storage.sh")); + let r = verifyDigest(root, manifest); + assert.equal(r.ok, false); + assert.match(r.reason, /missing/); + writeFileSync(join(root, "scripts", "lib", "storage.sh"), "storage\n"); // restore + + // Extra file not in the manifest. + writeFileSync(join(root, "scripts", "extra.sh"), "surprise\n"); + r = verifyDigest(root, manifest); + assert.equal(r.ok, false); + assert.match(r.reason, /unexpected/); + unlinkSync(join(root, "scripts", "extra.sh")); + + // A symlink anywhere under scripts/ fails verification outright, even + // though it points at an otherwise-correct file. + symlinkSync(join(root, "scripts", "team.sh"), join(root, "scripts", "team-link.sh")); + r = verifyDigest(root, manifest); + assert.equal(r.ok, false); + assert.match(r.reason, /symlink/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("verifyInstallId: matches, mismatches, and a never-set install.db all report correctly", () => { + const { root, manifest } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + // Never set: meta.install_id is NULL, must mismatch (not silently pass). + assert.equal(verifyInstallId(db, manifest).ok, false); + + db.exec(`UPDATE meta SET install_id = '${manifest.install_id}'`); + assert.equal(verifyInstallId(db, manifest).ok, true); + + db.exec("UPDATE meta SET install_id = 'some-other-install'"); + const r = verifyInstallId(db, manifest); + assert.equal(r.ok, false); + assert.match(r.reason, /mismatch/); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("watchForDrift: quiet when nothing changed; catches a moved manifest, a VERSION bump, and an in-place edit", async () => { + const { root, manifest } = makeInstall(); + try { + const first = readCompletionState(root); + const state = await captureWatchState(root, manifest, first.manifestText); + assert.deepEqual(await watchForDrift(root, state), { changed: false }); + + // Case 1: an install/uninstall starts (manifest moved to .prev). + renameSync( + join(root, "run", "install-manifest.json"), + join(root, "run", "install-manifest.json.prev"), + ); + let r = await watchForDrift(root, state); + assert.equal(r.changed, true); + assert.match(r.reason, /completion record/); + renameSync( + join(root, "run", "install-manifest.json.prev"), + join(root, "run", "install-manifest.json"), + ); + + // Case 2: VERSION changed without the manifest moving at all. + writeFileSync(join(root, "VERSION"), "0.0.1-different\n"); + r = await watchForDrift(root, state); + assert.equal(r.changed, true); + assert.match(r.reason, /VERSION/); + writeFileSync(join(root, "VERSION"), `${manifest.version}\n`); + + // Case 3: a file under scripts/ edited in place -- the #963 case -- + // with neither the manifest nor VERSION touched. + writeFileSync(join(root, "scripts", "team.sh"), "tampered in place\n"); + r = await watchForDrift(root, state); + assert.equal(r.changed, true); + assert.match(r.reason, /scripts\/ changed in place/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_log.test.mjs b/tests/agmsgd_log.test.mjs new file mode 100644 index 000000000..d2739a1c7 --- /dev/null +++ b/tests/agmsgd_log.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { logLine, logPath } from "../scripts/daemon/log.mjs"; + +test("logLine appends and rotates at 1 MiB, keeping exactly one prior generation", () => { + const root = mkdtempSync(join(tmpdir(), "agmsgd-log-test-")); + mkdirSync(join(root, "run"), { recursive: true }); + try { + logLine(root, "first line"); + const path = logPath(root); + assert.match(readFileSync(path, "utf8"), /first line/); + + // Force the file past the rotation threshold without writing 1 MiB + // through the real API one line at a time. + writeFileSync(path, "x".repeat(1024 * 1024 + 1)); + logLine(root, "after rotation"); + assert.equal(existsSync(`${path}.1`), true, "the oversized file must be rotated aside"); + assert.match(readFileSync(path, "utf8"), /after rotation/); + assert.ok(statSync(`${path}.1`).size >= 1024 * 1024); + + // A second rotation must not accumulate a .2 -- exactly one prior + // generation is kept. + writeFileSync(path, "y".repeat(1024 * 1024 + 1)); + logLine(root, "second rotation"); + assert.equal(existsSync(`${path}.2`), false); + assert.match(readFileSync(`${path}.1`, "utf8"), /^y+$/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_main.test.mjs b/tests/agmsgd_main.test.mjs new file mode 100644 index 000000000..e0e00627d --- /dev/null +++ b/tests/agmsgd_main.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { openInstallDb } from "../scripts/daemon/db.mjs"; +import { readOwner } from "../scripts/daemon/owner.mjs"; +import { gracefulStop, pollOnce, startup } from "../scripts/daemon/main.mjs"; +import { captureWatchState } from "../scripts/daemon/lifecycle.mjs"; +import { createHash } from "node:crypto"; + +function sha256(text) { + return createHash("sha256").update(text).digest("hex"); +} + +function makeInstall() { + const root = mkdtempSync(join(tmpdir(), "agmsgd-main-test-")); + mkdirSync(join(root, "run"), { recursive: true }); + mkdirSync(join(root, "scripts"), { recursive: true }); + writeFileSync(join(root, "scripts", "x.sh"), "x\n"); + writeFileSync(join(root, "VERSION"), "1.0.0\n"); + const manifest = { + install_id: "i1", + gen: 1, + version: "1.0.0", + created_at: new Date().toISOString(), + digest_algo: "sha256", + files: [{ path: "scripts/x.sh", digest: sha256("x\n") }], + }; + const manifestText = JSON.stringify(manifest); + writeFileSync(join(root, "run", "install-manifest.json"), manifestText); + return { root, manifest, manifestText }; +} + +test("startup: takes ownership, binds the socket, and marks ready", async () => { + const { root } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + const started = await startup(db, { + installRoot: root, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + handlers: { onStop: async () => ({}), onStatus: async () => ({}) }, + }); + assert.equal(started.ok, true); + assert.equal(readOwner(db).state, "ready"); + await started.controlHandle.close(); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("startup: a refused takeOwnership never touches the control socket, and reports ok:false", async () => { + const { root } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + // Wrong expectedOpGen -> takeOwnership refuses before ever trying to bind. + const started = await startup(db, { + installRoot: root, + expectedDesired: null, + expectedOpGen: 99, + version: "test", + handlers: { onStop: async () => ({}), onStatus: async () => ({}) }, + }); + assert.equal(started.ok, false); + assert.equal(readOwner(db).state, "none", "a refused start must never move daemon_owner"); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("pollOnce: continues when nothing changed, steps aside on drift, stops on an intent change", async () => { + const { root, manifest, manifestText } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + const watchState = await captureWatchState(root, manifest, manifestText); + + let verdict = await pollOnce(db, root, { gen: 1, expectedOpGen: 0, watchState }); + assert.deepEqual(verdict, { action: "continue" }); + + // Case: an explicit operation bumped op_gen since this process started. + db.exec("UPDATE daemon_intent SET op_gen = 1"); + verdict = await pollOnce(db, root, { gen: 1, expectedOpGen: 0, watchState }); + assert.equal(verdict.action, "stop"); + assert.equal(verdict.reason, "normal"); + db.exec("UPDATE daemon_intent SET op_gen = 0"); // restore + + // Case: an in-place edit under scripts/ (the #963 case) with neither + // the manifest nor the op_gen touched. + writeFileSync(join(root, "scripts", "x.sh"), "tampered\n"); + verdict = await pollOnce(db, root, { gen: 1, expectedOpGen: 0, watchState }); + assert.equal(verdict.action, "step_aside"); + assert.equal(verdict.reason, "stepped_aside_for_update"); + + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("gracefulStop: marks stopping, stops every channel hook, closes the socket, and records the reason", async () => { + const { root } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + const started = await startup(db, { + installRoot: root, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + handlers: { onStop: async () => ({}), onStatus: async () => ({}) }, + }); + assert.equal(started.ok, true); + + let hookStopped = false; + const hooks = [{ stop: async () => { hookStopped = true; } }]; + await gracefulStop(db, root, started.gen, started.controlHandle, hooks, "normal"); + + assert.equal(hookStopped, true); + const owner = readOwner(db); + assert.equal(owner.state, "none"); + assert.equal(owner.last_end_reason, "normal"); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("gracefulStop: a channel hook that throws is logged but does not stop the rest of shutdown", async () => { + const { root } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + const started = await startup(db, { + installRoot: root, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + handlers: { onStop: async () => ({}), onStatus: async () => ({}) }, + }); + let secondHookRan = false; + const hooks = [ + { stop: async () => { throw new Error("boom"); } }, + { stop: async () => { secondHookRan = true; } }, + ]; + await gracefulStop(db, root, started.gen, started.controlHandle, hooks, "normal"); + assert.equal(secondHookRan, true); + assert.equal(readOwner(db).state, "none", "shutdown must still complete despite the throwing hook"); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_owner.test.mjs b/tests/agmsgd_owner.test.mjs new file mode 100644 index 000000000..b44ca29a4 --- /dev/null +++ b/tests/agmsgd_owner.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { openInstallDb } from "../scripts/daemon/db.mjs"; +import { + markReady, + markStopping, + readOwner, + revertToNone, + stopNormally, + takeOwnership, +} from "../scripts/daemon/owner.mjs"; +import { bootId } from "../scripts/daemon/executor.mjs"; + +function freshDb() { + const dir = mkdtempSync(join(tmpdir(), "agmsgd-owner-test-")); + const db = openInstallDb(join(dir, "install.db")); + return { dir, db }; +} + +test("takeOwnership: none -> starting -> ready is a clean run", () => { + const { dir, db } = freshDb(); + try { + const r = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + }); + assert.equal(r.ok, true); + assert.equal(r.gen, 1); + assert.equal(readOwner(db).state, "starting"); + assert.equal(markReady(db, r.gen), true); + assert.equal(readOwner(db).state, "ready"); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("takeOwnership refuses when the current owner is alive", () => { + const { dir, db } = freshDb(); + try { + const first = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "v1", + }); + assert.equal(first.ok, true); + markReady(db, first.gen); + + const second = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "v2", + }); + assert.equal(second.ok, false); + assert.match(second.reason, /alive/); + assert.equal(readOwner(db).gen, first.gen, "the alive owner's gen must not be disturbed"); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("takeOwnership takes over when the recorded owner is confirmed dead", () => { + const { dir, db } = freshDb(); + try { + const first = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "v1", + }); + markReady(db, first.gen); + + // Overwrite the recorded executor with one that has already exited, at + // the current boot id -- i.e. confirmed dead, not merely absent. + const dead = spawnSync(process.execPath, ["-e", "process.exit(0)"]); + db.prepare("UPDATE daemon_owner SET executor_pid = ?, executor_boot_id = ? WHERE gen = ?").run( + dead.pid, + bootId(), + first.gen, + ); + + const second = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "v2", + }); + assert.equal(second.ok, true); + assert.equal(second.gen, first.gen + 1, "gen must advance and never be reused"); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("takeOwnership refuses when the intent changed since the launcher observed it", () => { + const { dir, db } = freshDb(); + try { + db.exec("UPDATE daemon_intent SET desired = 'on', op_gen = 5"); + const r = takeOwnership(db, { + installRoot: dir, + expectedDesired: "on", + expectedOpGen: 4, // stale -- op_gen has since moved to 5 + version: "test", + }); + assert.equal(r.ok, false); + assert.match(r.reason, /intent changed/); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("revertToNone (bind failure) and stopNormally both record last_end under this gen's own CAS", () => { + const { dir, db } = freshDb(); + try { + const r = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + }); + assert.equal(revertToNone(db, r.gen, "bind failed"), true); + let owner = readOwner(db); + assert.equal(owner.state, "none"); + assert.equal(owner.last_end_reason, "bind failed"); + assert.equal(owner.last_end_gen, r.gen); + + const r2 = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + }); + markReady(db, r2.gen); + assert.equal(markStopping(db, r2.gen), true); + assert.equal(readOwner(db).state, "stopping"); + assert.equal(stopNormally(db, r2.gen, "normal"), true); + owner = readOwner(db); + assert.equal(owner.state, "none"); + assert.equal(owner.last_end_reason, "normal"); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_status.test.mjs b/tests/agmsgd_status.test.mjs new file mode 100644 index 000000000..9c5edd195 --- /dev/null +++ b/tests/agmsgd_status.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { classify } from "../scripts/daemon/status.mjs"; + +const baseOwner = { gen: 1, version: "1.6.0", socket: "/tmp/x.sock" }; + +test("ready + reachable -> running, exit 0", () => { + const r = classify({ owner: { ...baseOwner, state: "ready" }, intent: { desired: "on" }, alive: true, reachable: true }); + assert.equal(r.exitCode, 0); + assert.match(r.text, /running/); +}); + +test("ready + NOT reachable -> exit 1, names the mismatch", () => { + const r = classify({ owner: { ...baseOwner, state: "ready" }, intent: { desired: "on" }, alive: true, reachable: false }); + assert.equal(r.exitCode, 1); + assert.match(r.text, /does not answer/); +}); + +test("starting, alive, within 30s grace -> exit 0; past grace or dead -> exit 1", () => { + const now = Date.now(); + const recent = new Date(now - 5_000).toISOString(); + const old = new Date(now - 60_000).toISOString(); + + let r = classify( + { owner: { ...baseOwner, state: "starting", started_at: recent }, intent: {}, alive: true }, + now, + ); + assert.equal(r.exitCode, 0); + + r = classify( + { owner: { ...baseOwner, state: "starting", started_at: old }, intent: {}, alive: true }, + now, + ); + assert.equal(r.exitCode, 1, "past the 30s grace window must warn even while alive"); + + r = classify( + { owner: { ...baseOwner, state: "starting", started_at: recent }, intent: {}, alive: false }, + now, + ); + assert.equal(r.exitCode, 1, "a confirmed-dead executor must warn even within the grace window"); + + r = classify( + { owner: { ...baseOwner, state: "starting", started_at: recent }, intent: {}, alive: null }, + now, + ); + assert.equal(r.exitCode, 1, "undetermined liveness must warn, not pass silently"); +}); + +test("none + intent off -> exit 0, names it as deliberate", () => { + const r = classify({ owner: { ...baseOwner, gen: 3, state: "none" }, intent: { desired: "off", set_at: "t" }, alive: null }); + assert.equal(r.exitCode, 0); + assert.match(r.text, /not in use/); +}); + +test("none + gen 0 (never started) -> exit 0, never says 'not installed'", () => { + const r = classify({ owner: { ...baseOwner, gen: 0, state: "none" }, intent: {}, alive: null }); + assert.equal(r.exitCode, 0); + assert.match(r.text, /never been started/); + assert.doesNotMatch(r.text, /not installed/i); +}); + +test("none + bind_failed last_end -> exit 1, names the reason", () => { + const r = classify({ + owner: { ...baseOwner, state: "none", last_end_reason: "bind_failed", last_end_at: "t", last_end_gen: 1 }, + intent: { desired: "on" }, + alive: null, + }); + assert.equal(r.exitCode, 1); + assert.match(r.text, /failed to start/); +}); + +test("none + stepped_aside_for_update -> exit 1, says the new version has not started", () => { + const r = classify({ + owner: { ...baseOwner, state: "none", last_end_reason: "stepped_aside_for_update", last_end_at: "t" }, + intent: { desired: "on" }, + alive: null, + }); + assert.equal(r.exitCode, 1); + assert.match(r.text, /stopped for an update/); +}); + +test("none + intent on + normal stop -> exit 1, distinct from no-recorded-intent", () => { + let r = classify({ + owner: { ...baseOwner, state: "none", last_end_reason: "normal" }, + intent: { desired: "on" }, + alive: null, + }); + assert.equal(r.exitCode, 1); + assert.match(r.text, /intent is on/); + + r = classify({ + owner: { ...baseOwner, state: "none", last_end_reason: "normal" }, + intent: {}, + alive: null, + }); + assert.equal(r.exitCode, 1); + assert.match(r.text, /No intent/); + + // Never collapse a real problem into exit 0. + assert.notEqual(r.exitCode, 0); +}); diff --git a/tests/test_agmsgd_control.bats b/tests/test_agmsgd_control.bats new file mode 100644 index 000000000..fab356fae --- /dev/null +++ b/tests/test_agmsgd_control.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd control: hello/stop/status framing, protocol/role/duplicate-key rejection, frame ceiling" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_control.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats new file mode 100644 index 000000000..879b54f6e --- /dev/null +++ b/tests/test_agmsgd_daemon_sh.bats @@ -0,0 +1,365 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env + DAEMON="$SCRIPTS/daemon.sh" + chmod +x "$SCRIPTS/daemon/agmsgd" "$SCRIPTS/daemon/agmsgd-launch.sh" +} + +teardown() { + # Safety net: recompute this test's own unit id/plist path the same way + # daemon.sh does and remove that exact, deterministic file -- never a + # glob or a freshly-recomputed variable standing in for "whatever is + # there now" (the anti-pattern is a rm target built from something that + # could resolve to someone else's entry; this is the same install + # re-deriving its own single, fixed name). + # + # Deliberately does NOT call the real launchctl here, even as a + # last-resort cleanup: `launchctl bootstrap "gui/$(id -u)" ` + # registers into the REAL, system-wide launchd session for this real + # user regardless of $HOME -- sandboxing $HOME only moves where the + # PLIST FILE lives, it does nothing to where the registration itself + # goes (found the hard way: a real unit leaked into the real + # ~/Library/LaunchAgents even though every path in this file's own + # $TEST_SKILL_DIR/$HOME was already sandboxed). Tests now only ever + # drive a fake launchctl (_fake_launchctl, below), so there is nothing + # real for this teardown to unregister; only the file needs removing. + if [ -n "${TEST_SKILL_DIR:-}" ] && [ -f "$TEST_SKILL_DIR/run/install.db" ]; then + local install_id label plist + install_id="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT install_id FROM meta;" 2>/dev/null || true)" + if [ -n "$install_id" ]; then + local unit_id + unit_id="$(printf '%s:%s' "$TEST_SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12)" + label="cc.agmsg.agmsgd.$unit_id" + plist="$HOME/Library/LaunchAgents/$label.plist" + rm -f "$plist" + fi + fi + teardown_test_env +} + +_seed_install_db() { + mkdir -p "$TEST_SKILL_DIR/run" + sqlite3 "$TEST_SKILL_DIR/run/install.db" < "$SCRIPTS/daemon/schema.sql" + # node_path is normally recorded by `enable` -- seeded + # here directly for tests that exercise `start` on its own, without + # going through `enable` first. + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET install_id = 'daemon-sh-test', node_path = '$(command -v node)';" +} + +_write_completion_record() { + # watchForDrift() compares the live VERSION file against the manifest's + # own copy of it every poll cycle; a manifest that names a version + # with no matching VERSION file on disk is drift on the very first + # cycle, indistinguishable from a real one. + echo "test" > "$TEST_SKILL_DIR/VERSION" + node -e " + const { createHash } = require('node:crypto'); + const { readFileSync, readdirSync, writeFileSync } = require('node:fs'); + const { join } = require('node:path'); + const root = process.argv[1]; + const files = []; + function walk(dir, rel) { + for (const e of readdirSync(join(root, dir), { withFileTypes: true })) { + const relPath = rel ? rel + '/' + e.name : e.name; + if (e.isSymbolicLink()) continue; + if (e.isDirectory()) walk(dir + '/' + e.name, relPath); + else if (e.isFile()) { + const digest = createHash('sha256').update(readFileSync(join(root, dir, e.name))).digest('hex'); + files.push({ path: 'scripts/' + relPath, digest }); + } + } + } + walk('scripts', ''); + files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + const bootstrapLine = readFileSync(join(root, 'scripts/daemon/agmsgd'), 'utf8') + .split('\n').find((l) => l.startsWith('const BOOTSTRAP_VERSION = ')); + const bootstrapVersion = Number(bootstrapLine.match(/= (\d+);/)[1]); + writeFileSync(join(root, 'run/install-manifest.json'), JSON.stringify({ + install_id: 'daemon-sh-test', + gen: 1, + version: 'test', + bootstrap_version: bootstrapVersion, + created_at: new Date().toISOString(), + digest_algo: 'sha256', + files, + })); + " "$TEST_SKILL_DIR" +} + +@test "daemon.sh status: no install.db -> exit 1" { + run bash "$DAEMON" status + [ "$status" -eq 1 ] +} + +@test "daemon.sh status: fresh install.db, no completion record -> Node path reports 'never been started'" { + _seed_install_db + run bash "$DAEMON" status + [ "$status" -eq 0 ] + [[ "$output" == *"never been started"* ]] +} + +@test "daemon.sh status: falls back to a minimal read when Node is not on PATH" { + _seed_install_db + local no_node_path + no_node_path="$(printf '%s' "$PATH" | tr ':' '\n' | grep -v -E '/(node|nodejs)([^/]*)?$' | while read -r d; do [ -x "$d/node" ] || printf '%s\n' "$d"; done | paste -sd: -)" + PATH="$no_node_path" run bash "$DAEMON" status + [ "$status" -eq 0 ] + [[ "$output" == *"no usable Node"* ]] +} + +# A bare `run` has no ceiling of its own: a real hang in the command +# under test (found twice already while writing this file) stalls the +# whole suite rather than failing the one test. Backgrounds the command, +# races it against a deadline, and reports "timed out" as a status bats +# can act on rather than a wedged bats process. +_run_with_deadline() { + local deadline_s="$1"; shift + local outfile exitfile + outfile="$(mktemp)" + exitfile="$(mktemp)" + (if "$@" > "$outfile" 2>&1; then echo 0 > "$exitfile"; else echo "$?" > "$exitfile"; fi) & + local pid=$! + local waited=0 + while kill -0 "$pid" 2>/dev/null; do + waited=$((waited + 1)) + if [ "$waited" -ge $((deadline_s * 10)) ]; then + kill -9 "$pid" 2>/dev/null || true + output="(timed out after ${deadline_s}s; partial output: $(cat "$outfile" 2>/dev/null))" + status=124 + rm -f "$outfile" "$exitfile" + return 0 + fi + sleep 0.1 + done + wait "$pid" 2>/dev/null || true + output="$(cat "$outfile")" + status="$(cat "$exitfile")" + rm -f "$outfile" "$exitfile" +} + +@test "daemon.sh start: a real run against the real entrypoint becomes ready" { + _seed_install_db + _write_completion_record + _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq 'running' + bash "$DAEMON" stop >/dev/null 2>&1 || true +} + +@test "daemon.sh status: reports running while the real daemon is up" { + _seed_install_db + _write_completion_record + _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + + _run_with_deadline 10 bash "$DAEMON" status + [ "$status" -eq 0 ] + [[ "$output" == *"running"* ]] || [[ "$output" == *'"exitCode":0'* ]] + bash "$DAEMON" stop >/dev/null 2>&1 || true +} + +@test "daemon.sh start: a second start while already running is a no-op" { + _seed_install_db + _write_completion_record + _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + + _run_with_deadline 10 bash "$DAEMON" start + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq 'already running' + bash "$DAEMON" stop >/dev/null 2>&1 || true +} + +@test "daemon.sh stop: stops a real running daemon, and a second stop is a no-op" { + _seed_install_db + _write_completion_record + local iteration + for iteration in 1 2 3; do + _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + + _run_with_deadline 10 bash "$DAEMON" status + [ "$status" -eq 0 ] + [[ "$output" == *"running"* ]] || [[ "$output" == *'"exitCode":0'* ]] + + _run_with_deadline 10 bash "$DAEMON" start + [ "$status" -eq 0 ] + [[ "$output" == *"already running"* ]] + + _run_with_deadline 15 bash "$DAEMON" stop + [ "$status" -eq 0 ] + [[ "$output" == *"stopped"* ]] + + _run_with_deadline 10 bash "$DAEMON" stop + [ "$status" -eq 0 ] + [[ "$output" == *"already stopped"* ]] + done +} + +# A fake launchctl, never the real one: if a bats run is killed externally +# mid-test, it skips its own teardown, and the +# real gui launchd domain is not this test's to leave litter in even when +# nothing goes wrong -- exactly this happened once already while writing +# this file. Real registration is exercised only by hand, never from an +# automated run on any platform. +_fake_launchctl() { + mkdir -p "$TEST_SKILL_DIR/fake-launchd" + cat > "$TEST_SKILL_DIR/fake-launchd/launchctl" <<'EOF' +#!/usr/bin/env bash +# Records enough for the test to tell load/bootstrap from unload/bootout +# apart, against a marker file instead of the real launchd. +if [ -n "${AGMSGD_FAKE_OP_LOCK_DB:-}" ]; then + if ! sqlite3 -cmd 'PRAGMA busy_timeout=0;' "$AGMSGD_FAKE_OP_LOCK_DB" 'BEGIN EXCLUSIVE; ROLLBACK;' >/dev/null 2>&1; then + printf 'locked %s\n' "$1" >> "${AGMSGD_FAKE_LAUNCHD_EVENTS:?}" + else + printf 'unlocked %s\n' "$1" >> "${AGMSGD_FAKE_LAUNCHD_EVENTS:?}" + fi +fi +printf 'call %s\n' "$*" >> "${AGMSGD_FAKE_LAUNCHD_EVENTS:-/dev/null}" +case "$1" in + bootstrap|load) touch "${AGMSGD_FAKE_LAUNCHD_MARKER:?}" ;; + bootout|unload) + [ "${AGMSGD_FAKE_LAUNCHD_FAIL_UNREGISTER:-0}" != 1 ] || exit 1 + rm -f "${AGMSGD_FAKE_LAUNCHD_MARKER:?}" + ;; + list) [ -f "${AGMSGD_FAKE_LAUNCHD_MARKER:?}" ] ;; + kickstart) + printf '%s\n' "$*" >> "${AGMSGD_FAKE_LAUNCHD_EVENTS:?}" + if [ "${AGMSGD_FAKE_LAUNCHD_FAIL_UNREGISTER:-0}" = 1 ]; then exit 1; fi + bash "${AGMSGD_TEST_LAUNCHER:?}" /dev/null 2>&1 3>&- & + ;; +esac +EOF + chmod +x "$TEST_SKILL_DIR/fake-launchd/launchctl" + echo "$TEST_SKILL_DIR/fake-launchd/launchctl" +} + +_fake_windows_manager() { + mkdir -p "$TEST_SKILL_DIR/fake-windows" + cat > "$TEST_SKILL_DIR/fake-windows/uname" <<'EOF' +#!/usr/bin/env bash +echo MINGW64_NT-10.0 +EOF + cat > "$TEST_SKILL_DIR/fake-windows/schtasks" <<'EOF' +#!/usr/bin/env bash +case "$1" in + /Create) + while [ "$#" -gt 0 ]; do + if [ "$1" = /XML ]; then cp "$2" "${AGMSGD_FAKE_TASK_XML:?}"; fi + shift + done + touch "${AGMSGD_FAKE_TASK_MARKER:?}" + ;; + /Run) bash "${AGMSGD_TEST_LAUNCHER:?}" /dev/null 2>&1 3>&- & ;; + /Delete) rm -f "${AGMSGD_FAKE_TASK_MARKER:?}" ;; +esac +EOF + chmod +x "$TEST_SKILL_DIR/fake-windows/uname" "$TEST_SKILL_DIR/fake-windows/schtasks" +} + +@test "daemon.sh enable/disable: registers a resident unit (via a fake launchctl on macOS) and cleans it up" { + [ "$(uname -s)" = "Darwin" ] || skip "this test's fake stands in for launchd specifically" + _seed_install_db + _write_completion_record + local fake_launchctl marker events + fake_launchctl="$(_fake_launchctl)" + marker="$TEST_SKILL_DIR/fake-launchd/registered" + events="$TEST_SKILL_DIR/fake-launchd/events" + + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ + run bash "$DAEMON" enable + [ "$status" -eq 0 ] + + local install_id unit_id label plist + install_id="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT install_id FROM meta;")" + unit_id="$(printf '%s:%s' "$TEST_SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12)" + label="cc.agmsg.agmsgd.$unit_id" + plist="$HOME/Library/LaunchAgents/$label.plist" + [ -f "$plist" ] + [ -f "$marker" ] + + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" \ + run bash "$DAEMON" disable + [ "$status" -eq 0 ] + [ ! -f "$plist" ] + [ ! -f "$marker" ] + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" = "none" ] + grep -q '^locked bootstrap$' "$events" + grep -q '^locked bootout$' "$events" +} + +@test "daemon.sh start: stale ready state asks the registered manager to start the service" { + [ "$(uname -s)" = "Darwin" ] || skip "this test's fake stands in for launchd specifically" + _seed_install_db + _write_completion_record + local fake_launchctl marker events install_id unit_id label plist + fake_launchctl="$(_fake_launchctl)" + marker="$TEST_SKILL_DIR/fake-launchd/registered" + events="$TEST_SKILL_DIR/fake-launchd/events" + install_id="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT install_id FROM meta;")" + unit_id="$(printf '%s:%s' "$TEST_SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12)" + label="cc.agmsg.agmsgd.$unit_id" + plist="$HOME/Library/LaunchAgents/$label.plist" + mkdir -p "$(dirname "$plist")" + : > "$plist" + touch "$marker" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='on'; UPDATE daemon_owner SET state='ready', gen=1, executor_pid=99999999, executor_boot_id='stale', socket='$TEST_SKILL_DIR/run/missing.sock';" + + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ + run bash "$DAEMON" start + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq 'running' + grep -q 'kickstart gui/' "$events" + bash "$DAEMON" stop >/dev/null 2>&1 || true +} + +@test "daemon.sh disable: unregister failure is reported and preserves registration" { + [ "$(uname -s)" = "Darwin" ] || skip "this test's fake stands in for launchd specifically" + _seed_install_db + _write_completion_record + local fake_launchctl marker events install_id unit_id label plist + fake_launchctl="$(_fake_launchctl)" + marker="$TEST_SKILL_DIR/fake-launchd/registered" + events="$TEST_SKILL_DIR/fake-launchd/events" + install_id="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT install_id FROM meta;")" + unit_id="$(printf '%s:%s' "$TEST_SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12)" + label="cc.agmsg.agmsgd.$unit_id" + plist="$HOME/Library/LaunchAgents/$label.plist" + mkdir -p "$(dirname "$plist")" + : > "$plist" + touch "$marker" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='on';" + + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" AGMSGD_FAKE_LAUNCHD_FAIL_UNREGISTER=1 \ + run bash "$DAEMON" disable + [ "$status" -ne 0 ] + printf '%s\n' "$output" | grep -Fq 'could not unregister launchd service' + [ -f "$plist" ] + [ -f "$marker" ] + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT desired FROM daemon_intent;")" = "on" ] + grep -q '^locked bootout$' "$events" +} + +@test "daemon.sh Windows registration writes the declared UTF-16 task XML" { + _seed_install_db + _write_completion_record + _fake_windows_manager + local capture marker + capture="$TEST_SKILL_DIR/fake-windows/captured.xml" + marker="$TEST_SKILL_DIR/fake-windows/registered" + + PATH="$TEST_SKILL_DIR/fake-windows:$PATH" AGMSGD_SCHTASKS="$TEST_SKILL_DIR/fake-windows/schtasks" AGMSGD_FAKE_TASK_XML="$capture" AGMSGD_FAKE_TASK_MARKER="$marker" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ + _run_with_deadline 15 bash "$DAEMON" enable + [ "$status" -eq 0 ] + [ "$(od -An -tx1 -N2 "$capture" | tr -d ' \n')" = "fffe" ] + iconv -f UTF-16LE -t UTF-8 "$capture" | grep -q 'encoding="UTF-16"' + + PATH="$TEST_SKILL_DIR/fake-windows:$PATH" AGMSGD_SCHTASKS="$TEST_SKILL_DIR/fake-windows/schtasks" AGMSGD_FAKE_TASK_MARKER="$marker" \ + _run_with_deadline 15 bash "$DAEMON" disable + [ "$status" -eq 0 ] + [ ! -f "$marker" ] + [ ! -f "$TEST_SKILL_DIR/run/agmsgd-$(_unit_id).xml" ] +} diff --git a/tests/test_agmsgd_db.bats b/tests/test_agmsgd_db.bats new file mode 100644 index 000000000..21593799b --- /dev/null +++ b/tests/test_agmsgd_db.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd db: schema is idempotent, readOnly never migrates or writes, transactions roll back on throw" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_db.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats new file mode 100644 index 000000000..c690a853f --- /dev/null +++ b/tests/test_agmsgd_entrypoint.bats @@ -0,0 +1,116 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env +} + +teardown() { + teardown_test_env +} + +# Builds a real completion record for $TEST_SKILL_DIR/scripts (which +# setup_test_env already populated with a full, real copy of this repo's +# scripts/ tree, this PR's new scripts/daemon/* files included) and a real +# install.db whose meta.install_id matches it -- an end-to-end fixture for +# agmsgd's own self-verification, not a synthetic one. +_write_completion_record() { + # watchForDrift() compares the live VERSION file against the manifest's + # copy of it every poll cycle; without a matching VERSION file, + # this test only avoids that drift by finishing inside the 5s poll + # interval -- fragile on a slower machine. Write it for real. + echo "test" > "$TEST_SKILL_DIR/VERSION" + local install_id="test-install-id" + mkdir -p "$TEST_SKILL_DIR/run" + node -e " + const { createHash } = require('node:crypto'); + const { readFileSync, readdirSync, writeFileSync } = require('node:fs'); + const { join } = require('node:path'); + const root = process.argv[1]; + const files = []; + function walk(dir, rel) { + for (const e of readdirSync(join(root, dir), { withFileTypes: true })) { + const relPath = rel ? rel + '/' + e.name : e.name; + if (e.isSymbolicLink()) continue; + if (e.isDirectory()) walk(dir + '/' + e.name, relPath); + else if (e.isFile()) { + const digest = createHash('sha256').update(readFileSync(join(root, dir, e.name))).digest('hex'); + files.push({ path: 'scripts/' + relPath, digest }); + } + } + } + walk('scripts', ''); + files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + const bootstrapLine = readFileSync(join(root, 'scripts/daemon/agmsgd'), 'utf8') + .split('\n').find((l) => l.startsWith('const BOOTSTRAP_VERSION = ')); + const bootstrapVersion = Number(bootstrapLine.match(/= (\d+);/)[1]); + writeFileSync(join(root, 'run/install-manifest.json'), JSON.stringify({ + install_id: process.argv[2], + gen: 1, + version: 'test', + bootstrap_version: bootstrapVersion, + created_at: new Date().toISOString(), + digest_algo: 'sha256', + files, + })); + " "$TEST_SKILL_DIR" "$install_id" + + sqlite3 "$TEST_SKILL_DIR/run/install.db" < "$SCRIPTS/daemon/schema.sql" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET install_id = '$install_id', node_path = '$(command -v node)'; UPDATE daemon_intent SET desired = 'on', op_gen = 0;" +} + +@test "agmsgd end-to-end: starts, answers status over its real control socket, and stops cleanly on request" { + _write_completion_record + + node "$SCRIPTS/daemon/agmsgd" "$TEST_SKILL_DIR" on 0 > "$TEST_SKILL_DIR/run/agmsgd.stdout" 2>&1 & + local daemon_pid=$! + + local socket="" waited=0 + while [ -z "$socket" ]; do + socket="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null)" + [ -n "$socket" ] && break + waited=$((waited + 1)) + if [ "$waited" -ge 100 ]; then + echo "agmsgd never became ready; stdout was:" >&2 + cat "$TEST_SKILL_DIR/run/agmsgd.stdout" >&2 + kill "$daemon_pid" 2>/dev/null || true + false + fi + sleep 0.05 + done + + run node -e " + const net = require('node:net'); + const s = net.createConnection(process.argv[1]); + let buf = ''; + s.on('connect', () => { + s.write(JSON.stringify({type:'hello',protocol:1,role:'control'}) + '\n'); + s.write(JSON.stringify({type:'status'}) + '\n'); + }); + s.on('data', (c) => { buf += c.toString('utf8'); if (buf.split('\n').filter(Boolean).length >= 2) { console.log(buf); s.end(); } }); + s.on('error', (e) => { console.error(e.message); process.exit(1); }); + " "$socket" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq '"type":"hello_ok"' + printf '%s\n' "$output" | grep -Fq '"type":"status"' + + run node -e " + const net = require('node:net'); + const s = net.createConnection(process.argv[1]); + s.on('connect', () => { + s.write(JSON.stringify({type:'hello',protocol:1,role:'control'}) + '\n'); + s.write(JSON.stringify({type:'stop'}) + '\n'); + }); + s.on('close', () => process.exit(0)); + s.on('error', (e) => { console.error(e.message); process.exit(1); }); + " "$socket" + [ "$status" -eq 0 ] + + wait "$daemon_pid" + [ "$?" -eq 0 ] + + local final_state + final_state="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" + [ "$final_state" = "none" ] +} diff --git a/tests/test_agmsgd_executor.bats b/tests/test_agmsgd_executor.bats new file mode 100644 index 000000000..49f8d1604 --- /dev/null +++ b/tests/test_agmsgd_executor.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd executor: bootId is stable, isAlive tells living/dead/wrong-boot apart" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_executor.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_launch.bats b/tests/test_agmsgd_launch.bats new file mode 100644 index 000000000..1cb52918b --- /dev/null +++ b/tests/test_agmsgd_launch.bats @@ -0,0 +1,130 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env + LAUNCH="$SCRIPTS/daemon/agmsgd-launch.sh" +} + +teardown() { + teardown_test_env +} + +# A fresh Node binary path this launcher's own version/node:sqlite check +# will accept, and a throwaway install.db already migrated with the real +# schema.sql this launcher's own repo copy carries. +_seed_install_db() { + sqlite3 "$SKILLDIR_INSTALL_DB" < "$SCRIPTS/daemon/schema.sql" +} + +setup_install_db() { + mkdir -p "$TEST_SKILL_DIR/run" + SKILLDIR_INSTALL_DB="$TEST_SKILL_DIR/run/install.db" + _seed_install_db +} + +@test "agmsgd-launch: no install.db -> exits 0, starts nothing" { + run bash "$LAUNCH" + [ "$status" -eq 0 ] +} + +@test "agmsgd-launch: intent is not 'on' -> exits 0 silently" { + setup_install_db + # schema.sql seeds desired = NULL; explicitly set it to 'off' too. + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'off';" + run bash "$LAUNCH" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agmsgd-launch: intent on, no completion record at all -> exits 0, records a start attempt" { + setup_install_db + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" + run bash "$LAUNCH" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq 'install.sh needs to run again' + count="$(sqlite3 "$SKILLDIR_INSTALL_DB" "SELECT count(*) FROM daemon_start_attempts;")" + [ "$count" -eq 1 ] +} + +@test "agmsgd-launch: an update in progress (a held install-op lock) -> exits 75" { + setup_install_db + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" + touch "$TEST_SKILL_DIR/run/install-manifest.json.prev" + + # sqlite3 given a whole statement list as one argument runs it and exits + # immediately -- releasing the lock before the launcher could ever see + # it held. An interactive session, fed through a FIFO kept open past + # the BEGIN EXCLUSIVE, is what actually holds it across commands, the + # way a real in-progress install.sh would. + local fifo="$TEST_SKILL_DIR/run/holder.fifo" + mkfifo "$fifo" + sqlite3 "$TEST_SKILL_DIR/run/install-op.lock.db" < "$fifo" & + local holder_pid=$! + exec 8> "$fifo" + echo "BEGIN EXCLUSIVE;" >&8 + # No fixed sleep budget: poll until the lock is actually held (a second, + # independent probe blocks), so this test does not race the holder's + # own startup time. + local waited=0 + while sqlite3 -cmd "PRAGMA busy_timeout=0;" "$TEST_SKILL_DIR/run/install-op.lock.db" "BEGIN EXCLUSIVE; ROLLBACK;" >/dev/null 2>&1; do + waited=$((waited + 1)) + [ "$waited" -lt 100 ] || break + sleep 0.05 + done + + run bash "$LAUNCH" + [ "$status" -eq 75 ] + + echo "ROLLBACK;" >&8 + exec 8>&- + wait "$holder_pid" 2>/dev/null || true +} + +@test "agmsgd-launch: a crashed update (.prev present, lock free) -> exits 0, records a start attempt" { + setup_install_db + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" + touch "$TEST_SKILL_DIR/run/install-manifest.json.prev" + run bash "$LAUNCH" + [ "$status" -eq 0 ] + [[ "$output" == *"never completed"* ]] +} + +@test "agmsgd-launch: bootstrap_version mismatch -> exits 75, records a start attempt" { + setup_install_db + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" + echo '{"bootstrap_version":999}' > "$TEST_SKILL_DIR/run/install-manifest.json" + run bash "$LAUNCH" + [ "$status" -eq 75 ] + [[ "$output" == *"bootstrap version mismatch"* ]] +} + +@test "agmsgd-launch: intent on, completion record present, but no usable Node recorded -> exits 0, records a start attempt" { + setup_install_db + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" + echo '{"bootstrap_version":1}' > "$TEST_SKILL_DIR/run/install-manifest.json" + run bash "$LAUNCH" + [ "$status" -eq 0 ] + [[ "$output" == *"no usable Node"* ]] +} + +@test "agmsgd-launch: full happy path execs into scripts/daemon/agmsgd with (skillDir, desired, op_gen)" { + setup_install_db + local real_node + real_node="$(command -v node)" + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on', op_gen = 7; UPDATE meta SET node_path = '$real_node';" + echo '{"bootstrap_version":1}' > "$TEST_SKILL_DIR/run/install-manifest.json" + # Stub the Node entrypoint so this test exercises only the launcher's + # own decision to hand off, not agmsgd itself (a separate component with + # its own tests). + cat > "$TEST_SKILL_DIR/scripts/daemon/agmsgd" <<'EOF' +#!/usr/bin/env node +console.log(JSON.stringify(process.argv.slice(2))); +EOF + chmod +x "$TEST_SKILL_DIR/scripts/daemon/agmsgd" + + run bash "$LAUNCH" + [ "$status" -eq 0 ] + [[ "$output" == *"\"$TEST_SKILL_DIR\",\"on\",\"7\""* ]] +} diff --git a/tests/test_agmsgd_lifecycle.bats b/tests/test_agmsgd_lifecycle.bats new file mode 100644 index 000000000..d0914aa86 --- /dev/null +++ b/tests/test_agmsgd_lifecycle.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd lifecycle: completion-record state, digest/install_id verification, and drift detection" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_lifecycle.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_log.bats b/tests/test_agmsgd_log.bats new file mode 100644 index 000000000..fbfc6faaa --- /dev/null +++ b/tests/test_agmsgd_log.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd log: appends and rotates at 1 MiB, one prior generation kept" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_log.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_main.bats b/tests/test_agmsgd_main.bats new file mode 100644 index 000000000..91e39d1e4 --- /dev/null +++ b/tests/test_agmsgd_main.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd main: startup/pollOnce/gracefulStop wire owner+control+lifecycle+log correctly" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_main.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_owner.bats b/tests/test_agmsgd_owner.bats new file mode 100644 index 000000000..f21701b60 --- /dev/null +++ b/tests/test_agmsgd_owner.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd owner: daemon_owner compare-and-swap lifecycle (take/ready/revert/stop)" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_owner.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_status.bats b/tests/test_agmsgd_status.bats new file mode 100644 index 000000000..c7fb675cd --- /dev/null +++ b/tests/test_agmsgd_status.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd status: decision table for daemon_owner/daemon_intent" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_status.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_spawn.bats b/tests/test_spawn.bats index c5512bef7..12e528dc0 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -1833,8 +1833,10 @@ _assert_bridged_argv() { # hooks that only bats sets; and names that are script-local variables # assigned inside the stack before they are read, never environment inputs # (role-session lookup results, codex-monitor's parsed command/args/version, - # the doc URL constant from delivery.sh). - local keep=" AGMSG_SPAWNED AGMSG_BASH AGMSG_WATCH_ONCE_INTERVAL AGMSG_WATCH_ONCE_TIMEOUT AGMSG_TEST_DISPATCHER_STALE_BARRIER AGMSG_TEST_ASSUME_CODEX_SOCKET AGMSG_ROLE_SESSION_UUID AGMSG_ROLE_SESSION_PROJECT CODEX_ARGS CODEX_COMMAND CODEX_VERSION CODEX_MONITOR_DOC_URL " + # the doc URL constant from delivery.sh). CODEX_HOME is intentionally + # inherited because the session recorder persists the active profile for + # resume, and the spawned Codex must use that same profile. + local keep=" AGMSG_SPAWNED AGMSG_BASH AGMSG_WATCH_ONCE_INTERVAL AGMSG_WATCH_ONCE_TIMEOUT AGMSG_TEST_DISPATCHER_STALE_BARRIER AGMSG_TEST_ASSUME_CODEX_SOCKET AGMSG_ROLE_SESSION_UUID AGMSG_ROLE_SESSION_PROJECT CODEX_HOME CODEX_ARGS CODEX_COMMAND CODEX_VERSION CODEX_MONITOR_DOC_URL " local inventory missing="" inventory="$( { grep -ohE '\$\{?(AGMSG_[A-Z0-9_]+|CODEX_[A-Z0-9_]+)' "$dir"/codex-shim.sh "$dir"/codex-monitor.sh "$dir"/_app-server.sh "$dir"/codex-bridge-launcher.sh "$dir"/codex-record-session.sh "$dir"/_session-start.sh "$dir"/codex-shim-install.sh "$dir"/_delivery.sh | sed -E 's/^\$\{?//'; grep -ohE 'process\.env\.(AGMSG_[A-Z0-9_]+|CODEX_[A-Z0-9_]+)' "$dir"/codex-bridge.js | sed 's/process\.env\.//'; } | sort -u )" while IFS= read -r v; do From db2a9a3c2769e3e60542e4dc078e682086ceca1a Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 07:57:40 -0700 Subject: [PATCH 03/97] feat(install): operation lock and completion manifest for agmsgd beta (#1506) ## Summary The installer and uninstaller use one per-install operation lock. Before the first protected write, each operation atomically publishes run/install-op-incomplete.json with an operation ID, operation kind and mode, target install path, actor PID, and start time. Later install and uninstall operations refuse to proceed while that record remains; the daemon entrypoint checks for it under the same exclusive lock before importing daemon code and exits with status 75 when it is present. A refusal prints the exact recovery command for the selected install and describes the recorded operation separately from the requested next operation. Before using it, an operator must verify that no writer from the recorded operation is still running; the command displays the recorded PID but does not infer liveness. Recovery removes only the matching operation ID under the lock, then continues the requested install or uninstall. Successful install publishes the completion manifest before removing its matching incomplete-operation record. A caught INT or TERM stops and reaps the tracked copy or removal command before clearing the record when the lock is still held; otherwise the record stays in place. Cancellation during the interval between writer spawn and PID publication also keeps the record, so later operations remain blocked until explicit recovery. A hard process death leaves the record as well. A small recovery helper is atomically published under run/ before installation files are rewritten. It remains available if an interrupted uninstall has removed scripts/, allowing the installed uninstall command to recover and continue. During a full uninstall, the installed uninstall command is moved to an operation-ID-specific retired path before the matching incomplete-operation record is cleared. The final cleanup removes only that retired path, never the canonical uninstall.sh path a later install may have recreated; the recovery helper follows the same matching-operation retirement rule. The installer preserves the last readable completion record before changing installation files. Generation 1 is used only when neither completion record exists; unreadable records without a valid fallback make the operation stop before changing installed files. The manifest records install identity, generation, source and bootstrap versions, timestamp, and SHA-256 digests for files under scripts/. The manifest writer hashes all safely named scripts files in one checked SHA-256 tool invocation and inserts their records with one SQLite session. Pending-operation validation reads all seven fields in one SQLite invocation, with hex-encoded values so delimiters and control characters in paths remain unambiguous. Lock confirmations occur at phase boundaries because the incomplete-operation record blocks competing operations during each phase; fresh-install marker and version writes share one checked phase. The tests/test_install.bats measurements progressed from 1614 seconds on the initial head to 171 seconds after the first batching change and 130 seconds after pending-record read batching; the integration baseline is 73 seconds. Bash 4 and later use a coprocess for the SQLite lock connection because the Windows SQLite CLI treats FIFO input as interactive; Bash 3.2 retains the FIFO path for compatibility with the system Bash on macOS. If the SQLite lock child exits during a copy or removal, the active command may finish, but the incomplete-operation record prevents later install/uninstall operations from entering concurrently; the next boundary check stops the original operation before subsequent writes. A daemon invocation that reaches its locked entry check while the record exists exits with status 75. ## Test plan - [x] Focused install tests cover lock-child loss during scripts copy and full uninstall removal, refusal of competing operations while the incomplete-operation record exists, recovery through the installed uninstaller after scripts/ is removed, a competing install during final retired-path cleanup, and cancellation before and after writer PID publication. - [x] Focused manifest and pending-record tests check batched hashes, per-file fallback for an ambiguous filename, refusal before publishing when the hash tool fails its known-answer probe, one SQLite read for all pending fields, Windows path conversion, CRLF normalization, and the legacy missing-mode default. - [x] Focused checks confirm lock liveness uses the shared local-pid helper when available and both lock/writer spawns close Bats file descriptors 3 and 4. - [x] The focused daemon-entrypoint test verifies status 75 while an incomplete-operation record exists. - [x] Focused install tests, bash -n, and git diff --check pass for the affected files; node --check passes for the daemon entrypoint. - [x] Windows CI (install helpers) passes, covering the coprocess lock path, CRLF normalization, and Windows path conversion. --- install.sh | 480 +++++++++++-- scripts/daemon/agmsgd | 5 + .../types/codex/codex-record-session.sh | 35 +- scripts/drivers/types/codex/template.md | 2 +- scripts/lib/install-db.sh | 61 ++ scripts/lib/install-manifest.sh | 326 +++++++++ scripts/lib/install-op-lock.sh | 558 +++++++++++++++ scripts/lib/role-session.sh | 4 +- scripts/lib/skill-render.sh | 7 + scripts/lib/sqlite-output.sh | 13 + tests/test_agmsgd_entrypoint.bats | 10 + tests/test_codex_resume.bats | 44 +- tests/test_install.bats | 657 +++++++++++++++++- tests/test_install_op_lock.bats | 112 +++ tests/test_spawn_fd_guard.bats | 10 +- uninstall.sh | 245 ++++++- 16 files changed, 2411 insertions(+), 158 deletions(-) create mode 100644 scripts/lib/install-db.sh create mode 100644 scripts/lib/install-manifest.sh create mode 100644 scripts/lib/install-op-lock.sh create mode 100644 scripts/lib/sqlite-output.sh create mode 100644 tests/test_install_op_lock.bats diff --git a/install.sh b/install.sh index 18579fe85..0ad180bd9 100755 --- a/install.sh +++ b/install.sh @@ -21,6 +21,16 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" AGENTS_DIR="$HOME/.agents" +# Operation lock, install.db's meta row, and the completion manifest +# (agmsgd beta) -- the same lock uninstall.sh takes, and the record both a +# fresh install and an --update write around the scripts/ copy. +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/scripts/lib/install-op-lock.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/scripts/lib/install-db.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/scripts/lib/install-manifest.sh" + # Type registry — resolve each type's SKILL command template from its manifest # (scripts/drivers/types//template.md) instead of a hardcoded templates/ path. Read-only # helpers; safe to source. @@ -129,6 +139,7 @@ agmsg_source_version() { # --- Defaults --- CMD_NAME="" UPDATE_ONLY=false +RECOVER_ID="" INTERACTIVE=true AGENT_TYPE="" # claude-code, codex, gemini, antigravity — passed via --agent-type, or empty for auto/default @@ -168,8 +179,11 @@ agmsg_stage_overwrite_backups() { agmsg_scripts_rel_is_safe "$rel" || continue [ -f "$dest/$rel" ] || continue cmp -s "$src/$rel" "$dest/$rel" && continue + agmsg_install_op_require || return 1 mkdir -p "$(dirname "$trash_root/$rel")" + agmsg_install_op_require || return 1 cp -p "$dest/$rel" "$trash_root/$rel" + agmsg_install_op_require || return 1 AGMSG_TRASH_COUNT=$((AGMSG_TRASH_COUNT + 1)) done < <(cd "$src" && find . -type f -print0) } @@ -233,8 +247,11 @@ agmsg_prune_removed_scripts() { done if [ -z "$found" ]; then echo " - moving to .trash/ (no longer shipped): scripts/$rel" + agmsg_install_op_require || return 1 mkdir -p "$(dirname "$trash_root/$rel")" + agmsg_install_op_require || return 1 mv "$dest/$rel" "$trash_root/$rel" + agmsg_install_op_require || return 1 AGMSG_TRASH_COUNT=$((AGMSG_TRASH_COUNT + 1)) fi done < <(cd "$dest" && find . -type f -print0) @@ -247,8 +264,285 @@ agmsg_prune_removed_scripts() { # it -- a .trash/ nested under scripts/ would have its OWN prior contents # picked up by the very next prune as "not shipped". agmsg_reset_trash() { + agmsg_install_op_require || return 1 rm -rf "$1" + agmsg_install_op_require || return 1 mkdir -p "$1" + agmsg_install_op_require +} + +# Begin one install transaction before its first file change. The lock stays +# held through all rendering, script changes, VERSION writes, and setup work; +# the completion manifest is published only by the matching finish call. +# A crash before finish leaves .prev as the last known-good state. +# +# Requires (globals the caller must already have set): SCRIPT_DIR, SKILL_DIR, +# INSTALLED_VERSION. +agmsg_install_operation_exit() { + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ]; then + agmsg_install_op_unlock + AGMSG_INSTALL_OP_ACTIVE=false + fi +} + +agmsg_install_write_recovery_helper() { + local helper="$SKILL_DIR/run/install-op-recovery.sh" temp + agmsg_install_op_require || return 1 + temp="$(mktemp "$SKILL_DIR/run/.install-op-recovery.XXXXXX")" || return 1 + if ! cat "$SCRIPT_DIR/scripts/lib/codex-config.sh" "$SCRIPT_DIR/scripts/lib/sqlpath.sh" \ + "$SCRIPT_DIR/scripts/lib/sqlite-output.sh" "$SCRIPT_DIR/scripts/lib/install-op-lock.sh" > "$temp"; then + rm -f "$temp" + return 1 + fi + if ! chmod 600 "$temp"; then + rm -f "$temp" + return 1 + fi + agmsg_install_op_require || { rm -f "$temp"; return 1; } + if ! mv -f "$temp" "$helper"; then + rm -f "$temp" + return 1 + fi + agmsg_install_op_require +} + +agmsg_install_operation_begin() { + local manifest="$SKILL_DIR/run/install-manifest.json" + local lock_db="$SKILL_DIR/run/install-op.lock.db" + local install_db="$SKILL_DIR/run/install.db" + + mkdir -p "$SKILL_DIR/run" + if ! agmsg_install_op_lock "$lock_db"; then + echo " ! could not take the install operation lock: ${AGMSG_INSTALL_OP_LOCK_FAILURE_REASON:-unknown lock handshake failure}" >&2 + return 1 + fi + AGMSG_INSTALL_OP_ACTIVE=true + trap 'agmsg_install_operation_exit' EXIT + trap 'agmsg_install_op_handle_signal INT' INT + trap 'agmsg_install_op_handle_signal TERM' TERM + + agmsg_install_op_require || return 1 + + local pending="$SKILL_DIR/run/install-op-incomplete.json" + local recovery_prefix operation_mode=install + recovery_prefix="bash $(printf '%q' "$SCRIPT_DIR/install.sh") --cmd $(printf '%q' "$CMD_NAME")" + if [ "$UPDATE_ONLY" = true ]; then + operation_mode=update + recovery_prefix="$recovery_prefix --update" + fi + recovery_prefix="$recovery_prefix --recover" + if [ -n "$RECOVER_ID" ]; then + agmsg_install_op_pending_recover "$pending" "$RECOVER_ID" install "$operation_mode" || return 1 + RECOVER_ID="" + elif [ -e "$pending" ] || [ -L "$pending" ]; then + agmsg_install_op_pending_refuse "$pending" "$recovery_prefix" install "$operation_mode" + return 1 + fi + + AGMSG_INSTALL_OP_MARKER="$pending" + agmsg_install_op_pending_begin "$pending" install "$SKILL_DIR" "" "$operation_mode" || return 1 + + if [ "${UPDATE_ONLY:-false}" = true ] && [ ! -f "$SKILL_DIR/.agmsg" ]; then + echo " ! the selected installation was removed before the update could acquire its lock" >&2 + return 1 + fi + + # Validate the prior generation before making any installation change. A + # first install is generation 1 only when neither completion file exists. + if ! AGMSG_INSTALL_GEN="$(agmsg_install_manifest_next_gen "$manifest")"; then + return 1 + fi + agmsg_install_op_require || return 1 + AGMSG_INSTALL_ID="$(agmsg_install_db_ensure_meta "$install_db")" || return 1 + agmsg_install_op_require || return 1 + if [ -z "$AGMSG_INSTALL_ID" ]; then + echo " ! could not read or create install.db's meta row" >&2 + return 1 + fi + agmsg_install_op_require || return 1 + if ! agmsg_install_manifest_rotate_prev "$manifest"; then + echo " ! could not move the previous completion record aside" >&2 + return 1 + fi + agmsg_install_op_require || return 1 + AGMSG_INSTALL_MANIFEST="$manifest" + return 0 +} + +agmsg_install_operation_finish() { + if [ -z "${AGMSG_INSTALL_BOOTSTRAP_VERSION:-}" ]; then + echo " ! the installed bootstrap version was not verified; refusing to write a completion record" >&2 + return 1 + fi + agmsg_install_op_require || return 1 + if ! agmsg_install_manifest_write \ + "$SKILL_DIR/scripts" "$AGMSG_INSTALL_MANIFEST" "$INSTALLED_VERSION" \ + "$AGMSG_INSTALL_ID" "$AGMSG_INSTALL_GEN" "$AGMSG_INSTALL_BOOTSTRAP_VERSION"; then + echo " ! could not write the new completion record; the previous install stays in place (see .prev)" >&2 + return 1 + fi + agmsg_install_op_require || return 1 + agmsg_install_op_pending_complete "$AGMSG_INSTALL_OP_MARKER" "$AGMSG_INSTALL_OP_ID" || { + echo " ! could not clear the completed-operation record; later changes are blocked pending recovery" >&2 + return 1 + } + trap - EXIT + trap - INT TERM + agmsg_install_op_unlock + AGMSG_INSTALL_OP_ACTIVE=false + unset AGMSG_INSTALL_ID AGMSG_INSTALL_GEN AGMSG_INSTALL_MANIFEST AGMSG_INSTALL_BOOTSTRAP_VERSION AGMSG_INSTALL_OP_ID AGMSG_INSTALL_OP_MARKER + return 0 +} + +# The scripts/ copy runs inside the install transaction opened above. It does +# not acquire or release the lock, and it does not publish the completion +# manifest; the caller does that only after every install-side write is done. +# +# Requires (globals the caller must already have set): SCRIPT_DIR, SKILL_DIR, +# TRASH_DIR (agmsg_reset_trash already run on it), INSTALLED_VERSION, plus an +# active transaction from agmsg_install_operation_begin. +# Updates AGMSG_TRASH_COUNT via the existing backup/prune helpers, same as +# before this existed. +# +# Lock ordering (2026-09-29 design decision): registry lock -> this +# install operation lock -> team store lock, never the reverse. This +# function never acquires a registry or team-store lock itself, so it +# cannot invert that order on its own; it is documented here as the +# constraint any future caller that nests this inside one of those must not +# break. +agmsg_install_copy_scripts() { + local stage_dir="" + local daemon_files="agmsgd agmsgd-launch.sh" + local f + + # Exclude the two rename-placed files from the bulk copy by giving cp -R a + # PRIVATE STAGING COPY of scripts/ with those two removed, rather than + # temporarily removing them from $SCRIPT_DIR itself. The first version of + # this function did the latter -- hid them from $SCRIPT_DIR, restored them via + # an EXIT trap -- and it was genuinely unsafe: $SCRIPT_DIR is the same, + # SHARED, live checkout every install.sh invocation reads from, so a + # second install run against it (even sequentially, one finishing before + # the next starts) could observe the files gone if anything landed between + # the hide and the restore. Measured: a two-install-in-a-row test hit + # exactly this and failed to place scripts/daemon/agmsgd. The staging copy + # never touches $SCRIPT_DIR at all, so there is nothing to race. + # + # agmsg_stage_overwrite_backups / agmsg_prune_removed_scripts still read + # the REAL $SCRIPT_DIR/scripts (not the staging copy) for "what does this + # release ship" -- they only ever READ that tree, never write it, and the + # two daemon files genuinely ARE shipped (just placed a different way), so + # excluding them from THAT membership list would wrongly trash a prior + # install's copies of them. + # Cleaned up explicitly at each exit point below, not via a RETURN trap: + # a RETURN trap set inside this function does NOT clear itself when the + # function returns -- it stays armed for the rest of the calling script + # and fires again on the NEXT function return or `source` anywhere later, + # by which point stage_dir (a local of THIS call) reads as empty in that + # unrelated context. Measured directly: a trap set this way fired a + # second time after an unrelated `source` command completed, well after + # this function had already returned. + stage_dir="$(mktemp -d)" || return 1 + + if ! cp -R "$SCRIPT_DIR/scripts/." "$stage_dir/"; then + rm -rf "$stage_dir" 2>/dev/null + echo " ! could not stage scripts/ for copying; the previous install stays in place (see .prev)" >&2 + return 1 + fi + for f in $daemon_files; do + rm -f "$stage_dir/daemon/$f" + done + + local copy_rc=0 + # Backups and the staged copy are one scripts-copy phase. The following + # prune phase rechecks the lock before it can remove anything from the target. + agmsg_install_op_phase_begin || copy_rc=1 + if [ "$copy_rc" -eq 0 ]; then + agmsg_stage_overwrite_backups "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" || copy_rc=1 + fi + if [ "$copy_rc" -eq 0 ]; then + agmsg_install_op_run_writer cp -R "$stage_dir/." "$SKILL_DIR/scripts/" || copy_rc=1 + fi + agmsg_install_op_phase_end + if [ "$copy_rc" -eq 0 ]; then + agmsg_install_op_run_phase agmsg_prune_removed_scripts \ + "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" || copy_rc=1 + fi + rm -rf "$stage_dir" 2>/dev/null + + if [ "$copy_rc" -ne 0 ]; then + echo " ! scripts/ copy failed; the previous install stays in place (see .prev)" >&2 + return 1 + fi + + agmsg_install_op_phase_begin || return 1 + mkdir -p "$SKILL_DIR/scripts/daemon" || return 1 + for f in $daemon_files; do + if ! agmsg_install_place_daemon_file \ + "$SCRIPT_DIR/scripts/daemon/$f" "$SKILL_DIR/scripts/daemon/$f"; then + echo " ! could not place scripts/daemon/$f; the previous install stays in place (see .prev)" >&2 + return 1 + fi + done + agmsg_install_op_phase_end + + # agmsgd and agmsgd-launch.sh are a fixed-shape pair that must carry the + # SAME bootstrap version (agmsgd beta) -- a mismatch + # here means the two files this install just placed together are already + # inconsistent with each other, which the manifest must never assert as a + # single agreed value. Extracted by exact pattern from each file's own + # embedded constant (see their own headers for why the pattern is pinned). + local launch_bv entry_bv + launch_bv="$(grep -m1 '^BOOTSTRAP_VERSION=' "$SKILL_DIR/scripts/daemon/agmsgd-launch.sh" 2>/dev/null | cut -d= -f2)" + entry_bv="$(grep -m1 '^const BOOTSTRAP_VERSION = ' "$SKILL_DIR/scripts/daemon/agmsgd" 2>/dev/null | sed 's/^const BOOTSTRAP_VERSION = \([0-9]*\);*$/\1/')" + if [ -z "$launch_bv" ] || [ -z "$entry_bv" ] || [ "$launch_bv" != "$entry_bv" ]; then + echo " ! agmsgd and agmsgd-launch.sh disagree on (or are missing) BOOTSTRAP_VERSION" >&2 + return 1 + fi + AGMSG_INSTALL_BOOTSTRAP_VERSION="$launch_bv" + return 0 +} + +agmsg_install_optional_copy() { + cp "$@" 2>/dev/null || true +} + +agmsg_install_optional_uninstaller_copy() { + cp "$SCRIPT_DIR/uninstall.sh" "$SKILL_DIR/uninstall.sh" 2>/dev/null && \ + chmod +x "$SKILL_DIR/uninstall.sh" 2>/dev/null || true +} + +agmsg_install_optional_codex_chmod() { + chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true +} + +agmsg_install_refresh_codex_shim() { + AGMSG_CODEX_SHIM_REFRESHED=false + if env AGMSG_CODEX_SHIM_INSTALL_QUIET=1 AGMSG_CODEX_SHIM_FORCE="${CODEX_SHIM_FORCE:-}" \ + "$CODEX_SHIM" install >/dev/null; then + AGMSG_CODEX_SHIM_REFRESHED=true + fi + return 0 +} + +agmsg_install_write_version() { + printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" +} + +agmsg_install_touch_marker_and_write_version() { + touch "$SKILL_DIR/.agmsg" || return 1 + agmsg_install_write_version +} + +agmsg_remove_retired_terminal_skills() { + local driver + for driver in herdr plain tmux; do + rm -f "$SKILL_DIR/scripts/drivers/terminals/$driver/SKILL.md" + done +} + +agmsg_install_place_daemon_file() { + agmsg_atomic_place_file "$1" "$2" || return 1 + chmod +x "$2" 2>/dev/null || true } # Put at , then remove any leftover . The arm is chosen by @@ -264,11 +558,13 @@ agmsg_reset_trash() { # POSIX tools), but the exposure is confined to symlink users, whose target # is typically a version-controlled dotfile. move_into_place() { + agmsg_install_op_require || return 1 if [ -L "$2" ]; then cat "$1" > "$2" && rm -f "$1" else mv "$1" "$2" fi + agmsg_install_op_require } # Adds this install's writable_paths (below) to ONE Codex config.toml. @@ -296,7 +592,9 @@ _configure_codex_sandbox_file() { return 0 fi + agmsg_install_op_require || return 1 cp "$code_config" "$code_config.bak" + agmsg_install_op_require || return 1 echo " ~ backed up $code_config → $code_config.bak" local entries inserts @@ -309,6 +607,7 @@ _configure_codex_sandbox_file() { # uniformly valid TOML for empty ([]), single-line and multiline arrays — # trailing commas are legal — and avoids the leading/double-comma corruption # that munging the closing ']' produced for an empty array (`[, "x"]`). + agmsg_install_op_require || return 1 awk -v ins="$inserts" ' !done && /writable_roots[[:space:]]*=[[:space:]]*\[/ { sub(/\[/, "[" ins) @@ -318,18 +617,23 @@ _configure_codex_sandbox_file() { ' "$code_config" > "$code_config.tmp" && move_into_place "$code_config.tmp" "$code_config" elif grep -q '^\[sandbox_workspace_write\]' "$code_config" 2>/dev/null; then # Section exists but no writable_roots + agmsg_install_op_require || return 1 awk -v entries="$entries" ' { print } /^\[sandbox_workspace_write\]/ { print "writable_roots = [" entries "]" } ' "$code_config" > "$code_config.tmp" && move_into_place "$code_config.tmp" "$code_config" else # No section at all + agmsg_install_op_require || return 1 printf '\n[sandbox_workspace_write]\nwritable_roots = [%s]\n' "$entries" >> "$code_config" + agmsg_install_op_require || return 1 fi + agmsg_install_op_require || return 1 echo " + added Codex writable_roots for db/, teams/, run/, and ext-tools/ ($code_config)" } configure_codex_sandbox() { + agmsg_install_op_require || return 1 # --- Configure Codex sandbox (if Codex is installed) --- # The Codex bridge writes pidfiles/sockets/request files under the # skill's db/, teams/, run/ dirs; Codex's sandbox blocks those writes unless @@ -381,6 +685,7 @@ configure_codex_sandbox() { for cfg in "${codex_configs[@]}"; do _configure_codex_sandbox_file "$cfg" "${writable_paths[@]}" done + agmsg_install_op_require } is_windows_host() { @@ -395,26 +700,36 @@ is_windows_host() { } install_windows_helpers() { + agmsg_install_op_require || return 1 if ! is_windows_host; then return 0 fi + agmsg_install_op_require || return 1 mkdir -p "$AGENTS_DIR" # Clean up legacy helpers created by the earlier native-Windows approaches. local ps_shortcut="$AGENTS_DIR/$CMD_NAME.ps1" if [ -f "$ps_shortcut" ] && grep -q "PowerShell shortcut for agmsg on native Windows" "$ps_shortcut" 2>/dev/null; then + agmsg_install_op_require || return 1 rm -f "$ps_shortcut" + agmsg_install_op_require || return 1 fi + agmsg_install_op_require || return 1 rm -f "$AGENTS_DIR/$CMD_NAME-run.sh" + agmsg_install_op_require || return 1 local sqlite_shim="$AGENTS_DIR/bin/sqlite3" local removed_sqlite_shim=false if [ -f "$sqlite_shim" ] && grep -q "sqlite3 compatibility shim for agmsg" "$sqlite_shim" 2>/dev/null; then + agmsg_install_op_require || return 1 rm -f "$sqlite_shim" + agmsg_install_op_require || return 1 removed_sqlite_shim=true fi if [ "$removed_sqlite_shim" = true ]; then + agmsg_install_op_require || return 1 rm -f "$AGENTS_DIR/run/sqlite3-shim.cache" + agmsg_install_op_require || return 1 fi } @@ -425,7 +740,9 @@ install_antigravity_tui_shim() { target_dir="$(dirname "$target")" owner="# agmsg-shim-owner: $source" expected_owner="" + agmsg_install_op_require || return 1 mkdir -p "$target_dir" + agmsg_install_op_require || return 1 if [ -e "$target" ] || [ -L "$target" ]; then expected_owner="$(grep '^# agmsg-shim-owner: ' "$target" 2>/dev/null || true)" if ! grep -q '^# agmsg Antigravity TUI launcher shim$' "$target" 2>/dev/null; then @@ -447,7 +764,9 @@ install_antigravity_tui_shim() { printf 'exec bash %s "$@"\n' "$quoted_source" } > "$tmp" chmod +x "$tmp" + agmsg_install_op_require || { rm -f "$tmp"; return 1; } mv "$tmp" "$target" + agmsg_install_op_require || return 1 if [ -n "$expected_owner" ]; then echo " + refreshed Antigravity TUI shim (~/.agents/bin/agy-tui)" else @@ -456,6 +775,7 @@ install_antigravity_tui_shim() { } install_antigravity_skill() { + agmsg_install_op_require || return 1 # Antigravity looks for global skills under ~/.gemini/config/skills, not the # cross-vendor ~/.agents/skills tree. Treat either of the installed agy # markers as evidence that this destination is available; the config tree @@ -464,8 +784,11 @@ install_antigravity_skill() { return 0 fi local skill_dir="$HOME/.gemini/config/skills/$CMD_NAME" + agmsg_install_op_require || return 1 mkdir -p "$skill_dir" + agmsg_install_op_require || return 1 agmsg_render_skill antigravity "$CMD_NAME" "$skill_dir/SKILL.md" + agmsg_install_op_require || return 1 echo " + installed /$CMD_NAME skill to ~/.gemini/config/skills/" } @@ -473,6 +796,7 @@ install_antigravity_skill() { while [[ $# -gt 0 ]]; do case "$1" in --cmd) CMD_NAME="$2"; INTERACTIVE=false; shift 2 ;; + --recover) RECOVER_ID="$2"; INTERACTIVE=false; shift 2 ;; --agent-type) AGENT_TYPE="$2"; shift 2 ;; --update) UPDATE_ONLY=true; shift ;; -h|--help) @@ -488,6 +812,7 @@ while [[ $# -gt 0 ]]; do echo " they have their own skill file." echo " ( matches the type arg passed to join.sh / whoami.sh)" echo " --update Update skill scripts only (preserve DB and teams)" + echo " --recover Clear a verified incomplete operation, then continue this install" echo "" echo "After install, join a team per-project:" echo " ~/.agents/skills//scripts/join.sh " @@ -498,6 +823,11 @@ while [[ $# -gt 0 ]]; do esac done +if [ -n "$RECOVER_ID" ] && [ -z "$CMD_NAME" ]; then + echo " ! --recover requires --cmd to select one installation" >&2 + exit 1 +fi + # Force non-interactive when stdin is not a terminal. Without this, the # command-name prompt below would call `read -r` on whatever stream is wired # to fd 0 — which for `curl ... | bash`-style entry paths (e.g. the npm @@ -578,6 +908,9 @@ if [ "$UPDATE_ONLY" = true ]; then SKILL_NAME="$(basename "$SKILL_DIR")" CMD_NAME="$SKILL_NAME" echo " Updating $SKILL_NAME..." + INSTALLED_VERSION="$(agmsg_source_version)" + agmsg_install_operation_begin || exit 1 + agmsg_install_op_run_phase agmsg_install_write_recovery_helper || exit 1 # #963: a sync engine that is running when the write below starts either # survives on the code it already loaded (silent -- `remote.sh status` still # reports it as running, and nothing about the new scripts takes effect) or @@ -649,16 +982,16 @@ $_agmsg_running_team" ;; esac unset _agmsg_explicit_agent_type _agmsg_detected_type - agmsg_render_skill "$TPL_TYPE" "$SKILL_NAME" "$SKILL_DIR/SKILL.md" + agmsg_install_op_run_phase agmsg_render_skill "$TPL_TYPE" "$SKILL_NAME" "$SKILL_DIR/SKILL.md" || exit 1 TRASH_DIR="$SKILL_DIR/.trash" AGMSG_TRASH_COUNT=0 - agmsg_reset_trash "$TRASH_DIR" - agmsg_stage_overwrite_backups "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" + agmsg_install_op_run_phase agmsg_reset_trash "$TRASH_DIR" || exit 1 # Recursive copy so nested helper dirs (scripts/lib/, scripts/drivers/types/) # ship without enumerating files. The agent-type manifests and per-type runtimes # live under scripts/drivers/types/ now, so this single copy carries them too. - cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" - agmsg_prune_removed_scripts "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" + # Runs under the install operation lock. The completion manifest is written + # only after the remaining install updates below are complete. + agmsg_install_copy_scripts || exit 1 echo " ~ $AGMSG_TRASH_COUNT file(s) backed up to .trash/ (cleared on next upgrade)" # #1249: drivers/terminals/{herdr,plain,tmux}/SKILL.md used to name each # driver's own doc file, and a directory-scanning skill loader (e.g. @@ -672,24 +1005,24 @@ $_agmsg_running_team" # scripts/drivers/terminals/ (nothing about that path is exclusive to # agmsg's own three); a glob there would delete a file this install # does not own (#1249 review). - for _agmsg_builtin_driver in herdr plain tmux; do - rm -f "$SKILL_DIR/scripts/drivers/terminals/$_agmsg_builtin_driver/SKILL.md" - done - unset _agmsg_builtin_driver + agmsg_install_op_run_phase agmsg_remove_retired_terminal_skills || exit 1 # Ship the external-plugin drop-in dir (just its README) so the location exists # post-install. A plain cp — not cp -R --delete — preserves any plugins the # user dropped in and their db/trusted-plugins opt-ins. - mkdir -p "$SKILL_DIR/plugins" - cp "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" 2>/dev/null || true + agmsg_install_op_phase_begin || exit 1 + mkdir -p "$SKILL_DIR/plugins" || exit 1 + agmsg_install_optional_copy "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" # Ship uninstall.sh alongside the skill itself — npx/curl installs fetch a # temp checkout that gets deleted right after install, so without this copy # those users would have no local uninstaller to run later (only a manual # `git clone` checkout would). See the README's Uninstall section. - cp "$SCRIPT_DIR/uninstall.sh" "$SKILL_DIR/uninstall.sh" 2>/dev/null && chmod +x "$SKILL_DIR/uninstall.sh" || true + agmsg_install_optional_uninstaller_copy + agmsg_install_op_phase_end || exit 1 # Refresh the Claude Code slash command file (was missed in earlier --update flows). + agmsg_install_op_phase_begin || exit 1 CC_COMMANDS_DIR="$HOME/.claude/commands" if [ -d "$CC_COMMANDS_DIR" ] && [ -f "$CC_COMMANDS_DIR/$SKILL_NAME.md" ]; then - agmsg_render_skill claude-code "$SKILL_NAME" "$CC_COMMANDS_DIR/$SKILL_NAME.md" + agmsg_render_skill claude-code "$SKILL_NAME" "$CC_COMMANDS_DIR/$SKILL_NAME.md" || exit 1 fi # Refresh / install the Copilot CLI skill (Copilot reads SKILL.md from its # own skills dir; the shared ~/.agents/skills//SKILL.md is @@ -698,29 +1031,31 @@ $_agmsg_running_team" # from a pre-Copilot release via --update also gain the skill. COPILOT_SKILL_DIR="$HOME/.copilot/skills/$SKILL_NAME" if [ -d "$HOME/.copilot" ]; then - mkdir -p "$COPILOT_SKILL_DIR" - agmsg_render_skill copilot "$SKILL_NAME" "$COPILOT_SKILL_DIR/SKILL.md" + mkdir -p "$COPILOT_SKILL_DIR" || exit 1 + agmsg_render_skill copilot "$SKILL_NAME" "$COPILOT_SKILL_DIR/SKILL.md" || exit 1 fi # Refresh / install the OpenCode skill (same reasoning as Copilot above). OPENCODE_SKILL_DIR="$HOME/.config/opencode/skills/$SKILL_NAME" if [ -d "$HOME/.config/opencode" ]; then - mkdir -p "$OPENCODE_SKILL_DIR" - agmsg_render_skill opencode "$SKILL_NAME" "$OPENCODE_SKILL_DIR/SKILL.md" + mkdir -p "$OPENCODE_SKILL_DIR" || exit 1 + agmsg_render_skill opencode "$SKILL_NAME" "$OPENCODE_SKILL_DIR/SKILL.md" || exit 1 fi # Refresh / install the Hermes Agent skill (same reasoning as Copilot above). HERMES_SKILL_DIR="$HOME/.hermes/skills/$SKILL_NAME" if [ -d "$HOME/.hermes" ]; then - mkdir -p "$HERMES_SKILL_DIR" - agmsg_render_skill hermes "$SKILL_NAME" "$HERMES_SKILL_DIR/SKILL.md" + mkdir -p "$HERMES_SKILL_DIR" || exit 1 + agmsg_render_skill hermes "$SKILL_NAME" "$HERMES_SKILL_DIR/SKILL.md" || exit 1 fi # Refresh / install the Grok Build skill (same reasoning as Copilot above). GROK_SKILL_DIR="$HOME/.grok/skills/$SKILL_NAME" if [ -d "$HOME/.grok" ]; then - mkdir -p "$GROK_SKILL_DIR" - agmsg_render_skill grok-build "$SKILL_NAME" "$GROK_SKILL_DIR/SKILL.md" + mkdir -p "$GROK_SKILL_DIR" || exit 1 + agmsg_render_skill grok-build "$SKILL_NAME" "$GROK_SKILL_DIR/SKILL.md" || exit 1 fi - install_antigravity_skill - cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true + install_antigravity_skill || exit 1 + agmsg_install_op_phase_end || exit 1 + agmsg_install_op_phase_begin || exit 1 + agmsg_install_optional_copy "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" # A team config written by an older release can be group- or world-writable, # and the sync engine refuses to read one that is (#804). Upgrading does not # rewrite files that already exist, so without this the release we are asking @@ -791,9 +1126,9 @@ $_agmsg_running_team" done || true unset -f agmsg_shq fi - chmod +x "$SKILL_DIR/scripts/"*.sh - chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true - install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" + chmod +x "$SKILL_DIR/scripts/"*.sh || exit 1 + agmsg_install_optional_codex_chmod + install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" || exit 1 # Refresh the Codex monitor shim (~/.agents/bin/codex) if it's ours. --update # cp's the new codex-shim-install.sh but does not re-run it, so a shim from an # older install keeps its stale baked exec path after the @@ -833,16 +1168,17 @@ $_agmsg_running_team" if printf '%s' "$CODEX_SHIM_STATUS" | grep -q '^installed:'; then CODEX_SHIM_FORCE="" [ "$CMD_WAS_EXPLICIT" = true ] && CODEX_SHIM_FORCE=1 - if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 AGMSG_CODEX_SHIM_FORCE="$CODEX_SHIM_FORCE" "$CODEX_SHIM" install >/dev/null; then + agmsg_install_refresh_codex_shim || exit 1 + if [ "$AGMSG_CODEX_SHIM_REFRESHED" = true ]; then echo " + refreshed Codex monitor shim (~/.agents/bin/codex)" fi fi - install_windows_helpers - INSTALLED_VERSION="$(agmsg_source_version)" - printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" + install_windows_helpers || exit 1 + agmsg_install_op_phase_end || exit 1 + agmsg_install_op_run_phase agmsg_install_write_version || exit 1 echo " + updated scripts, templates, and SKILL.md (version $INSTALLED_VERSION)" echo " ~ DB and team configs preserved" - configure_codex_sandbox + agmsg_install_op_run_phase configure_codex_sandbox || exit 1 echo "" echo " ! Restart any running agent sessions to pick up the updated scripts." echo " In-flight watch.sh processes detect this and stand down on their own;" @@ -853,7 +1189,7 @@ $_agmsg_running_team" if [ -n "$AGMSG_RUNNING_TEAMS" ]; then while IFS= read -r _agmsg_team; do [ -n "$_agmsg_team" ] || continue - if ! "$SKILL_DIR/scripts/remote.sh" sync restart "$_agmsg_team"; then + if ! agmsg_install_op_run_phase "$SKILL_DIR/scripts/remote.sh" sync restart "$_agmsg_team"; then echo " ! could not restart the sync engine for '$_agmsg_team'; run:" >&2 echo " bash $SKILL_DIR/scripts/remote.sh sync restart $_agmsg_team" >&2 fi @@ -872,6 +1208,7 @@ $_agmsg_running_team" echo " a project's settings, silently stopping delivery until it is re-registered." echo " Check with 'delivery.sh status '. (#133)" echo "" + agmsg_install_operation_finish || exit 1 echo " ✓ Update complete" echo "" exit 0 @@ -889,10 +1226,13 @@ fi # --- Apply defaults --- CMD_NAME="${CMD_NAME:-agmsg}" SKILL_DIR="$AGENTS_DIR/skills/$CMD_NAME" +INSTALLED_VERSION="$(agmsg_source_version)" +agmsg_install_operation_begin || exit 1 +agmsg_install_op_run_phase agmsg_install_write_recovery_helper || exit 1 # --- Install skill --- echo " Installing to ~/.agents/skills/$CMD_NAME/ ..." -mkdir -p "$SKILL_DIR"/{scripts,types,db,agents} +agmsg_install_op_run_phase mkdir -p "$SKILL_DIR"/{scripts,types,db,agents} || exit 1 # SKILL.md is composed from the shared root and the agent-specific overlay # resolved from the type manifest (scripts/drivers/types//template.md). @@ -915,32 +1255,33 @@ case " $AGMSG_TYPES_WITH_OWN_SKILL_FILE " in esac ;; esac -agmsg_render_skill "$TPL_TYPE" "$CMD_NAME" "$SKILL_DIR/SKILL.md" +agmsg_install_op_run_phase agmsg_render_skill "$TPL_TYPE" "$CMD_NAME" "$SKILL_DIR/SKILL.md" || exit 1 TRASH_DIR="$SKILL_DIR/.trash" AGMSG_TRASH_COUNT=0 -agmsg_reset_trash "$TRASH_DIR" -agmsg_stage_overwrite_backups "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" +agmsg_install_op_run_phase agmsg_reset_trash "$TRASH_DIR" || exit 1 # Recursive copy so nested helper dirs (scripts/lib/, scripts/drivers/types/) ship # without enumerating files. The agent-type manifests and per-type runtimes live # under scripts/drivers/types/ now, so this single copy carries them too. -cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" -agmsg_prune_removed_scripts "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" +# Runs under the install operation lock. The completion manifest is written +# only after the remaining install updates below are complete. +agmsg_install_copy_scripts || exit 1 echo " ~ $AGMSG_TRASH_COUNT file(s) backed up to .trash/ (cleared on next upgrade)" # Ship the external-plugin drop-in dir (just its README) so the location exists # post-install. A plain cp — not cp -R --delete — preserves any plugins the user # dropped in and their db/trusted-plugins opt-ins. -mkdir -p "$SKILL_DIR/plugins" -cp "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" 2>/dev/null || true +agmsg_install_op_phase_begin || exit 1 +mkdir -p "$SKILL_DIR/plugins" || exit 1 +agmsg_install_optional_copy "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" # Ship uninstall.sh alongside the skill itself — npx/curl installs fetch a # temp checkout that gets deleted right after install, so without this copy # those users would have no local uninstaller to run later (only a manual # `git clone` checkout would). See the README's Uninstall section. -cp "$SCRIPT_DIR/uninstall.sh" "$SKILL_DIR/uninstall.sh" 2>/dev/null && chmod +x "$SKILL_DIR/uninstall.sh" || true +agmsg_install_optional_uninstaller_copy -cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true -chmod +x "$SKILL_DIR/scripts/"*.sh -chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true -install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" +agmsg_install_optional_copy "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" +chmod +x "$SKILL_DIR/scripts/"*.sh || exit 1 +agmsg_install_optional_codex_chmod +install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" || exit 1 # Re-point an existing Codex monitor shim at the new path on a reinstall over an # older layout (no-op when no agmsg shim is present). See the --update block # above. NOT forced (#553): unlike --update, a fresh install here gives no @@ -957,22 +1298,22 @@ if printf '%s' "$CODEX_SHIM_STATUS" | grep -q '^installed:'; then # current owner and the exact consequence of forcing -- repeating a # shorter, separate version of that here would risk saying something # different from what actually happens. - if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" install >/dev/null; then + CODEX_SHIM_FORCE="" + agmsg_install_refresh_codex_shim || exit 1 + if [ "$AGMSG_CODEX_SHIM_REFRESHED" = true ]; then echo " + refreshed Codex monitor shim (~/.agents/bin/codex)" fi fi -install_windows_helpers +install_windows_helpers || exit 1 +agmsg_install_op_phase_end || exit 1 -# Marker file for uninstall detection -touch "$SKILL_DIR/.agmsg" - -# Record the provenance version of the source we installed from (see #117). -INSTALLED_VERSION="$(agmsg_source_version)" -printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" +# Marker file for uninstall detection and source provenance version are one +# protected phase so neither write can occur after a lost-lock boundary. +agmsg_install_op_run_phase agmsg_install_touch_marker_and_write_version || exit 1 # Initialize DB if [ ! -f "$SKILL_DIR/db/messages.db" ]; then - bash "$SKILL_DIR/scripts/internal/init-db.sh" + agmsg_install_op_run_phase bash "$SKILL_DIR/scripts/internal/init-db.sh" || exit 1 fi # Nothing moves stores here. Installing must not change where a team's messages @@ -983,15 +1324,16 @@ fi # Initialize config if [ ! -f "$SKILL_DIR/db/config.yaml" ]; then - bash "$SKILL_DIR/scripts/config.sh" show >/dev/null + agmsg_install_op_run_phase bash "$SKILL_DIR/scripts/config.sh" show >/dev/null || exit 1 echo " + created default config at db/config.yaml" fi # --- Install Claude Code global command --- +agmsg_install_op_phase_begin || exit 1 CC_COMMANDS_DIR="$HOME/.claude/commands" if [ -d "$HOME/.claude" ]; then - mkdir -p "$CC_COMMANDS_DIR" - agmsg_render_skill claude-code "$CMD_NAME" "$CC_COMMANDS_DIR/$CMD_NAME.md" + mkdir -p "$CC_COMMANDS_DIR" || exit 1 + agmsg_render_skill claude-code "$CMD_NAME" "$CC_COMMANDS_DIR/$CMD_NAME.md" || exit 1 echo " + installed /$CMD_NAME command to ~/.claude/commands/" fi @@ -1001,8 +1343,8 @@ fi # would mis-identify a Copilot session — keep the Copilot copy separate. COPILOT_SKILL_DIR="$HOME/.copilot/skills/$CMD_NAME" if [ -d "$HOME/.copilot" ]; then - mkdir -p "$COPILOT_SKILL_DIR" - agmsg_render_skill copilot "$CMD_NAME" "$COPILOT_SKILL_DIR/SKILL.md" + mkdir -p "$COPILOT_SKILL_DIR" || exit 1 + agmsg_render_skill copilot "$CMD_NAME" "$COPILOT_SKILL_DIR/SKILL.md" || exit 1 echo " + installed /$CMD_NAME skill to ~/.copilot/skills/" fi @@ -1013,8 +1355,8 @@ fi # copy separate, same pattern as Copilot. OPENCODE_SKILL_DIR="$HOME/.config/opencode/skills/$CMD_NAME" if [ -d "$HOME/.config/opencode" ]; then - mkdir -p "$OPENCODE_SKILL_DIR" - agmsg_render_skill opencode "$CMD_NAME" "$OPENCODE_SKILL_DIR/SKILL.md" + mkdir -p "$OPENCODE_SKILL_DIR" || exit 1 + agmsg_render_skill opencode "$CMD_NAME" "$OPENCODE_SKILL_DIR/SKILL.md" || exit 1 echo " + installed \$$CMD_NAME skill to ~/.config/opencode/skills/" fi @@ -1025,8 +1367,8 @@ fi # (manual inbox checks only), but the skill itself installs the same way. HERMES_SKILL_DIR="$HOME/.hermes/skills/$CMD_NAME" if [ -d "$HOME/.hermes" ]; then - mkdir -p "$HERMES_SKILL_DIR" - agmsg_render_skill hermes "$CMD_NAME" "$HERMES_SKILL_DIR/SKILL.md" + mkdir -p "$HERMES_SKILL_DIR" || exit 1 + agmsg_render_skill hermes "$CMD_NAME" "$HERMES_SKILL_DIR/SKILL.md" || exit 1 echo " + installed /$CMD_NAME skill to ~/.hermes/skills/" fi @@ -1040,8 +1382,8 @@ fi # hooks_file; see grok-build/_delivery.sh). GROK_SKILL_DIR="$HOME/.grok/skills/$CMD_NAME" if [ -d "$HOME/.grok" ]; then - mkdir -p "$GROK_SKILL_DIR" - agmsg_render_skill grok-build "$CMD_NAME" "$GROK_SKILL_DIR/SKILL.md" + mkdir -p "$GROK_SKILL_DIR" || exit 1 + agmsg_render_skill grok-build "$CMD_NAME" "$GROK_SKILL_DIR/SKILL.md" || exit 1 echo " + installed /$CMD_NAME skill to ~/.grok/skills/" fi @@ -1049,7 +1391,8 @@ fi # Antigravity (agy) reads global skills from ~/.gemini/config/skills//. # Its CLI may create ~/.gemini/antigravity-cli before the config directory, so # either path is a sufficient installation signal. -install_antigravity_skill +install_antigravity_skill || exit 1 +agmsg_install_op_phase_end || exit 1 # Codex sandbox writable_roots are configured by configure_codex_sandbox() at # the "Done" step below — the single source of truth for db/, teams/, and run/. @@ -1057,7 +1400,10 @@ install_antigravity_skill # produced invalid TOML on a fresh install; it has been removed.) # --- Done --- -configure_codex_sandbox +agmsg_install_op_phase_begin || exit 1 +configure_codex_sandbox || exit 1 +agmsg_install_op_phase_end || exit 1 +agmsg_install_operation_finish || exit 1 echo "" echo " ✓ Installed to ~/.agents/skills/$CMD_NAME/ (version $INSTALLED_VERSION)" echo "" diff --git a/scripts/daemon/agmsgd b/scripts/daemon/agmsgd index dc8ab5673..9b556e632 100755 --- a/scripts/daemon/agmsgd +++ b/scripts/daemon/agmsgd @@ -58,6 +58,7 @@ async function main() { const expectedOpGen = Number(opGenStr); const lockPath = join(installRoot, "run", "install-op.lock.db"); + const incompleteOperationPath = join(installRoot, "run", "install-op-incomplete.json"); const manifestPath = join(installRoot, "run", "install-manifest.json"); const dbPath = join(installRoot, "run", "install.db"); @@ -66,6 +67,10 @@ async function main() { let manifest; let mainModule; try { + if (existsSync(incompleteOperationPath)) { + fail(75, "an install or uninstall operation is incomplete; recover it before starting agmsgd"); + } + // 1. An install generation exists at all. if (!existsSync(manifestPath)) { fail(0, "no completion record -- install.sh needs to run again"); diff --git a/scripts/drivers/types/codex/codex-record-session.sh b/scripts/drivers/types/codex/codex-record-session.sh index 41e29d0b4..843dce0f4 100755 --- a/scripts/drivers/types/codex/codex-record-session.sh +++ b/scripts/drivers/types/codex/codex-record-session.sh @@ -5,9 +5,8 @@ # otherwise send-side only and never runs actas-claim, so without this a codex # role would have no role-session record and could never be resumed (spawn would # always boot it fresh). This is the codex-side equivalent: the codex actas flow -# calls it, and it writes the thread plus the effective CODEX_HOME so a later -# spawn/resume brings the role back into its thread and profile. The /clear -# recovery in self-fix.sh calls this same script after #1470 rebinds the seat. +# calls it, and it writes the record so a later spawn/resume brings the role back +# into its thread. # # Usage: codex-record-session.sh [project] # @@ -161,28 +160,6 @@ if [ "$probe_ran" = "1" ]; then [ -n "$thread" ] || exit 0 fi -# Resolve the effective profile before either fallback can infer a thread. The -# rollout index belongs to CODEX_HOME, not necessarily to the process HOME. -# Codex defaults to $HOME/.codex when CODEX_HOME is unset; resolve either -# spelling to a physical absolute path so discovery and the stored destination -# use the same profile. A missing or malformed directory is not safe to publish -# as a delivery destination, so leave the previous record untouched. -codex_home="${CODEX_HOME:-}" -if [ -z "$codex_home" ]; then - [ -n "${HOME:-}" ] || exit 0 - codex_home="$HOME/.codex" -fi -case "$codex_home" in *[[:cntrl:]]*) exit 0 ;; esac -[ -d "$codex_home" ] || exit 0 -codex_home="$(agmsg_canonical_path "$codex_home")" -# Keep the absolute path in the cross-platform form used by Node consumers; -# Git Bash's physical /c/... spelling is normalized to C:/... on Windows. -codex_home="$(agmsg_normalize_project_path "$codex_home")" -case "$codex_home" in - /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; - *) exit 0 ;; -esac - if [ -z "$thread" ]; then # No app-server to ask, or it could not be reached -- a codex session outside # monitor mode, a missing Node, a server that is not answering. The rollout scan @@ -190,8 +167,10 @@ if [ -z "$thread" ]; then # single-rollout case it always did, and on a project with history it records # nothing, which is what happens today. # - sessions_dir="$codex_home/sessions" - if [ -d "$sessions_dir" ]; then + # ${HOME:-} so an unset HOME under `set -u` is a silent no-op (empty -> the + # dir check below fails -> fresh), not an unbound-variable abort (nit). + sessions_dir="${HOME:-}/.codex/sessions" + if [ -n "${HOME:-}" ] && [ -d "$sessions_dir" ]; then # Distinct thread ids whose session_meta cwd (canonicalized -- codex records # the physical cwd while agmsg may hold a symlinked path, #160) matches the # project, among the most recent rollouts. Exactly one => unambiguously ours. @@ -233,7 +212,7 @@ fi # as-is. The project is recorded in its canonical (physical) form so records # carry one path spelling regardless of how the caller spelled the argument. agmsg_role_session_load "$TEAM" "$AGENT" 2>/dev/null || true -agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" "$codex_home" || true +agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" || true # The Codex actas flow reaches this script instead of actas-claim.sh. Publish # the same seat request here so a resumed seat's dispatcher has an authority diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 631887950..2877f0382 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -18,7 +18,7 @@ Do not use POSIX `'"'"'` quote splicing in PowerShell, and do not use escaped do If argument starts with "actas" followed by an agent name: 1. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" __AGENT_TYPE__`. If `` is not listed, join with `~/.agents/skills/__SKILL_NAME__/scripts/join.sh __AGENT_TYPE__ "$(pwd)"`. -2. Record this Codex thread and its effective profile directory so a later spawn can resume it and route notices to the right profile: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. +2. Record the Codex thread so a later spawn can resume it: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. 3. Use the role as the active FROM; monitor delivery is routed only to its recorded thread. diff --git a/scripts/lib/install-db.sh b/scripts/lib/install-db.sh new file mode 100644 index 000000000..62d836be9 --- /dev/null +++ b/scripts/lib/install-db.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# install-db.sh — install.db's `meta` table. +# +# The single source of truth for this install's install_id: a fresh UUID, +# minted once by install.sh the first time install.db's meta row is +# created, and never changed afterward -- until an uninstall removes +# install.db outright, at which point the next install starts a genuinely +# new install_id. The completion manifest (install-manifest.sh) carries +# only a COPY of this value; it is never a second place that MINTS one +# (2026-09-29 design decision -- this replaced an earlier design where the +# manifest/.prev itself was the install_id's source). +# +# schema_version starts at 1 so a later migration has a value to gate a +# table-adding ALTER on. This script initially writes only +# `meta(schema_version, install_id)`; the Node/CLI-only columns +# (node_path, node_version) are added by whichever PR implements +# `enable`/`disable`. +# +# Required caller-set variable: none. Sources scripts/lib/compat.sh (for +# compat_uuid7) and scripts/lib/sqlite-output.sh (for normalized query output) +# if not already loaded. + +[ -n "${_AGMSG_INSTALL_DB_SH:-}" ] && return 0 +_AGMSG_INSTALL_DB_SH=1 + +if ! declare -F compat_uuid7 >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/compat.sh" +fi +if ! declare -F agmsg_sqlite_capture >/dev/null 2>&1; then + _agmsg_install_db_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + # shellcheck disable=SC1091 + . "$_agmsg_install_db_dir/sqlite-output.sh" + unset _agmsg_install_db_dir +fi + +# Ensures install.db exists with its meta row: creates the table if absent, +# and mints a fresh install_id (schema_version 1) only if the table has no +# row yet. An --update over an install.db that already has a meta row +# leaves its install_id exactly as it was -- this never overwrites an +# existing one. Prints the (possibly just-created) install_id on success; +# prints nothing and returns 1 on any sqlite failure. +agmsg_install_db_ensure_meta() { # + local db="$1" id + mkdir -p "$(dirname "$db")" 2>/dev/null || true + if ! sqlite3 "$db" \ + "CREATE TABLE IF NOT EXISTS meta (schema_version INTEGER NOT NULL, install_id TEXT NOT NULL);" \ + 2>/dev/null; then + return 1 + fi + id="$(agmsg_sqlite_capture "$db" "SELECT install_id FROM meta LIMIT 1;")" || return 1 + if [ -z "$id" ]; then + id="$(compat_uuid7)" + if ! sqlite3 "$db" \ + "INSERT INTO meta (schema_version, install_id) VALUES (1, '$id');" \ + 2>/dev/null; then + return 1 + fi + fi + printf '%s\n' "$id" +} diff --git a/scripts/lib/install-manifest.sh b/scripts/lib/install-manifest.sh new file mode 100644 index 000000000..f41afef88 --- /dev/null +++ b/scripts/lib/install-manifest.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +# install-manifest.sh — the install completion record (agmsgd beta). +# +# run/install-manifest.json names one completed install: which generation it +# is (install_id, gen), what version it shipped, and the path+sha256 digest +# of every file under scripts/ at the moment it was written. Its presence, +# absence, and .prev sibling are how a stale generation and a crashed +# mid-update are told apart -- see agmsg_install_manifest_next_gen. +# +# install_id itself is NOT this file's concern -- see install-db.sh. +# +# Required caller-set variable: none. Sources scripts/lib/sqlpath.sh (for +# agmsg_sql_readfile_path), scripts/lib/sqlite-output.sh (for normalized +# sqlite3 output), and scripts/lib/hash.sh (for agmsg_sha256) if not already +# loaded; all are small, dependency-light files, not the whole of storage.sh. + +[ -n "${_AGMSG_INSTALL_MANIFEST_SH:-}" ] && return 0 +_AGMSG_INSTALL_MANIFEST_SH=1 + +_agmsg_install_manifest_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +if ! declare -F agmsg_sql_readfile_path >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$_agmsg_install_manifest_dir/sqlpath.sh" +fi +if ! declare -F agmsg_sqlite_capture >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$_agmsg_install_manifest_dir/sqlite-output.sh" +fi +if ! declare -F agmsg_sha256 >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$_agmsg_install_manifest_dir/hash.sh" +fi +unset _agmsg_install_manifest_dir + +# Same '' -> '' doubling every other SQL-string-literal caller in this +# codebase uses (leave.sh's own _agmsg_sqlesc, storage.sh's agmsg_sqlesc). +# Defined locally rather than pulling in storage.sh, which this file has no +# other reason to depend on. +_agmsg_install_manifest_sqlesc() { + local value="${1:-}" quote="'" + printf '%s' "${value//$quote/$quote$quote}" +} + +# Hash the known-answer probe and a batch of files in one selected-tool +# invocation. This is deliberately local to the install manifest: the shared +# agmsg_sha256 API still self-tests each individual digest. File names are +# supplied by the package, but only batch them when every relative name and the +# temporary probe path use characters whose tool output is unambiguous. The +# caller falls back to agmsg_sha256 for any other name. +_agmsg_install_manifest_hash_batch() { # ... + local scripts_dir="${1:-}" tool="" kind="" probe="" output="" line digest count=0 + shift || return 1 + local -a file_args=() + [ -n "$scripts_dir" ] || return 1 + for line in "$@"; do + case "$line" in *[!A-Za-z0-9._/-]*) return 1 ;; esac + file_args+=("./$line") + done + + if command -v shasum >/dev/null 2>&1; then + tool="$(command -v shasum)" || return 1 + kind=shasum + elif command -v sha256sum >/dev/null 2>&1; then + tool="$(command -v sha256sum)" || return 1 + kind=sha256sum + elif command -v openssl >/dev/null 2>&1; then + tool="$(command -v openssl)" || return 1 + kind=openssl + else + echo "agmsg: no SHA-256 tool found on PATH (looked for shasum, sha256sum, openssl)." >&2 + return 1 + fi + + probe="$(mktemp)" || return 1 + case "$probe" in *[!A-Za-z0-9._/-]*) rm -f "$probe"; return 1 ;; esac + if ! printf '%s' probe > "$probe"; then + rm -f "$probe" + return 1 + fi + case "$kind" in + shasum) + output="$(cd "$scripts_dir" && "$tool" -a 256 - "${file_args[@]}" < "$probe")" || { + rm -f "$probe" + return 1 + } + ;; + sha256sum) + output="$(cd "$scripts_dir" && "$tool" - "${file_args[@]}" < "$probe")" || { + rm -f "$probe" + return 1 + } + ;; + openssl) + output="$(cd "$scripts_dir" && "$tool" dgst -sha256 - "${file_args[@]}" < "$probe")" || { + rm -f "$probe" + return 1 + } + ;; + esac + rm -f "$probe" + + while IFS= read -r line; do + line="${line%$'\r'}" + case "$kind" in + openssl) digest="${line##*= }" ;; + *) digest="${line%%[[:space:]]*}" ;; + esac + case "$digest" in ''|*[!0-9a-f]*) return 1 ;; esac + [ "${#digest}" -eq 64 ] || return 1 + if [ "$count" -eq 0 ]; then + [ "$digest" = 'ba9c736f19e7f60b7f6764adb0b7908c0a2b394e09b6c09863528c7f2bc86095' ] || { + echo "agmsg: the SHA-256 tool on PATH returned the wrong digest for the install-manifest probe." >&2 + return 1 + } + else + printf '%s\n' "$digest" + fi + count=$((count + 1)) + done <<< "$output" + [ "$count" -eq "$(( $# + 1 ))" ] || { + echo "agmsg: the SHA-256 tool returned an unexpected number of install-manifest digests." >&2 + return 1 + } +} + +# Prints the NEXT gen for the completion record install.sh is about to +# write: the current manifest's gen + 1, or a valid .prev's if the current +# manifest is absent or unreadable, or 1 only when neither file exists. +# +# install_id is not this function's concern: install.db's meta row is the +# source of truth, and the manifest only carries a copy of it. +# +# install.sh reads the current generation after acquiring the operation +# lock, then writes the next generation; it has no earlier observation that +# could have become stale while waiting for the lock. +_agmsg_install_manifest_read_gen() { # + local path="${1:-}" sqlpath gen + [ -f "$path" ] || return 1 + sqlpath="$(agmsg_sql_readfile_path "$path")" || return 1 + gen="$(agmsg_sqlite_capture :memory: "SELECT json_extract(CAST(readfile('$sqlpath') AS TEXT), '\$.gen');")" || return 1 + case "$gen" in + ''|*[!0-9]*) return 1 ;; + esac + # Keep the value inside Bash's portable signed arithmetic range. + [ "${#gen}" -le 18 ] || return 1 + [ "$gen" -gt 0 ] || return 1 + printf '%s\n' "$gen" +} + +agmsg_install_manifest_next_gen() { # + local manifest_path="${1:-}" prior_gen="" current_present=false prev_present=false + [ -n "$manifest_path" ] || return 1 + { [ -e "$manifest_path" ] || [ -L "$manifest_path" ]; } && current_present=true + { [ -e "$manifest_path.prev" ] || [ -L "$manifest_path.prev" ]; } && prev_present=true + + if [ "$current_present" = true ] && prior_gen="$(_agmsg_install_manifest_read_gen "$manifest_path")"; then + : + elif [ "$prev_present" = true ] && prior_gen="$(_agmsg_install_manifest_read_gen "$manifest_path.prev")"; then + : + elif [ "$current_present" = false ] && [ "$prev_present" = false ]; then + printf '1\n' + return 0 + else + echo "agmsg: install manifest and its previous copy are unreadable; refusing to reuse a generation" >&2 + return 1 + fi + + printf '%s\n' "$((prior_gen + 1))" +} + +# Renames the current manifest to its own .prev, so a half-done copy is +# never photographed as "complete" -- must run BEFORE the copy step, every +# time. A missing manifest (first install) is not an error: there is +# nothing to rotate. Overwrites any existing .prev -- an install that +# crashed twice in a row without ever completing in between only ever needs +# the most recent attempt's generation, which agmsg_install_manifest_next_gen +# already read before this runs. +agmsg_install_manifest_rotate_prev() { # + local manifest_path="${1:-}" + [ -n "$manifest_path" ] || return 1 + if [ -f "$manifest_path" ]; then + if _agmsg_install_manifest_read_gen "$manifest_path" >/dev/null; then + mv -f "$manifest_path" "$manifest_path.prev" + return $? + fi + elif [ ! -e "$manifest_path" ] && [ ! -L "$manifest_path" ]; then + [ -e "$manifest_path.prev" ] || [ -L "$manifest_path.prev" ] || return 0 + fi + + if _agmsg_install_manifest_read_gen "$manifest_path.prev" >/dev/null; then + # Preserve the last readable completion record instead of replacing it + # with an unreadable current file. + rm -f "$manifest_path" + return $? + fi + echo "agmsg: cannot rotate an unreadable install manifest without a readable previous copy" >&2 + return 1 +} + +# Writes the completion record for a just-finished copy: every file under +# , its path (relative to 's own parent, i.e. +# "scripts/...") and sha256 digest, plus , , , +# (the fixed shape agmsgd and +# agmsgd-launch.sh both carry, a SEPARATE version number from / +# that only bumps when that fixed contract itself changes; the real +# entrypoint, a later PR, checks this against its own embedded constant +# under the operation lock and exits 75 on a mismatch), and the write's own +# timestamp. Atomic (tmpfile + rename): a reader never sees a partially- +# written manifest. Symlinks are never listed (`find -type f` only) -- +# deliberately: the startup-side reader treats ANY symlink under scripts/ as +# a match failure on its own, so a writer that skipped listing one is +# consistent with that, not a gap in it. +agmsg_install_manifest_write() { # + # ${N:-}, not bare $N: this function's own caller has been seen, under + # heavy load on this shared machine, to somehow reach this call with fewer + # than 6 positional arguments -- root cause not yet pinned down (not + # reproducible in isolation). A bare $6 there turns that into an unbound- + # variable crash under set -u; reading it as "" instead lets the + # gen/bootstrap_version validation below report it as an ordinary, clearly + # worded refusal -- no manifest written, .prev left in place -- same as + # any other input this function already rejects. + local scripts_dir="${1:-}" manifest_path="${2:-}" version="${3:-}" install_id="${4:-}" gen="${5:-}" bootstrap_version="${6:-}" + local entry rel digest created_at sql tmp json rel_sql quote write_rc=0 i hash_lines batch_safe=true + local -a rels=() digests=() + + if [ -z "$scripts_dir" ] || [ -z "$manifest_path" ] || [ -z "$install_id" ]; then + echo "agmsg: install manifest write called with a missing argument (scripts_dir/manifest_path/install_id); refusing" >&2 + return 1 + fi + + created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + case "$gen" in + ''|*[!0-9]*) + echo "agmsg: install manifest gen must be a positive integer, got: $gen" >&2 + return 1 + ;; + esac + case "$bootstrap_version" in + ''|*[!0-9]*) + echo "agmsg: install manifest bootstrap_version must be a positive integer, got: $bootstrap_version" >&2 + return 1 + ;; + esac + + while IFS= read -r -d '' entry; do + rel="${entry#./}" + rels+=("$rel") + case "$rel" in *[!A-Za-z0-9._/-]*) batch_safe=false ;; esac + done < <(cd "$scripts_dir" && find . -type f -print0) + + if [ "$batch_safe" = true ]; then + if ! hash_lines="$(_agmsg_install_manifest_hash_batch "$scripts_dir" "${rels[@]}")"; then + return 1 + fi + while IFS= read -r digest; do + digests+=("$digest") + done <<< "$hash_lines" + [ "${#digests[@]}" -eq "${#rels[@]}" ] || return 1 + else + agmsg_sha256_usable || { + echo "agmsg: no usable sha256 tool found; refusing to write the install manifest" >&2 + return 1 + } + for rel in "${rels[@]}"; do + digest="$(agmsg_sha256 < "$scripts_dir/$rel")" || return 1 + digests+=("$digest") + done + fi + + sql="$(mktemp)" || return 1 + + { + printf 'CREATE TABLE files (path TEXT PRIMARY KEY, digest TEXT NOT NULL);\n' + for ((i = 0; i < ${#rels[@]}; i = i + 1)); do + rel="${rels[$i]}" + digest="${digests[$i]}" + quote="'" + rel_sql="${rel//$quote/$quote$quote}" + if ! printf "INSERT INTO files (path, digest) VALUES ('scripts/%s', '%s');\n" \ + "$rel_sql" "$digest"; then + write_rc=1 + break + fi + done + + if [ "$write_rc" -eq 0 ]; then + printf '%s\n' "SELECT json_object( + 'install_id', '$(_agmsg_install_manifest_sqlesc "$install_id")', + 'gen', $gen, + 'version', '$(_agmsg_install_manifest_sqlesc "$version")', + 'bootstrap_version', $bootstrap_version, + 'created_at', '$(_agmsg_install_manifest_sqlesc "$created_at")', + 'digest_algo', 'sha256', + 'files', (SELECT json_group_array(json_object('path', path, 'digest', digest)) + FROM (SELECT path, digest FROM files ORDER BY path)) + );" + fi + } > "$sql" || write_rc=1 + + if [ "$write_rc" -ne 0 ]; then + rm -f "$sql" + return 1 + fi + + if ! json="$(agmsg_sqlite_capture :memory: < "$sql")"; then + rm -f "$sql" + return 1 + fi + rm -f "$sql" + [ -n "$json" ] || return 1 + + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ] && ! agmsg_install_op_require; then + return 1 + fi + tmp="$(mktemp "$(dirname "$manifest_path")/.$(basename "$manifest_path").XXXXXX")" || return 1 + if ! printf '%s\n' "$json" > "$tmp" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ] && ! agmsg_install_op_require; then + rm -f "$tmp" + return 1 + fi + mv "$tmp" "$manifest_path" +} diff --git a/scripts/lib/install-op-lock.sh b/scripts/lib/install-op-lock.sh new file mode 100644 index 000000000..8bc7cf500 --- /dev/null +++ b/scripts/lib/install-op-lock.sh @@ -0,0 +1,558 @@ +#!/usr/bin/env bash +# install-op-lock.sh — the install/uninstall operation lock (agmsgd beta). +# +# One lock per install, held for the whole of install.sh / uninstall.sh / +# the `enable` / `disable` / `start` CLI operations. Mechanism: SQLite's own +# BEGIN EXCLUSIVE on run/install-op.lock.db -- a SEPARATE file from +# install.db, held by a single long-running sqlite3 process bash keeps alive +# for the whole operation. Bash 4+ feeds it through a coprocess; Bash 3.2 uses +# a pair of named pipes so bash can keep working while the transaction stays +# open. +# The OS releases the file lock the moment that sqlite3 process dies, for +# any reason -- there is no stale-lock recovery step. +# +# THE LOCK FILE ITSELF IS NEVER DELETED, not even by uninstall (citing +# https://www.sqlite.org/howtocorrupt.html): removing a DB a waiter still has +# open makes a fresh file recreated under the same name a DIFFERENT lock than +# the one that waiter is holding a reference to. +# +# THE LOCK DB HOLDS NO CONTENT (2026-09-29 design decision): no +# tables, no generation, no install_id, no owner -- an empty file whose only +# job is to be BEGIN EXCLUSIVE'd. Writing anything into it would make it a +# second place recording facts install.db and the completion manifest +# already own. +# +# LOCK ORDERING (2026-09-29 design decision): a caller that also +# needs the registry lock or a team-store lock takes them in this order -- +# registry lock -> this install operation lock -> team store lock -- and +# never the reverse. install.sh/uninstall.sh do not take a registry or +# team-store lock today, so this is a constraint on future callers, not a +# thing this file enforces itself. +# +# Required caller-set variables: none. Sources sqlpath.sh for native-path +# conversion and sqlite-output.sh for CRLF-safe captured output. + +[ -n "${_AGMSG_INSTALL_OP_LOCK_SH:-}" ] && return 0 +_AGMSG_INSTALL_OP_LOCK_SH=1 + +_agmsg_install_op_lock_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +if ! declare -F agmsg_sql_readfile_path >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$_agmsg_install_op_lock_dir/sqlpath.sh" +fi +if ! declare -F agmsg_sqlite_capture >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$_agmsg_install_op_lock_dir/sqlite-output.sh" +fi +unset _agmsg_install_op_lock_dir + +# A write to a pipe whose reader has already died raises SIGPIPE. Received by +# the shell's OWN write (a builtin `printf` redirected to that fd runs IN +# this process, not a forked child), the default action terminates the whole +# script -- measured: an untrapped SIGPIPE here kills install.sh outright, +# not just the one write. Every caller of this file needs a failed write to +# come back as an ordinary nonzero status instead, so this is set once, on +# source, rather than left to each call site to remember. +trap '' PIPE + +# Globals set by agmsg_install_op_lock on success, cleared by +# agmsg_install_op_unlock: _AGMSG_LOCK_PID (the sqlite3 process), fd 9 (write +# into it) and fd 8 (read its output), and optionally _AGMSG_LOCK_TMPDIR (the +# Bash 3.2 FIFOs' directory). Fixed fd numbers, not `exec {fd}>`: that form +# needs bash 4.1+, and this file runs under macOS's /bin/bash 3.2. + +# Acquires the lock. Prints nothing; returns 0 once BEGIN EXCLUSIVE is +# CONFIRMED open (a canary SELECT read back over the same pipe -- not merely +# "the write didn't fail"), 1 on any failure (busy past timeout_ms, the +# sqlite3 coprocess could not be started, or its output didn't confirm). +# Leaves no fd/process/tmpdir behind on failure. +agmsg_install_op_lock() { # [timeout_ms, default 30000] + local db="$1" timeout_ms="${2:-30000}" + local dir="" in_fifo="" out_fifo="" line read_timeout read_fd write_fd coproc_prefix read_rc + AGMSG_INSTALL_OP_LOCK_FAILURE_REASON="" + coproc_prefix=AGMSG_INSTALL_OP_SQLITE + _AGMSG_INSTALL_OP_USE_COPROC=false + _AGMSG_LOCK_CHANNELS_READY=false + if ! mkdir -p "$(dirname "$db")" 2>/dev/null; then + _agmsg_install_op_lock_set_failure "preparing lock database directory" + return 1 + fi + if [ "${BASH_VERSINFO[0]:-3}" -ge 4 ]; then + # `coproc` is syntax unknown to Bash 3.2, so keep it in an eval string + # that is evaluated only on Bash 4+. Redirections close Bats' inherited + # TAP descriptors in the coprocess command. + unset AGMSG_INSTALL_OP_SQLITE AGMSG_INSTALL_OP_SQLITE_PID + if ! eval 'coproc AGMSG_INSTALL_OP_SQLITE { exec sqlite3 "$db" 2>&1; } 3>&- 4>&-' 2>/dev/null; then + _agmsg_install_op_lock_set_failure "starting sqlite3 coprocess" + return 1 + fi + _AGMSG_INSTALL_OP_USE_COPROC=true + _AGMSG_LOCK_PID="${AGMSG_INSTALL_OP_SQLITE_PID:-}" + eval "read_fd=\${${coproc_prefix}[0]:-}" + eval "write_fd=\${${coproc_prefix}[1]:-}" + case "$_AGMSG_LOCK_PID" in ''|*[!0-9]*) _agmsg_install_op_lock_set_failure "starting sqlite3 coprocess: no valid child PID"; _AGMSG_LOCK_PID=""; agmsg_install_op_unlock; return 1 ;; esac + case "$read_fd" in ''|*[!0-9]*) _agmsg_install_op_lock_set_failure "connecting to sqlite3 coprocess: missing read pipe"; agmsg_install_op_unlock; return 1 ;; esac + case "$write_fd" in ''|*[!0-9]*) _agmsg_install_op_lock_set_failure "connecting to sqlite3 coprocess: missing write pipe"; agmsg_install_op_unlock; return 1 ;; esac + if ! eval "exec 8<&${read_fd} 9>&${write_fd}"; then + _agmsg_install_op_lock_set_failure "connecting to sqlite3 coprocess: could not attach its pipes" + agmsg_install_op_unlock + return 1 + fi + eval "exec ${read_fd}<&- ${write_fd}>&-" + _AGMSG_LOCK_CHANNELS_READY=true + unset AGMSG_INSTALL_OP_SQLITE AGMSG_INSTALL_OP_SQLITE_PID + else + dir="$(mktemp -d "${TMPDIR:-/tmp}/agmsg-install-lock.XXXXXX")" || { + _agmsg_install_op_lock_set_failure "preparing SQLite lock pipes: could not create a temporary directory" + return 1 + } + in_fifo="$dir/in"; out_fifo="$dir/out" + if ! mkfifo "$in_fifo" "$out_fifo" 2>/dev/null; then + _agmsg_install_op_lock_set_failure "preparing SQLite lock pipes: mkfifo failed" + rm -rf "$dir" 2>/dev/null + return 1 + fi + + # 2>&1 into the SAME out fifo: a busy/failed BEGIN EXCLUSIVE prints its + # error there instead of the canary line, which is exactly how failure is + # told apart from success below -- one stream, read for one exact literal. + sqlite3 "$db" < "$in_fifo" > "$out_fifo" 2>&1 3>&- 4>&- & + _AGMSG_LOCK_PID=$! + exec 9> "$in_fifo" + exec 8< "$out_fifo" + _AGMSG_LOCK_TMPDIR="$dir" + _AGMSG_LOCK_CHANNELS_READY=true + fi + + # `.timeout` (a dot-command), not `PRAGMA busy_timeout=N;`: a PRAGMA that + # returns a value is echoed back on the out fifo like a query result, which + # would land as an extra line before the canary and make the read count + # fragile. The dot-command sets the same busy handler with no echo. + if ! printf '.timeout %s\nBEGIN EXCLUSIVE;\nSELECT '"'"'agmsg-lock-ok'"'"';\n' "$timeout_ms" >&9; then + _agmsg_install_op_lock_set_failure "writing the lock request to sqlite3: the pipe closed or rejected the write" + agmsg_install_op_unlock + return 1 + fi + + read_timeout=$((timeout_ms / 1000 + 5)) + if IFS= read -r -t "$read_timeout" -u 8 line; then + : + else + read_rc=$? + if [ "$read_rc" -gt 128 ]; then + _agmsg_install_op_lock_set_failure "waiting for lock confirmation from sqlite3: timed out after ${read_timeout}s" + else + _agmsg_install_op_lock_set_failure "waiting for lock confirmation from sqlite3: output closed before a reply" + fi + agmsg_install_op_unlock + return 1 + fi + line="${line%$'\r'}" + if [ "$line" != "agmsg-lock-ok" ]; then + if [ -n "$line" ]; then + _agmsg_install_op_lock_set_failure "unexpected lock confirmation from sqlite3" "$line" + else + _agmsg_install_op_lock_set_failure "unexpected empty lock confirmation from sqlite3" + fi + agmsg_install_op_unlock + return 1 + fi + return 0 +} + +# Preserve a short, shell-escaped first response so a caller can explain which +# handshake stage failed without allowing control characters into terminal output. +_agmsg_install_op_lock_set_failure() { + local reason="${1:-lock handshake failed}" detail="${2:-}" + if [ -n "$detail" ]; then + printf -v detail '%q' "$detail" + detail="${detail:0:80}" + if [ -n "$detail" ]; then + reason="$reason: $detail" + fi + fi + AGMSG_INSTALL_OP_LOCK_FAILURE_REASON="$reason" +} + +# Load the shared liveness check when its source file is still installed. The +# recovery helper can outlive scripts/, so its canary round-trip remains the +# authoritative fallback when instance-id.sh is unavailable. +_agmsg_install_op_load_pid_helper() { + declare -F _agmsg_pid_alive_local >/dev/null 2>&1 && return 0 + if [ -n "${SCRIPT_DIR:-}" ] && [ -r "$SCRIPT_DIR/scripts/lib/instance-id.sh" ]; then + . "$SCRIPT_DIR/scripts/lib/instance-id.sh" + elif [ -r "$(dirname "${BASH_SOURCE[0]}")/instance-id.sh" ]; then + . "$(dirname "${BASH_SOURCE[0]}")/instance-id.sh" + fi + declare -F _agmsg_pid_alive_local >/dev/null 2>&1 +} + +# Re-proves the lock is still genuinely held: use the shared local-pid +# liveness check when available, then require a fresh canary round-trip. The +# canary also covers the recovery-only helper after scripts/ has been removed. +# A caller that only checked liveness at acquire time would otherwise write +# past a lock it silently no longer holds. Returns 1 (lock not provably held; +# do not proceed) without ever killing the caller, since PIPE is trapped. +agmsg_install_op_confirm() { + local line + [ -n "${_AGMSG_LOCK_PID:-}" ] || return 1 + if _agmsg_install_op_load_pid_helper; then + _agmsg_pid_alive_local "$_AGMSG_LOCK_PID" || return 1 + fi + printf "SELECT 'agmsg-lock-ok';\n" >&9 || return 1 + read -r -t 5 -u 8 line || return 1 + line="${line%$'\r'}" + [ "$line" = "agmsg-lock-ok" ] +} + +# Refuse the next protected write unless the SQLite child still proves the +# transaction is held. Phase entry checks gate each group of writes; the next +# phase or operation_finish detects a child-only death before later work starts. +agmsg_install_op_require() { + # The incomplete-operation record excludes every later install/uninstall + # while a phase is in flight. Within that phase, the record -- not another + # SQLite round trip for each small write -- is what prevents overlap. The + # phase runner proves the lock at each phase entry so a lost lock cannot + # carry this operation into its next phase. + [ "${AGMSG_INSTALL_OP_PHASE_ACTIVE:-false}" = true ] && return 0 + if ! agmsg_install_op_confirm; then + echo " ! the install lock was lost partway through; stopping before the next write (see .prev)" >&2 + return 1 + fi +} + +# Run one logical group of protected writes. The operation marker remains in +# place throughout the group, so a later operation can acquire the SQLite lock +# after a child-only failure but must still refuse to write. The next phase +# checks the lock before it writes; operation_finish checks it after the last +# phase. That makes each boundary one canary round-trip, not two. +agmsg_install_op_phase_begin() { + agmsg_install_op_require || return 1 + AGMSG_INSTALL_OP_PHASE_ACTIVE=true +} + +agmsg_install_op_phase_end() { + AGMSG_INSTALL_OP_PHASE_ACTIVE=false + return 0 +} + +agmsg_install_op_run_phase() { + local phase_rc=0 + agmsg_install_op_phase_begin || return 1 + "$@" || phase_rc=$? + agmsg_install_op_phase_end || return 1 + return "$phase_rc" +} + +_agmsg_install_op_sql_quote() { + printf '%s' "$1" | sed "s/'/''/g" +} + +_agmsg_install_op_pending_fields() { + local path="$1" path_sql + path_sql="$(agmsg_sql_readfile_path "$path")" || return 1 + # Hex fields make the colon separator unambiguous even when a path contains + # tabs, quotes, or newlines, while keeping all seven reads in one sqlite3. + agmsg_sqlite_capture :memory: \ + "WITH record AS (SELECT CAST(readfile('$path_sql') AS TEXT) AS json) SELECT lower(hex(CAST(COALESCE(json_extract(json, '\$.operation_id'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.operation'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.mode'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.install_path'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.actor_pid'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.started_at'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.state'), '') AS BLOB))) FROM record;" +} + +_agmsg_install_op_hex_decode() { + local hex="$1" decoded="" byte char + case "$hex" in *[!0123456789abcdefABCDEF]*) return 1 ;; esac + [ $(( ${#hex} % 2 )) -eq 0 ] || return 1 + # Bash variables cannot represent NUL, and no valid path or record field + # needs one. Reject it rather than silently changing the decoded value. + case "$hex" in *00*) return 1 ;; esac + while [ -n "$hex" ]; do + byte="${hex:0:2}" + hex="${hex:2}" + printf -v char '%b' "\\x$byte" || return 1 + decoded="${decoded}${char}" + done + AGMSG_INSTALL_OP_DECODED="$decoded" +} + +agmsg_install_op_pending_validate() { + local path="$1" encoded id_hex kind_hex mode_hex install_path_hex pid_hex started_hex state_hex extra + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + encoded="$(_agmsg_install_op_pending_fields "$path")" || return 1 + IFS=: read -r id_hex kind_hex mode_hex install_path_hex pid_hex started_hex state_hex extra <<< "$encoded" + [ -z "$extra" ] || return 1 + _agmsg_install_op_hex_decode "$id_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_ID="$AGMSG_INSTALL_OP_DECODED" + _agmsg_install_op_hex_decode "$kind_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_KIND="$AGMSG_INSTALL_OP_DECODED" + _agmsg_install_op_hex_decode "$mode_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_MODE="$AGMSG_INSTALL_OP_DECODED" + [ -n "$AGMSG_INSTALL_OP_PENDING_MODE" ] || AGMSG_INSTALL_OP_PENDING_MODE=legacy + _agmsg_install_op_hex_decode "$install_path_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_PATH="$AGMSG_INSTALL_OP_DECODED" + _agmsg_install_op_hex_decode "$pid_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_PID="$AGMSG_INSTALL_OP_DECODED" + _agmsg_install_op_hex_decode "$started_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_STARTED="$AGMSG_INSTALL_OP_DECODED" + _agmsg_install_op_hex_decode "$state_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_STATE="$AGMSG_INSTALL_OP_DECODED" + case "$AGMSG_INSTALL_OP_PENDING_ID" in + ''|*[!0-9a-f]*) return 1 ;; + esac + [ "${#AGMSG_INSTALL_OP_PENDING_ID}" -eq 32 ] || return 1 + case "$AGMSG_INSTALL_OP_PENDING_KIND" in install|uninstall) ;; *) return 1 ;; esac + case "$AGMSG_INSTALL_OP_PENDING_MODE" in install|update|keep-data|remove-data|legacy) ;; *) return 1 ;; esac + case "$AGMSG_INSTALL_OP_PENDING_KIND:$AGMSG_INSTALL_OP_PENDING_MODE" in + install:install|install:update|install:legacy|uninstall:keep-data|uninstall:remove-data|uninstall:legacy) ;; + *) return 1 ;; + esac + case "$AGMSG_INSTALL_OP_PENDING_PID" in ''|*[!0-9]*) return 1 ;; esac + [ "${#AGMSG_INSTALL_OP_PENDING_PID}" -le 10 ] || return 1 + [ "$AGMSG_INSTALL_OP_PENDING_PID" -gt 0 ] || return 1 + case "$AGMSG_INSTALL_OP_PENDING_STATE" in + in_progress|interrupted_complete|completed) ;; + *) return 1 ;; + esac + [ -n "$AGMSG_INSTALL_OP_PENDING_PATH" ] && [ -n "$AGMSG_INSTALL_OP_PENDING_STARTED" ] +} + +agmsg_install_op_pending_report() { + local path="$1" + agmsg_install_op_pending_validate "$path" || return 1 + printf ' operation_id: %s\n' "$AGMSG_INSTALL_OP_PENDING_ID" >&2 + printf ' operation: %s\n' "$AGMSG_INSTALL_OP_PENDING_KIND" >&2 + printf ' mode: %s\n' "$AGMSG_INSTALL_OP_PENDING_MODE" >&2 + printf ' install: %s\n' "$AGMSG_INSTALL_OP_PENDING_PATH" >&2 + printf ' started_at: %s\n' "$AGMSG_INSTALL_OP_PENDING_STARTED" >&2 + printf ' actor_pid: %s (displayed only; no liveness inference)\n' "$AGMSG_INSTALL_OP_PENDING_PID" >&2 +} + +agmsg_install_op_pending_refuse() { + local path="$1" recovery_prefix="$2" next_kind="$3" next_mode="$4" + if [ -L "$path" ] || ! agmsg_install_op_pending_report "$path"; then + echo " ! an unreadable or malformed incomplete-operation record blocks this install; inspect $path before recovery" >&2 + return 1 + fi + printf ' ! an earlier %s operation (%s) is incomplete; %s (%s) will not start\n' \ + "$AGMSG_INSTALL_OP_PENDING_KIND" "$AGMSG_INSTALL_OP_PENDING_MODE" "$next_kind" "$next_mode" >&2 + echo " Verify that no writer from the recorded operation is still running before recovery." >&2 + printf ' Recovery command: %s %s\n' "$recovery_prefix" "$AGMSG_INSTALL_OP_PENDING_ID" >&2 + return 1 +} + +agmsg_install_op_pending_begin() { + local path="$1" kind="$2" install_path="$3" install_id="${4:-}" mode="${5:-legacy}" + local op_id started_at path_sql kind_sql mode_sql id_sql started_sql install_id_sql json tmp + [ -n "$path" ] && [ -n "$kind" ] && [ -n "$install_path" ] || return 1 + case "$kind:$mode" in + install:install|install:update|uninstall:keep-data|uninstall:remove-data) ;; + *) return 1 ;; + esac + agmsg_install_op_require || return 1 + mkdir -p "$(dirname "$path")" || return 1 + if [ -e "$path" ] || [ -L "$path" ]; then + echo " ! an incomplete-operation record already exists at $path" >&2 + return 1 + fi + op_id="$(agmsg_sqlite_capture :memory: 'SELECT lower(hex(randomblob(16)));')" || return 1 + case "$op_id" in ''|*[!0-9a-f]*) return 1 ;; esac + [ "${#op_id}" -eq 32 ] || return 1 + started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" || return 1 + path_sql="$(_agmsg_install_op_sql_quote "$install_path")" || return 1 + kind_sql="$(_agmsg_install_op_sql_quote "$kind")" || return 1 + mode_sql="$(_agmsg_install_op_sql_quote "$mode")" || return 1 + id_sql="$(_agmsg_install_op_sql_quote "$op_id")" || return 1 + started_sql="$(_agmsg_install_op_sql_quote "$started_at")" || return 1 + install_id_sql="$(_agmsg_install_op_sql_quote "$install_id")" || return 1 + json="$(agmsg_sqlite_capture :memory: "SELECT json_object('operation_id','$id_sql','operation','$kind_sql','mode','$mode_sql','install_path','$path_sql','install_id','$install_id_sql','actor_pid',$$,'started_at','$started_sql','state','in_progress');")" || return 1 + [ -n "$json" ] || return 1 + tmp="$(mktemp "$(dirname "$path")/.$(basename "$path").XXXXXX")" || return 1 + if ! printf '%s\n' "$json" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + agmsg_install_op_require || { rm -f "$tmp"; return 1; } + if ! ln "$tmp" "$path" 2>/dev/null; then + rm -f "$tmp" + echo " ! an incomplete-operation record already exists at $path" >&2 + return 1 + fi + rm -f "$tmp" || return 1 + AGMSG_INSTALL_OP_ID="$op_id" + AGMSG_INSTALL_OP_MARKER="$path" + agmsg_install_op_require +} + +agmsg_install_op_pending_set_state() { + local path="$1" op_id="$2" state="$3" current_id path_sql json tmp + case "$state" in completed|interrupted_complete) ;; *) return 1 ;; esac + agmsg_install_op_require || return 1 + agmsg_install_op_pending_validate "$path" || return 1 + current_id="$AGMSG_INSTALL_OP_PENDING_ID" + [ "$current_id" = "$op_id" ] || return 1 + path_sql="$(agmsg_sql_readfile_path "$path")" || return 1 + op_id="$(_agmsg_install_op_sql_quote "$op_id")" || return 1 + state="$(_agmsg_install_op_sql_quote "$state")" || return 1 + json="$(agmsg_sqlite_capture :memory: "SELECT json_set(CAST(readfile('$path_sql') AS TEXT), '\$.state', '$state') WHERE json_extract(CAST(readfile('$path_sql') AS TEXT), '\$.operation_id') = '$op_id';")" || return 1 + [ -n "$json" ] || return 1 + tmp="$(mktemp "$(dirname "$path")/.$(basename "$path").XXXXXX")" || return 1 + if ! printf '%s\n' "$json" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + agmsg_install_op_require || { rm -f "$tmp"; return 1; } + agmsg_install_op_pending_validate "$path" || { rm -f "$tmp"; return 1; } + [ "$AGMSG_INSTALL_OP_PENDING_ID" = "$op_id" ] || { rm -f "$tmp"; return 1; } + if ! mv -f "$tmp" "$path"; then + rm -f "$tmp" + return 1 + fi + agmsg_install_op_require +} + +agmsg_install_op_pending_remove() { + local path="$1" op_id="$2" + agmsg_install_op_require || return 1 + agmsg_install_op_pending_validate "$path" || return 1 + [ "$AGMSG_INSTALL_OP_PENDING_ID" = "$op_id" ] || return 1 + rm -f "$path" || return 1 + agmsg_install_op_require +} + +agmsg_install_op_pending_complete() { + local path="$1" op_id="$2" state="${3:-completed}" + agmsg_install_op_pending_set_state "$path" "$op_id" "$state" || return 1 + agmsg_install_op_pending_remove "$path" "$op_id" +} + +agmsg_install_op_pending_recover() { + local path="$1" requested_id="$2" next_kind="$3" next_mode="$4" + agmsg_install_op_require || return 1 + if ! agmsg_install_op_pending_report "$path"; then + echo " ! the incomplete-operation record is missing or unreadable; it cannot be recovered automatically" >&2 + return 1 + fi + if [ "$AGMSG_INSTALL_OP_PENDING_ID" != "$requested_id" ]; then + echo " ! recovery operation id does not match the current record" >&2 + return 1 + fi + printf ' Recorded operation: %s (%s)\n' \ + "$AGMSG_INSTALL_OP_PENDING_KIND" "$AGMSG_INSTALL_OP_PENDING_MODE" >&2 + printf ' Continuing requested operation: %s (%s)\n' "$next_kind" "$next_mode" >&2 + echo " Verify that no writer from this operation can still modify the installation before continuing." >&2 + agmsg_install_op_pending_remove "$path" "$requested_id" +} + +agmsg_install_op_run_writer() { + local status=0 writer_pid + AGMSG_INSTALL_OP_WRITER_STARTING=true + "$@" 3>&- 4>&- & + writer_pid=$! + if [ -n "${AGMSG_TEST_INSTALL_OP_WRITER_PID_PUBLISH_GATE:-}" ]; then + printf '%s\n' "$writer_pid" > "${AGMSG_TEST_INSTALL_OP_WRITER_PID_PUBLISH_GATE}.pid" + while [ ! -e "${AGMSG_TEST_INSTALL_OP_WRITER_PID_PUBLISH_GATE}.release" ]; do sleep 0.02; done + fi + AGMSG_INSTALL_OP_WRITER_PID="$writer_pid" + AGMSG_INSTALL_OP_WRITER_STARTING=false + if wait "$writer_pid"; then + status=0 + else + status=$? + fi + AGMSG_INSTALL_OP_WRITER_PID="" + return "$status" +} + +# A caught cancellation is the only automatic path that removes an in-flight +# record before the completion manifest is published. Stop and reap the one +# tracked mutating child first; if the SQLite lock has already been lost, keep +# the record so a competing operation cannot enter while that child exits. +agmsg_install_op_handle_signal() { + local signal="$1" exit_status=130 + [ "$signal" = TERM ] && exit_status=143 + trap - INT TERM + AGMSG_INSTALL_OP_PHASE_ACTIVE=false + if [ "${AGMSG_INSTALL_OP_WRITER_STARTING:-false}" = true ] && [ -z "${AGMSG_INSTALL_OP_WRITER_PID:-}" ]; then + echo " ! writer launch was interrupted before its pid was published; the incomplete-operation record was kept for recovery" >&2 + agmsg_install_op_unlock + exit "$exit_status" + fi + if [ -n "${AGMSG_INSTALL_OP_WRITER_PID:-}" ]; then + kill -TERM "$AGMSG_INSTALL_OP_WRITER_PID" 2>/dev/null || true + wait "$AGMSG_INSTALL_OP_WRITER_PID" 2>/dev/null || true + AGMSG_INSTALL_OP_WRITER_PID="" + fi + AGMSG_INSTALL_OP_WRITER_STARTING=false + if [ -n "${AGMSG_INSTALL_OP_ID:-}" ] && [ -n "${AGMSG_INSTALL_OP_MARKER:-}" ] && agmsg_install_op_confirm; then + agmsg_install_op_pending_complete "$AGMSG_INSTALL_OP_MARKER" "$AGMSG_INSTALL_OP_ID" interrupted_complete || true + fi + agmsg_install_op_unlock + exit "$exit_status" +} + +# Releases the lock and cleans up every resource agmsg_install_op_lock +# created, whether or not the lock was ever confirmed open (safe to call +# after a failed agmsg_install_op_lock, and safe to call twice). COMMIT is +# attempted but its failure is not itself an error here -- a coprocess that +# already died released the OS lock by dying; there is nothing left to +# commit, and this function's job is cleanup, not re-detecting that. +agmsg_install_op_unlock() { + local coproc_read_fd="" coproc_write_fd="" coproc_prefix=AGMSG_INSTALL_OP_SQLITE + eval "coproc_read_fd=\${${coproc_prefix}[0]:-}" + eval "coproc_write_fd=\${${coproc_prefix}[1]:-}" + if [ "${_AGMSG_LOCK_CHANNELS_READY:-false}" = true ] && [ -n "${_AGMSG_LOCK_PID:-}" ]; then + printf 'COMMIT;\n' >&9 2>/dev/null || true + fi + # `exec` with no command applies its redirections to the CURRENT SHELL, + # not to a single statement -- a bare `exec 9>&- 2>/dev/null` (to silence + # a "no such file descriptor" if 9 were somehow already closed) therefore + # redirects stderr for the REST OF THE CALLING SCRIPT, not just this line. + # Measured: it did, and every later stderr write -- including a + # downstream command's own error message -- went to /dev/null for good. + # `{ exec 9>&-; } 2>/dev/null` scopes the same suppression to the group + # instead: stderr is only silenced for the compound command inside the + # braces, and is itself restored once the group ends. + { exec 9>&-; } 2>/dev/null || true + { exec 8<&-; } 2>/dev/null || true + if [ "${_AGMSG_INSTALL_OP_USE_COPROC:-false}" = true ]; then + case "$coproc_read_fd" in ''|*[!0-9]*) ;; *) eval "exec ${coproc_read_fd}<&-" 2>/dev/null || true ;; esac + case "$coproc_write_fd" in ''|*[!0-9]*) ;; *) eval "exec ${coproc_write_fd}>&-" 2>/dev/null || true ;; esac + fi + if [ -n "${_AGMSG_LOCK_PID:-}" ]; then + kill "$_AGMSG_LOCK_PID" 2>/dev/null || true + wait "$_AGMSG_LOCK_PID" 2>/dev/null || true + fi + [ -n "${_AGMSG_LOCK_TMPDIR:-}" ] && rm -rf "$_AGMSG_LOCK_TMPDIR" 2>/dev/null + unset _AGMSG_LOCK_PID _AGMSG_LOCK_TMPDIR _AGMSG_INSTALL_OP_USE_COPROC _AGMSG_LOCK_CHANNELS_READY + unset AGMSG_INSTALL_OP_SQLITE AGMSG_INSTALL_OP_SQLITE_PID + AGMSG_INSTALL_OP_PHASE_ACTIVE=false +} + +# Places 's CONTENT at atomically: a reader of sees either +# the old file in full or the new one in full, never a torn write -- +# agmsgd-launch.sh and the agmsgd entrypoint must never be +# read half-written. The temp file lives in 's own directory so the +# final `mv` is a same-filesystem rename, not a cross-filesystem copy. +# install.sh already depends on `mktemp` elsewhere and has no join.sh-style +# minimal-PATH constraint, so this does not need registry-lock.sh's +# mkdir-only fallback. Mode is mktemp's default (0600); the caller chmod's +# afterward the same way it does for every other shipped script -- +# not this helper's job, and `chmod --reference` is a GNU-only flag this +# codebase's macOS/BSD chmod does not have. +agmsg_atomic_place_file() { # + local src="$1" dest="$2" tmp + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ]; then + agmsg_install_op_require || return 1 + fi + tmp="$(mktemp "$(dirname "$dest")/.$(basename "$dest").XXXXXX")" || return 1 + if ! cp "$src" "$tmp" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null + return 1 + fi + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ] && ! agmsg_install_op_require; then + rm -f "$tmp" 2>/dev/null + return 1 + fi + mv "$tmp" "$dest" + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ]; then + agmsg_install_op_require || return 1 + fi +} diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index 71057b1c1..e00f926f8 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -137,10 +137,9 @@ agmsg_role_session_load() { # from the type manifest. Empty when unknown. # project= the resolved project root # owner= the actas owner token written by actas-claim -# codex_home= the effective absolute Codex profile directory # updated_at= best-effort timestamp (empty if date(1) unavailable) agmsg_role_session_record() { - local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" codex_home="${7:-}" + local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" [ -n "$team" ] && [ -n "$agent" ] && [ -n "$bare_sid" ] || return 0 local path dir tmp ts named_ref="" named_epoch="" named_at="" _agmsg_role_session_path_into "$team" "$agent" @@ -164,7 +163,6 @@ agmsg_role_session_record() { printf 'type=%s\n' "$type" printf 'project=%s\n' "$project" [ -z "$owner" ] || printf 'owner=%s\n' "$owner" - [ -z "$codex_home" ] || printf 'codex_home=%s\n' "$codex_home" printf 'updated_at=%s\n' "$ts" [ -z "$named_ref" ] || printf 'named_ref=%s\n' "$named_ref" [ -z "$named_ref" ] || printf 'named_epoch=%s\n' "$named_epoch" diff --git a/scripts/lib/skill-render.sh b/scripts/lib/skill-render.sh index 5efa62ab4..f037a61e8 100644 --- a/scripts/lib/skill-render.sh +++ b/scripts/lib/skill-render.sh @@ -114,9 +114,16 @@ agmsg_render_skill() { return 1 fi chmod 644 "$temp" || { rm -f "$temp"; return 1; } + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ] && ! agmsg_install_op_require; then + rm -f "$temp" + return 1 + fi if ! mv -f "$temp" "$output"; then rm -f "$temp" echo "agmsg: cannot install rendered skill: $output" >&2 return 1 fi + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ] && ! agmsg_install_op_require; then + return 1 + fi } diff --git a/scripts/lib/sqlite-output.sh b/scripts/lib/sqlite-output.sh new file mode 100644 index 000000000..0e4d03b8d --- /dev/null +++ b/scripts/lib/sqlite-output.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Capture one sqlite3 result while normalizing the CR from native Windows +# sqlite3.exe's CRLF line ending. Callers use this for scalar or JSON output. + +[ -n "${_AGMSG_SQLITE_OUTPUT_SH:-}" ] && return 0 +_AGMSG_SQLITE_OUTPUT_SH=1 + +agmsg_sqlite_capture() { + local output + output="$(sqlite3 "$@" 2>/dev/null)" || return $? + output="${output%$'\r'}" + printf '%s' "$output" +} diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats index c690a853f..3216ae390 100644 --- a/tests/test_agmsgd_entrypoint.bats +++ b/tests/test_agmsgd_entrypoint.bats @@ -114,3 +114,13 @@ _write_completion_record() { final_state="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" [ "$final_state" = "none" ] } + +@test "agmsgd refuses to start while an incomplete install operation is recorded" { + _write_completion_record + printf '%s\n' '{"operation_id":"0123456789abcdef0123456789abcdef"}' \ + > "$TEST_SKILL_DIR/run/install-op-incomplete.json" + + run node "$SCRIPTS/daemon/agmsgd" "$TEST_SKILL_DIR" on 0 + [ "$status" -eq 75 ] + [[ "$output" == *"operation is incomplete"* ]] +} diff --git a/tests/test_codex_resume.bats b/tests/test_codex_resume.bats index 4a3738b64..7a4d1a830 100644 --- a/tests/test_codex_resume.bats +++ b/tests/test_codex_resume.bats @@ -14,8 +14,6 @@ setup() { export RUN_DIR="$SKILL_DIR/run" mkdir -p "$RUN_DIR" export CODEX_SESSIONS="$HOME/.codex/sessions" - export CODEX_HOME="$HOME/.codex" - mkdir -p "$CODEX_HOME" } teardown() { teardown_test_env; } @@ -70,51 +68,21 @@ recorded_uuid() { agmsg_role_session_uuid "$1" "$2" } -@test "codex record: stores the thread and effective profile path" { - local proj explicit_home expected_home; proj="$(mktemp -d)" - explicit_home="$TEST_SKILL_DIR/codex profile" - mkdir -p "$explicit_home" "$HOME/.codex" - CODEX_HOME="$explicit_home" CODEX_THREAD_ID="env-thread-1" \ +@test "codex record: prefers CODEX_THREAD_ID (unambiguous env path)" { + local proj; proj="$(mktemp -d)" + CODEX_THREAD_ID="env-thread-1" \ bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" [ "$(recorded_uuid team alice)" = "env-thread-1" ] + # type is recorded as codex. source "$SKILL_DIR/scripts/lib/role-session.sh" [ "$(agmsg_role_session_get team alice type)" = "codex" ] - expected_home="$(cd "$explicit_home" && pwd -P)" - # Match the recorder's cross-platform path spelling (not raw Git Bash /c/...). - # shellcheck disable=SC1090 - source "$SCRIPTS/lib/resolve-project.sh" - expected_home="$(agmsg_normalize_project_path "$expected_home")" - [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] - - # Unset CODEX_HOME uses the same default Codex uses, recorded as an absolute - # path rather than leaving a later reader to infer it from its own HOME. - env -u CODEX_HOME CODEX_THREAD_ID="env-thread-2" \ - bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" - [ "$(recorded_uuid team alice)" = "env-thread-2" ] - expected_home="$(cd "$HOME/.codex" && pwd -P)" - expected_home="$(agmsg_normalize_project_path "$expected_home")" - [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: falls back to the unique matching-cwd rollout when env is unset" { - local proj explicit_home expected_home; proj="$(mktemp -d)" - explicit_home="$TEST_SKILL_DIR/profile-B" - mkdir -p "$explicit_home" - # A matching rollout in the default profile must not outrank the selected - # profile's own unique matching rollout. - CODEX_SESSIONS="$HOME/.codex/sessions" - make_rollout "wrong-profile-uuid" "$proj" - CODEX_SESSIONS="$explicit_home/sessions" + local proj; proj="$(mktemp -d)" make_rollout "fallback-uuid" "$proj" - CODEX_HOME="$explicit_home" env -u CODEX_THREAD_ID \ - bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + ( unset CODEX_THREAD_ID; bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) [ "$(recorded_uuid team alice)" = "fallback-uuid" ] - source "$SKILL_DIR/scripts/lib/role-session.sh" - expected_home="$(cd "$explicit_home" && pwd -P)" - # shellcheck disable=SC1090 - source "$SCRIPTS/lib/resolve-project.sh" - expected_home="$(agmsg_normalize_project_path "$expected_home")" - [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: records NOTHING when two recent rollouts share the cwd (ambiguous)" { diff --git a/tests/test_install.bats b/tests/test_install.bats index 3db5b6708..decf4ee65 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -300,7 +300,12 @@ teardown() { # install this run is about (#1400). HOME="$FAKE_HOME" CODEX_HOME="$codex_home2" bash "$SK/uninstall.sh" --yes - [ ! -e "$SK" ] + # $SK itself is no longer fully gone: run/install-op.lock.db + # is kept on purpose, so a residual $SK/run/ survives. Its substantive + # content is what must be gone -- covered in full by its own test + # ("uninstall keeps run/install-op.lock.db while removing everything else"). + [ ! -e "$SK/scripts" ] + [ ! -e "$SK/SKILL.md" ] [ ! -f "$cmd_first" ] [ ! -f "$proj_cmd_first" ] refute grep -qF "$SK/" "$settings" @@ -376,8 +381,10 @@ teardown() { # two installs present and no single one identified. HOME="$FAKE_HOME" bash "$REPO_ROOT/uninstall.sh" --all --yes - [ ! -e "$FAKE_HOME/.agents/skills/agmsg" ] - [ ! -e "$FAKE_HOME/.agents/skills/agmsg-second" ] + # Neither install is fully gone: run/install-op.lock.db + # is kept on purpose for each. Substantive content is what must be gone. + [ ! -e "$FAKE_HOME/.agents/skills/agmsg/scripts" ] + [ ! -e "$FAKE_HOME/.agents/skills/agmsg-second/scripts" ] [ ! -f "$cmd_first" ] [ ! -f "$cmd_second" ] [ ! -e "$shim" ] @@ -845,6 +852,92 @@ PS1 [ ! -f "$SK/scripts/windows/sqlite3-shim.sh" ] } +@test "install reports the lock handshake stage and a bounded SQLite response" { + local fake_bin="$FAKE_HOME/bin" banner expected + mkdir -p "$fake_bin" + banner="SQLite version $(printf '%100s' '' | tr ' ' X)" + cat > "$fake_bin/sqlite3" <<'SH' +#!/usr/bin/env bash +if [ "${SQLITE_MODE:-}" = crlf-canary ]; then + while IFS= read -r statement; do + if [ "$statement" = "SELECT 'agmsg-lock-ok';" ]; then + printf 'agmsg-lock-ok\r\n' + fi + done + exit 0 +fi +printf '%s\n' "${SQLITE_BANNER:-unexpected sqlite output}" +SH + chmod +x "$fake_bin/sqlite3" + + run env HOME="$FAKE_HOME" PATH="$fake_bin:$PATH" SQLITE_BANNER="$banner" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg + [ "$status" -ne 0 ] + printf -v expected '%q' "$banner" + expected="${expected:0:80}" + grep -Fq -- "could not take the install operation lock: unexpected lock confirmation from sqlite3: $expected" <<<"$output" + [ "${#expected}" -eq 80 ] + refute grep -Fq -- "another install/uninstall in progress?" <<<"$output" + + run env HOME="$FAKE_HOME" PATH="$fake_bin:$PATH" SQLITE_MODE=crlf-canary \ + bash -c 'source "$1"; agmsg_install_op_lock "$2" 1000 || exit 1; agmsg_install_op_confirm || exit 2; printf "lock acquired\\n"; agmsg_install_op_unlock' \ + _ "$REPO_ROOT/scripts/lib/install-op-lock.sh" "$FAKE_HOME/run/install-op.lock.db" + [ "$status" -eq 0 ] + grep -Fq -- "lock acquired" <<<"$output" +} + +@test "install SQLite readers batch pending fields, convert paths, and normalize CRLF" { + local fake_bin="$FAKE_HOME/bin" record="$FAKE_HOME/record.json" manifest="$FAKE_HOME/manifest.json" calls="$FAKE_HOME/sqlite-calls" + mkdir -p "$fake_bin" + printf '{}\n' > "$record" + printf '{"gen":1}\n' > "$manifest" + cat > "$fake_bin/cygpath" <<'SH' +#!/usr/bin/env bash +printf 'C:/converted\n' +SH + cat > "$fake_bin/sqlite3" <<'SH' +#!/usr/bin/env bash +query= +for arg do query="$arg"; done +printf 'call\n' >> "$SQLITE_CALLS" +case "$query" in + *"readfile('/"*) printf 'SQLite received an unconverted path\n' >&2; exit 20 ;; + *"readfile('C:/converted')"*'$.operation_id'*) + printf '%s\r\n' \ + '3031323334353637383961626364656630313233343536373839616263646566:696e7374616c6c::433a2f636f6e766572746564:3132333435:78:696e5f70726f6772657373' + ;; + *"readfile('C:/converted')"*'$.gen'*) printf '7\r\n' ;; + *"SELECT install_id FROM meta LIMIT 1;"*) printf 'install-id\r\n' ;; + *"CREATE TABLE IF NOT EXISTS meta"*) exit 0 ;; + *) printf 'unexpected SQL: %s\n' "$query" >&2; exit 21 ;; +esac +SH + chmod +x "$fake_bin/cygpath" "$fake_bin/sqlite3" + + run env HOME="$FAKE_HOME" PATH="$fake_bin:$PATH" \ + SQLITE_CALLS="$calls" bash -c ' + lib="$1" + . "$lib/install-op-lock.sh" + . "$lib/install-db.sh" + . "$lib/install-manifest.sh" + agmsg_install_op_pending_validate "$2" || exit 10 + [ "$AGMSG_INSTALL_OP_PENDING_ID" = "0123456789abcdef0123456789abcdef" ] || exit 13 + [ "$AGMSG_INSTALL_OP_PENDING_KIND" = "install" ] || exit 14 + [ "$AGMSG_INSTALL_OP_PENDING_MODE" = "legacy" ] || exit 15 + [ "$AGMSG_INSTALL_OP_PENDING_PATH" = "C:/converted" ] || exit 16 + [ "$AGMSG_INSTALL_OP_PENDING_PID" = "12345" ] || exit 17 + [ "$AGMSG_INSTALL_OP_PENDING_STARTED" = "x" ] || exit 18 + [ "$AGMSG_INSTALL_OP_PENDING_STATE" = "in_progress" ] || exit 19 + [ "$(wc -l < "$SQLITE_CALLS" | tr -d " ")" = 1 ] || exit 20 + op_id="$AGMSG_INSTALL_OP_PENDING_ID" + gen="$(_agmsg_install_manifest_read_gen "$3")" || exit 11 + install_id="$(agmsg_install_db_ensure_meta "$4")" || exit 12 + printf "%s|%s|%s\n" "$op_id" "$gen" "$install_id" + ' _ "$REPO_ROOT/scripts/lib" "$record" "$manifest" "$FAKE_HOME/install.db" + [ "$status" -eq 0 ] + [ "$output" = '0123456789abcdef0123456789abcdef|7|install-id' ] +} + @test "plugin SKILL.md bootstrap: a fresh plugin install path can bootstrap ~/.agents/skills/agmsg" { # Simulate the post-plugin-install state: no ~/.agents/skills/agmsg yet, but # the plugin marketplace flow has populated the cache dir with a copy of the @@ -2056,6 +2149,564 @@ CYG done < <(agmsg_renderable_types "$BATS_TEST_DIRNAME/..") } +@test "install writes a completion manifest, --update carries install_id and bumps gen" { + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --cmd agmsg + local manifest="$SK/run/install-manifest.json" + [ -f "$manifest" ] + # The operation lock DB must exist, never be empty-deleted by install + # itself, and never appear in the manifest's own file listing (it lives + # under run/, not scripts/). + [ -f "$SK/run/install-op.lock.db" ] + + local id1 gen1 + id1="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.install_id');")" + gen1="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.gen');")" + [ -n "$id1" ] + [ "$gen1" = "1" ] + local manifest_version + manifest_version="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.version');")" + [ "$manifest_version" = "$(cat "$SK/VERSION")" ] + + # bootstrap_version: a fixed constant embedded in + # both agmsgd and agmsgd-launch.sh, distinct from the overall install + # version/gen, and copied into the manifest for the real entrypoint (a + # later PR) to check against under the operation lock. + local bv + bv="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.bootstrap_version');")" + [ "$bv" = "1" ] + + # install_id's one source of truth is install.db's meta row; the manifest + # only carries a copy of it. + local db_id + db_id="$(sqlite3 "$SK/run/install.db" "SELECT install_id FROM meta LIMIT 1;")" + [ "$db_id" = "$id1" ] + + # The lock DB holds no tables of its own -- it exists only to be BEGIN + # EXCLUSIVE'd. + local lock_tables + lock_tables="$(sqlite3 "$SK/run/install-op.lock.db" "SELECT count(*) FROM sqlite_master WHERE type='table';")" + [ "$lock_tables" = "0" ] + + # The two daemon bootstrap files were placed (not left out by the bulk + # copy's own exclusion) and are executable, and their digests are in the + # manifest under their scripts/-relative path. + [ -x "$SK/scripts/daemon/agmsgd" ] + [ -x "$SK/scripts/daemon/agmsgd-launch.sh" ] + local listed + listed="$(sqlite3 :memory: "SELECT count(*) FROM json_each(json_extract(readfile('$(rf "$manifest")'), '\$.files')) WHERE json_extract(value,'\$.path') IN ('scripts/daemon/agmsgd','scripts/daemon/agmsgd-launch.sh');")" + [ "$listed" = "2" ] + + # A real file's manifest digest matches an independent sha256 of it right + # now -- not just "a digest is present for it". + local recorded_digest actual_digest + recorded_digest="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.files[0].digest');")" + local first_path + first_path="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.files[0].path');")" + actual_digest="$(bash -c 'source "$1/scripts/lib/hash.sh"; agmsg_sha256 < "$2"' _ "$REPO_ROOT" "$SK/$first_path")" + [ -n "$actual_digest" ] + [ "$recorded_digest" = "$actual_digest" ] + + mkdir -p "$SK/scripts/drivers/terminals/herdr" + printf 'stale\n' > "$SK/scripts/drivers/terminals/herdr/SKILL.md" + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --update + [ -f "$manifest" ] + [ -f "$manifest.prev" ] + [ ! -e "$SK/scripts/drivers/terminals/herdr/SKILL.md" ] + local id2 gen2 + id2="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.install_id');")" + gen2="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.gen');")" + [ "$id2" = "$id1" ] + [ "$gen2" = "2" ] + manifest_version="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.version');")" + [ "$manifest_version" = "$(cat "$SK/VERSION")" ] + local stale_listed + stale_listed="$(sqlite3 :memory: "SELECT count(*) FROM json_each(json_extract(readfile('$(rf "$manifest")'), '\$.files')) WHERE json_extract(value,'\$.path')='scripts/drivers/terminals/herdr/SKILL.md';")" + [ "$stale_listed" = "0" ] + db_id="$(sqlite3 "$SK/run/install.db" "SELECT install_id FROM meta LIMIT 1;")" + [ "$db_id" = "$id1" ] + + # A readable previous generation can recover a damaged current manifest. + printf 'not json\n' > "$manifest" + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --update + gen2="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.gen');")" + [ "$gen2" = "2" ] + + # With neither record readable, update must stop before touching VERSION or + # the installed scripts instead of silently reusing generation 1. + printf 'not json\n' > "$manifest" + printf 'not json\n' > "$manifest.prev" + local unchanged_digest before_version + before_version="$(cat "$SK/VERSION")" + unchanged_digest="$(shasum -a 256 "$SK/scripts/team.sh" | awk '{print $1}')" + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --update + [ "$status" -ne 0 ] + grep -qF -- "manifest and its previous copy are unreadable" <<<"$output" + [ "$(cat "$SK/VERSION")" = "$before_version" ] + [ "$(shasum -a 256 "$SK/scripts/team.sh" | awk '{print $1}')" = "$unchanged_digest" ] +} + +@test "install manifest batches safe names and checks its probe before publishing" { + local scripts="$FAKE_HOME/manifest-scripts" safe_scripts="$FAKE_HOME/safe-manifest-scripts" + local manifest="$FAKE_HOME/manifest.json" + local bad_manifest="$FAKE_HOME/bad-manifest.json" fake_bin="$FAKE_HOME/fake-bin" + mkdir -p "$scripts" "$fake_bin" + printf 'safe file\n' > "$scripts/safe.sh" + printf 'space file\n' > "$scripts/with space.sh" + + run bash -c '. "$1/scripts/lib/install-manifest.sh"; agmsg_install_manifest_write "$2" "$3" 1.5.1 test-install 1 1' \ + _ "$REPO_ROOT" "$scripts" "$manifest" + [ "$status" -eq 0 ] + local recorded_digest actual_digest + recorded_digest="$(sqlite3 :memory: "SELECT json_extract(value, '\$.digest') FROM json_each(json_extract(readfile('$(rf "$manifest")'), '\$.files')) WHERE json_extract(value, '\$.path')='scripts/with space.sh';")" + actual_digest="$(bash -c 'source "$1/scripts/lib/hash.sh"; agmsg_sha256 < "$2"' _ "$REPO_ROOT" "$scripts/with space.sh")" + [ "$recorded_digest" = "$actual_digest" ] + + mkdir -p "$safe_scripts" + printf 'safe file\n' > "$safe_scripts/safe.sh" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [ "${1:-}" = "-a" ]; then shift 2; fi' \ + 'for path do printf "%064d %s\\n" 0 "$path"; done' > "$fake_bin/shasum" + chmod +x "$fake_bin/shasum" + run env PATH="$fake_bin:$PATH" bash -c '. "$1/scripts/lib/install-manifest.sh"; agmsg_install_manifest_write "$2" "$3" 1.5.1 test-install 1 1' \ + _ "$REPO_ROOT" "$safe_scripts" "$bad_manifest" + [ "$status" -ne 0 ] + grep -qF -- 'returned the wrong digest for the install-manifest probe' <<<"$output" + [ ! -e "$bad_manifest" ] +} + +@test "install holds the operation lock while rendering the shared skill" { + local bin="$FAKE_HOME/bin" entered="$FAKE_HOME/render-entered" release="$FAKE_HOME/render-release" + local awk_real install_pid install_rc blocked=0 i + awk_real="$(command -v awk)" + mkdir -p "$bin" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'for arg do case "$arg" in fragment=*) touch "$AGMSG_TEST_RENDER_ENTERED"; while [ ! -e "$AGMSG_TEST_RENDER_RELEASE" ]; do sleep 0.02; done ;; esac; done' \ + 'exec "$AGMSG_TEST_REAL_AWK" "$@"' > "$bin/awk" + chmod +x "$bin/awk" + + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_RENDER_ENTERED="$entered" AGMSG_TEST_RENDER_RELEASE="$release" \ + AGMSG_TEST_REAL_AWK="$awk_real" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg > "$FAKE_HOME/install.out" 2>&1 & + install_pid=$! + _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --cmd agmsg" + + for ((i = 0; i < 250; i++)); do + [ -e "$entered" ] && break + kill -0 "$install_pid" 2>/dev/null || break + sleep 0.02 + done + if [ -e "$entered" ]; then + if bash -c 'source "$1"; if agmsg_install_op_lock "$2" 300; then agmsg_install_op_unlock; exit 3; else exit 0; fi' \ + _ "$REPO_ROOT/scripts/lib/install-op-lock.sh" "$SK/run/install-op.lock.db"; then + blocked=1 + fi + fi + touch "$release" + if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi + + [ -e "$entered" ] + [ "$blocked" -eq 1 ] + [ "$install_rc" -eq 0 ] + [ -f "$SK/run/install-manifest.json" ] +} + +@test "install stops before pruning when the lock child dies during scripts copy" { + local bin="$FAKE_HOME/bin" inject_dir="$FAKE_HOME/lock-loss" + local sqlite_real cp_real sqlite_q cp_q lock_pid lock_cmd install_pid install_rc=0 + local before_version manifest="$SK/run/install-manifest.json" op_id i + sqlite_real="$(command -v sqlite3)" + cp_real="$(command -v cp)" + sqlite_q="$(printf '%q' "$sqlite_real")" + cp_q="$(printf '%q' "$cp_real")" + mkdir -p "$bin" "$inject_dir" + + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --cmd agmsg + printf 'retired test file\n' > "$SK/scripts/retired-test-file" + before_version="$(cat "$SK/VERSION")" + [ -s "$manifest" ] + + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/lock-loss}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'if [ "${1:-}" = "$lock_db" ]; then printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid"; fi' \ + "exec $sqlite_q \"\$@\"" > "$bin/sqlite3" + chmod +x "$bin/sqlite3" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/lock-loss}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'target="$test_home/.agents/skills/agmsg/scripts/"' \ + 'dest=""; for arg do dest="$arg"; done' \ + 'if [ "$dest" = "$target" ] && [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/fired" ]; then' \ + ' lock_pid="$(cat "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid" 2>/dev/null)"' \ + ' case "$lock_pid" in ""|*[!0-9]*) lock_pid="" ;; esac' \ + ' if [ -n "$lock_pid" ]; then' \ + ' lock_cmd="$(/bin/ps -p "$lock_pid" -o args= 2>/dev/null)"' \ + ' case "$lock_cmd" in *"$lock_db"*) kill -9 "$lock_pid" 2>/dev/null && touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/fired" ;; esac' \ + ' fi' \ + ' touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/copy-entered"' \ + ' while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/copy-release" ]; do sleep 0.02; done' \ + 'fi' \ + "exec $cp_q \"\$@\"" > "$bin/cp" + chmod +x "$bin/cp" + + env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$REPO_ROOT/install.sh" --update > "$inject_dir/install.out" 2>&1 & + install_pid=$! + _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/copy-entered" ] && break + kill -0 "$install_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/copy-entered" ] + [ -e "$inject_dir/fired" ] + [ -e "$SK/run/install-op-incomplete.json" ] + op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/uninstall.sh" --cmd agmsg --keep-data --yes + [ "$status" -ne 0 ] + grep -qF -- "earlier install operation (update) is incomplete; uninstall (keep-data) will not start" <<<"$output" + grep -qF -- "--recover $op_id" <<<"$output" + touch "$inject_dir/copy-release" + if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi + [ "$install_rc" -ne 0 ] + grep -qF "install lock was lost partway through" "$inject_dir/install.out" + [ -f "$SK/scripts/retired-test-file" ] + [ "$(cat "$SK/VERSION")" = "$before_version" ] + [ ! -e "$manifest" ] + [ -s "$manifest.prev" ] + + run bash -c 'exec "$@" 2>&1' _ env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/uninstall.sh" --cmd agmsg --keep-data --yes --recover "$op_id" + [ "$status" -eq 0 ] + grep -qF -- "Recorded operation: install (update)" <<<"$output" + grep -qF -- "Continuing requested operation: uninstall (keep-data)" <<<"$output" + [ ! -e "$SK/run/install-op-incomplete.json" ] + [ ! -e "$SK/scripts" ] + [ ! -e "$SK/SKILL.md" ] + [ ! -e "$SK/run/install-op-recovery.sh" ] +} + +@test "install cancellation waits for the active writer before clearing its operation record" { + local bin="$FAKE_HOME/bin" inject_dir="$FAKE_HOME/cancel-writer" + local cp_real cp_q sqlite_real sqlite_q install_pid writer_pid lock_pid install_rc=0 i op_id stage_source stage_dir tmp_root + cp_real="$(command -v cp)" + cp_q="$(printf '%q' "$cp_real")" + sqlite_real="$(command -v sqlite3)" + sqlite_q="$(printf '%q' "$sqlite_real")" + mkdir -p "$bin" "$inject_dir" + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --cmd agmsg + + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/cancel-writer}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'if [ "${1:-}" = "$lock_db" ]; then printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid"; fi' \ + "exec $sqlite_q \"\$@\"" > "$bin/sqlite3" + chmod +x "$bin/sqlite3" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/cancel-writer}"' \ + 'target="$test_home/.agents/skills/agmsg/scripts/"' \ + 'dest=""; for arg do dest="$arg"; done' \ + 'if [ "$dest" = "$target" ]; then exec 8>&- 9>&-; printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer.pid"; touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer-entered"; if [ -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-kill-mode" ]; then printf "%s\\n" "$2" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/stage-source"; while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-release" ]; do sleep 0.02; done; exit 0; fi; while :; do sleep 1; done; fi' \ + "exec $cp_q \"\$@\"" > "$bin/cp" + chmod +x "$bin/cp" + + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$REPO_ROOT/install.sh" --update > "$inject_dir/install.out" 2>&1 & + install_pid=$! + _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/writer-entered" ] && break + kill -0 "$install_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/writer-entered" ] + writer_pid="$(cat "$inject_dir/writer.pid")" + _agmsg_watch_pid "$writer_pid" "$bin/cp" + [ -e "$SK/run/install-op-incomplete.json" ] + + kill -TERM "$install_pid" + if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi + [ "$install_rc" -eq 143 ] + refute kill -0 "$writer_pid" 2>/dev/null + [ ! -e "$SK/run/install-op-incomplete.json" ] + [ -f "$SK/run/install-manifest.json.prev" ] + + touch "$inject_dir/hard-kill-mode" + rm -f "$inject_dir/writer-entered" "$inject_dir/hard-release" + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$REPO_ROOT/install.sh" --update > "$inject_dir/hard-kill-install.out" 2>&1 & + install_pid=$! + _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/writer-entered" ] && break + kill -0 "$install_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/writer-entered" ] + writer_pid="$(cat "$inject_dir/writer.pid")" + lock_pid="$(cat "$inject_dir/lock.pid")" + _agmsg_watch_pid "$writer_pid" "$bin/cp" + op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" + kill -KILL "$install_pid" + if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi + [ "$install_rc" -eq 137 ] + kill -0 "$writer_pid" 2>/dev/null + wait_for_pid_exit "$lock_pid" + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/uninstall.sh" --cmd agmsg --keep-data --yes + [ "$status" -ne 0 ] + grep -qF -- "earlier install operation (update) is incomplete" <<<"$output" + kill -0 "$writer_pid" 2>/dev/null + stage_source="$(cat "$inject_dir/stage-source")" + case "$stage_source" in */.) stage_dir="${stage_source%/.}" ;; *) return 1 ;; esac + touch "$inject_dir/hard-release" + wait_for_pid_exit "$writer_pid" + tmp_root="${TMPDIR:-/tmp}" + tmp_root="${tmp_root%/}" + case "$stage_dir" in "$tmp_root"/tmp.*) rm -rf "$stage_dir" ;; *) return 1 ;; esac + [ -e "$SK/run/install-op-incomplete.json" ] + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg --update --recover "$op_id" + [ "$status" -eq 0 ] + [ ! -e "$SK/run/install-op-incomplete.json" ] +} + +@test "install cancellation before writer pid publication keeps the incomplete-operation record" { + local bin="$FAKE_HOME/bin" inject_dir="$FAKE_HOME/pid-publication" + local cp_real cp_q install_pid writer_pid install_rc=0 i op_id + cp_real="$(command -v cp)" + cp_q="$(printf '%q' "$cp_real")" + mkdir -p "$bin" "$inject_dir" + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --cmd agmsg + + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'dest=""; for arg do dest="$arg"; done' \ + 'if [ "$dest" = "'"$SK"'/scripts/" ]; then printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_WRITER_GATE_DIR/writer.pid"; touch "$AGMSG_TEST_INSTALL_WRITER_GATE_DIR/writer-entered"; while [ ! -e "$AGMSG_TEST_INSTALL_WRITER_GATE_DIR/writer-release" ]; do sleep 0.02; done; fi' \ + "exec $cp_q \"\$@\"" > "$bin/cp" + chmod +x "$bin/cp" + + env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_WRITER_GATE_DIR="$inject_dir" \ + AGMSG_TEST_INSTALL_OP_WRITER_PID_PUBLISH_GATE="$inject_dir/publish" \ + bash "$REPO_ROOT/install.sh" --update > "$inject_dir/install.out" 2>&1 & + install_pid=$! + _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/publish.pid" ] && [ -e "$inject_dir/writer-entered" ] && break + kill -0 "$install_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/publish.pid" ] + [ -e "$inject_dir/writer-entered" ] + writer_pid="$(cat "$inject_dir/writer.pid")" + _agmsg_watch_pid "$writer_pid" "$bin/cp" + [ -e "$SK/run/install-op-incomplete.json" ] + op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" + + kill -TERM "$install_pid" + if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi + [ "$install_rc" -eq 143 ] + kill -0 "$writer_pid" 2>/dev/null + [ -e "$SK/run/install-op-incomplete.json" ] + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg --update + [ "$status" -ne 0 ] + grep -qF -- "earlier install operation (update) is incomplete" <<<"$output" + grep -qF -- "--recover $op_id" <<<"$output" + + touch "$inject_dir/writer-release" + wait_for_pid_exit "$writer_pid" + [ -e "$SK/run/install-op-incomplete.json" ] + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg --update --recover "$op_id" + [ "$status" -eq 0 ] + [ ! -e "$SK/run/install-op-incomplete.json" ] +} + +@test "uninstall keeps run/install-op.lock.db while removing everything else" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + [ -f "$SK/run/install-op.lock.db" ] + [ -f "$SK/run/install.db" ] + [ -f "$SK/scripts/team.sh" ] + + HOME="$FAKE_HOME" bash "$REPO_ROOT/uninstall.sh" --yes + + [ -f "$SK/run/install-op.lock.db" ] + [ ! -e "$SK/run/install.db" ] + [ ! -e "$SK/run/install-manifest.json" ] + [ ! -e "$SK/scripts" ] + [ ! -e "$SK/SKILL.md" ] +} + +@test "uninstall preserves recovery through removal and cannot delete a later install entrypoint" { + local bin="$FAKE_HOME/bin" inject_dir="$FAKE_HOME/lock-loss" + local sqlite_real rm_real mv_real sqlite_q rm_q mv_q uninstall_pid uninstall_rc=0 lock_pid lock_cmd op_id moved_op_id i retired_entrypoint candidate + sqlite_real="$(command -v sqlite3)" + rm_real="$(command -v rm)" + mv_real="$(command -v mv)" + sqlite_q="$(printf '%q' "$sqlite_real")" + rm_q="$(printf '%q' "$rm_real")" + mv_q="$(printf '%q' "$mv_real")" + mkdir -p "$bin" "$inject_dir" + + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --cmd agmsg + [ -f "$SK/SKILL.md" ] + [ -d "$SK/scripts" ] + + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/lock-loss}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'if [ "${1:-}" = "$lock_db" ]; then printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid"; fi' \ + "exec $sqlite_q \"\$@\"" > "$bin/sqlite3" + chmod +x "$bin/sqlite3" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/lock-loss}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'target="$test_home/.agents/skills/agmsg/scripts"' \ + 'final_root="$test_home/.agents/skills/agmsg/uninstall.sh"' \ + 'final_retired="$test_home/.agents/skills/agmsg/.install-op-uninstaller-retired."' \ + 'has_target=false; has_final=false; for arg do [ "$arg" = "$target" ] && has_target=true; case "$arg" in "$final_root"|"$final_retired"*) has_final=true ;; esac; done' \ + 'if [ "$has_target" = true ] && [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/fired" ]; then' \ + ' lock_pid="$(cat "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid" 2>/dev/null)"' \ + ' case "$lock_pid" in ""|*[!0-9]*) lock_pid="" ;; esac' \ + ' if [ -n "$lock_pid" ]; then' \ + ' lock_cmd="$(/bin/ps -p "$lock_pid" -o args= 2>/dev/null)"' \ + ' case "$lock_cmd" in *"$lock_db"*) kill -9 "$lock_pid" 2>/dev/null && touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/fired" ;; esac' \ + ' fi' \ + ' touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/rm-entered"' \ + ' while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/rm-release" ]; do sleep 0.02; done' \ + 'fi' \ + 'if [ "$has_final" = true ] && [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/final-fired" ]; then' \ + ' lock_pid="$(cat "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid" 2>/dev/null)"' \ + ' case "$lock_pid" in ""|*[!0-9]*) lock_pid="" ;; esac' \ + ' if [ -n "$lock_pid" ]; then' \ + ' lock_cmd="$(/bin/ps -p "$lock_pid" -o args= 2>/dev/null)"' \ + ' case "$lock_cmd" in *"$lock_db"*) kill -9 "$lock_pid" 2>/dev/null && touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/final-fired" ;; esac' \ + ' fi' \ + ' touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/final-rm-entered"' \ + ' while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/final-rm-release" ]; do sleep 0.02; done' \ + 'fi' \ + "exec $rm_q \"\$@\"" > "$bin/rm" + chmod +x "$bin/rm" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/lock-loss}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'source_path="$1"; dest_path="$2"' \ + 'case "$source_path:$dest_path" in "$test_home/.agents/skills/agmsg/uninstall.sh:$test_home/.agents/skills/agmsg/.install-op-uninstaller-retired."*)' \ + ' if [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/move-fired" ]; then' \ + ' lock_pid="$(cat "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid" 2>/dev/null)"' \ + ' case "$lock_pid" in ""|*[!0-9]*) lock_pid="" ;; esac' \ + ' if [ -n "$lock_pid" ]; then' \ + ' lock_cmd="$(/bin/ps -p "$lock_pid" -o args= 2>/dev/null)"' \ + ' case "$lock_cmd" in *"$lock_db"*) kill -9 "$lock_pid" 2>/dev/null && touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/move-fired" ;; esac' \ + ' fi' \ + ' fi ;;' \ + 'esac' \ + "exec $mv_q \"\$@\"" > "$bin/mv" + chmod +x "$bin/mv" + + env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$SK/uninstall.sh" --yes > "$inject_dir/uninstall.out" 2>&1 & + uninstall_pid=$! + _agmsg_watch_pid "$uninstall_pid" "$SK/uninstall.sh --yes" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/rm-entered" ] && break + kill -0 "$uninstall_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/rm-entered" ] + [ -e "$inject_dir/fired" ] + [ -e "$SK/run/install-op-incomplete.json" ] + op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" + [ -x "$SK/uninstall.sh" ] + [ -r "$SK/run/install-op-recovery.sh" ] + run bash -c 'exec "$@" 2>&1' _ env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg --update + [ "$status" -ne 0 ] + grep -qF -- "earlier uninstall operation (remove-data) is incomplete; install (update) will not start" <<<"$output" + grep -qF -- "--recover $op_id" <<<"$output" + touch "$inject_dir/rm-release" + if wait "$uninstall_pid"; then uninstall_rc=0; else uninstall_rc=$?; fi + [ "$uninstall_rc" -ne 0 ] + grep -qF "install lock was lost partway through" "$inject_dir/uninstall.out" + # The full uninstall walks top-level entries in locale-dependent glob + # order. This injection stops immediately after removing scripts, so other + # entries may legitimately remain if they sort after scripts. + [ ! -e "$SK/scripts" ] + [ -f "$SK/run/install-op.lock.db" ] + [ -x "$SK/uninstall.sh" ] + [ -r "$SK/run/install-op-recovery.sh" ] + + env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$SK/uninstall.sh" --cmd agmsg --yes --recover "$op_id" > "$inject_dir/recovery.out" 2>&1 & + uninstall_pid=$! + _agmsg_watch_pid "$uninstall_pid" "$SK/uninstall.sh --cmd agmsg --yes --recover $op_id" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/move-fired" ] && break + kill -0 "$uninstall_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/move-fired" ] + if wait "$uninstall_pid"; then uninstall_rc=0; else uninstall_rc=$?; fi + [ "$uninstall_rc" -ne 0 ] + grep -qF "install lock was lost partway through" "$inject_dir/recovery.out" + [ -e "$SK/run/install-op-incomplete.json" ] + moved_op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" + retired_entrypoint="" + for candidate in "$SK"/.install-op-uninstaller-retired.*; do + [ -e "$candidate" ] || continue + retired_entrypoint="$candidate" + break + done + [ -f "$retired_entrypoint" ] + [ ! -e "$SK/uninstall.sh" ] + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/uninstall.sh" --cmd agmsg --yes + [ "$status" -ne 0 ] + grep -qF -- "operation: uninstall" <<<"$output" + grep -qF -- "mode: remove-data" <<<"$output" + grep -qF -- "Recovery command: bash $retired_entrypoint --cmd agmsg --yes --recover $moved_op_id" <<<"$output" + env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$retired_entrypoint" --cmd agmsg --yes --recover "$moved_op_id" > "$inject_dir/final-recovery.out" 2>&1 & + uninstall_pid=$! + _agmsg_watch_pid "$uninstall_pid" "$retired_entrypoint --cmd agmsg --yes --recover $moved_op_id" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/final-rm-entered" ] && break + kill -0 "$uninstall_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/final-rm-entered" ] + [ -e "$inject_dir/final-fired" ] + [ ! -e "$SK/run/install-op-incomplete.json" ] + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg + [ "$status" -eq 0 ] + [ -x "$SK/uninstall.sh" ] + [ -f "$SK/run/install-op-recovery.sh" ] + touch "$inject_dir/final-rm-release" + wait_for_pid_exit "$uninstall_pid" + diff "$REPO_ROOT/uninstall.sh" "$SK/uninstall.sh" + [ -f "$SK/run/install-op-recovery.sh" ] +} + @test "no rendered skill of any type still carries the unwired 'supplied by the type overlay' comment" { # The shared root SKILL.md used to carry two lines that read like slot # markers right after the spawn slot -- "shared actas/drop guidance is diff --git a/tests/test_install_op_lock.bats b/tests/test_install_op_lock.bats new file mode 100644 index 000000000..74eb79cbe --- /dev/null +++ b/tests/test_install_op_lock.bats @@ -0,0 +1,112 @@ +#!/usr/bin/env bats + +# The install/uninstall operation lock (agmsgd beta): a +# single sqlite3 coprocess bash keeps alive across a whole install.sh / +# uninstall.sh run, fed through a pair of named pipes so BEGIN EXCLUSIVE +# stays open while bash does its own work in between. This file also covers +# the case where only the sqlite3 lock-holding child dies while bash remains +# alive and unaware. + +load test_helper + +setup() { + setup_test_env + LOCKLIB="$SCRIPTS/lib/install-op-lock.sh" + LOCK_DB="$BATS_TEST_TMPDIR/install-op.lock.db" +} + +@test "install-op-lock: acquires, confirms, blocks a second acquirer, and releases cleanly" { + run bash -c ' + source "$1" + agmsg_install_op_lock "$2" 3000 || { echo "acquire failed"; exit 1; } + agmsg_install_op_confirm || { echo "confirm failed while alive"; exit 1; } + echo "held pid=$_AGMSG_LOCK_PID" + + # A second, independent process trying the SAME db must fail fast, not + # hang for the full busy_timeout of its own accord succeeding. + ( source "$1" + if agmsg_install_op_lock "$2" 500; then + echo "second UNEXPECTEDLY acquired" + exit 1 + fi + echo "second correctly failed to acquire" + ) + + agmsg_install_op_unlock + + # The lock DB is never deleted and must be reusable immediately after. + agmsg_install_op_lock "$2" 3000 || { echo "re-acquire after unlock failed"; exit 1; } + agmsg_install_op_unlock + echo "re-acquire after unlock ok" + ' _ "$LOCKLIB" "$LOCK_DB" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qF "held pid=" + printf '%s\n' "$output" | grep -qF "second correctly failed to acquire" + printf '%s\n' "$output" | grep -qF "re-acquire after unlock ok" +} + +@test "install-op-lock: detects when only the lock-holding sqlite3 child dies" { + run bash -c ' + source "$1" + agmsg_install_op_lock "$2" 3000 || { echo "acquire failed"; exit 1; } + + # Kill ONLY the sqlite3 coprocess -- this process (the "bash" role in + # This process stays alive and unaware, as after a child-only crash + # during an install operation. + kill -9 "$_AGMSG_LOCK_PID" + sleep 0.3 + + if agmsg_install_op_confirm; then + echo "confirm WRONGLY reported the lock still held" + exit 1 + fi + echo "confirm correctly detected the dead lock-holder" + + # The OS file lock must be genuinely gone -- a fresh, independent + # acquirer (standing in for a retried operation) can now get it. This is + # the property that makes "abort rather than continue unprotected" safe + # to do here instead of trying to resurrect the same lock. + ( source "$1" + if ! agmsg_install_op_lock "$2" 3000; then + echo "independent re-acquire after the child died UNEXPECTEDLY failed" + exit 1 + fi + agmsg_install_op_unlock + echo "independent re-acquire after the child died ok" + ) + ' _ "$LOCKLIB" "$LOCK_DB" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qF "confirm correctly detected the dead lock-holder" + printf '%s\n' "$output" | grep -qF "independent re-acquire after the child died ok" +} + +@test "install-op-lock: unlock does not silently redirect the caller's stderr for the rest of the script" { + # Regression test: `exec 9>&- 2>/dev/null` (no command -- redirections on + # a bare `exec` apply to the CURRENT SHELL, not to that one statement) + # silently sent every later stderr write in the calling script to + # /dev/null for good, including a completely unrelated command's own + # error message. Caught because install.sh's own Codex-shim ownership + # refusal (written to stderr) stopped appearing in its output after any + # lock/unlock cycle earlier in the same run. + run bash -c ' + source "$1" + agmsg_install_op_lock "$2" 3000 || { echo "acquire failed"; exit 1; } + agmsg_install_op_unlock + echo "stderr still works after unlock" >&2 + ' _ "$LOCKLIB" "$LOCK_DB" + [ "$status" -eq 0 ] + [[ "$output" == *"stderr still works after unlock"* ]] +} + +@test "install-op-lock: the lock DB never holds any tables of its own" { + run bash -c ' + source "$1" + agmsg_install_op_lock "$2" 3000 || { echo "acquire failed"; exit 1; } + agmsg_install_op_unlock + ' _ "$LOCKLIB" "$LOCK_DB" + [ "$status" -eq 0 ] + [ -f "$LOCK_DB" ] + run sqlite3 "$LOCK_DB" "SELECT count(*) FROM sqlite_master WHERE type='table';" + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} diff --git a/tests/test_spawn_fd_guard.bats b/tests/test_spawn_fd_guard.bats index 7e61e6412..2d10a1722 100644 --- a/tests/test_spawn_fd_guard.bats +++ b/tests/test_spawn_fd_guard.bats @@ -28,7 +28,10 @@ _unguarded_spawns() { ;; *) printf '%s:%s: closes neither fd 3 nor fd 4 -- %s\n' "$file" "$line" "$rest" ;; esac - done < <(grep -rnE '^[^#]*[^&|]&[[:space:]]*$' "$root" 2>/dev/null) + done < <( + grep -rnE '^[^#]*[^&|]&[[:space:]]*$' "$root" 2>/dev/null + grep -rnE '^[^#]*coproc[[:space:]]' "$root" 2>/dev/null + ) } @test "every background spawn under scripts/ closes bats' fd 3 and fd 4" { @@ -44,10 +47,13 @@ _unguarded_spawns() { # Without this, a pattern that quietly stopped matching would leave a test # passing because it found nothing -- the failure being guarded against is a # silent one, so the count is asserted rather than assumed. - local total + local total coproc_total total="$(grep -rcE '^[^#]*[^&|]&[[:space:]]*$' "$REPO_ROOT/scripts" 2>/dev/null \ | awk -F: '{s+=$2} END {print s+0}')" + coproc_total="$(grep -rcE '^[^#]*coproc[[:space:]]' "$REPO_ROOT/scripts" 2>/dev/null \ + | awk -F: '{s+=$2} END {print s+0}')" [ "$total" -ge 5 ] + [ "$coproc_total" -ge 1 ] } @test "a spawn closing only fd 3 is reported" { diff --git a/uninstall.sh b/uninstall.sh index a6e7b11ea..5ba2e28ba 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -16,18 +16,64 @@ set -euo pipefail AGENTS_DIR="$HOME/.agents" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/scripts/lib/codex-config.sh" +AGMSG_INSTALL_OP_RECOVERY_SOURCE="" +if [ -e "$SCRIPT_DIR/run/install-op-incomplete.json" ] || [ -L "$SCRIPT_DIR/run/install-op-incomplete.json" ]; then + if [ -r "$SCRIPT_DIR/run/install-op-recovery.sh" ]; then + AGMSG_INSTALL_OP_RECOVERY_SOURCE="$SCRIPT_DIR/run/install-op-recovery.sh" + else + for _agmsg_recovery_candidate in "$SCRIPT_DIR"/run/.install-op-recovery-retired.*; do + [ -r "$_agmsg_recovery_candidate" ] || continue + AGMSG_INSTALL_OP_RECOVERY_SOURCE="$_agmsg_recovery_candidate" + break + done + unset _agmsg_recovery_candidate + fi +fi +if [ -n "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then + # Prefer the atomically-published recovery code while an operation record + # exists; scripts/ may be incomplete after an interrupted update. + # shellcheck disable=SC1090 + . "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" +elif [ -r "$SCRIPT_DIR/scripts/lib/codex-config.sh" ] && [ -r "$SCRIPT_DIR/scripts/lib/install-op-lock.sh" ]; then + # shellcheck disable=SC1091 + . "$SCRIPT_DIR/scripts/lib/codex-config.sh" + # The same operation lock install.sh takes (agmsgd beta). + # shellcheck disable=SC1091 + . "$SCRIPT_DIR/scripts/lib/install-op-lock.sh" +else + AGMSG_INSTALL_OP_RECOVERY_SOURCE="$SCRIPT_DIR/run/install-op-recovery.sh" + if [ ! -r "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then + for _agmsg_recovery_candidate in "$SCRIPT_DIR"/run/.install-op-recovery-retired.*; do + [ -r "$_agmsg_recovery_candidate" ] || continue + AGMSG_INSTALL_OP_RECOVERY_SOURCE="$_agmsg_recovery_candidate" + break + done + unset _agmsg_recovery_candidate + fi + if [ ! -r "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then + echo " ! uninstall support files are missing; restore the install before continuing" >&2 + exit 1 + fi + # This fallback remains under run/ while an uninstall is incomplete, so an + # installed copy can recover after --keep-data removed scripts/. + # shellcheck disable=SC1090 + . "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" +fi AUTO_YES=false KEEP_DATA=false REMOVE_ALL=false +CMD_NAME="" +RECOVER_ID="" +AGMSG_INSTALL_OP_ACTIVE=false while [[ $# -gt 0 ]]; do case "$1" in --yes|-y) AUTO_YES=true; shift ;; --keep-data) KEEP_DATA=true; shift ;; --all) REMOVE_ALL=true; shift ;; + --cmd) CMD_NAME="$2"; shift 2 ;; + --recover) RECOVER_ID="$2"; shift 2 ;; -h|--help) echo "Usage: ./uninstall.sh [options]" echo "" @@ -35,12 +81,19 @@ while [[ $# -gt 0 ]]; do echo " --yes, -y Remove without confirmation" echo " --keep-data Remove skill but keep DB and team configs" echo " --all Remove every agmsg install on the machine" + echo " --cmd Select one installation under ~/.agents/skills" + echo " --recover Clear a verified incomplete operation, then continue uninstall" exit 0 ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac done +if [ -n "$RECOVER_ID" ] && { [ -z "$CMD_NAME" ] || [ "$REMOVE_ALL" = true ]; }; then + echo " ! --recover requires --cmd and cannot be combined with --all" >&2 + exit 1 +fi + echo "" echo " agmsg — Uninstall" echo " ──────────────────" @@ -55,6 +108,33 @@ confirm() { REMOVED=false +_uninstall_operation_exit() { + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ]; then + agmsg_install_op_unlock + AGMSG_INSTALL_OP_ACTIVE=false + fi +} +trap '_uninstall_operation_exit' EXIT +trap 'agmsg_install_op_handle_signal INT' INT +trap 'agmsg_install_op_handle_signal TERM' TERM + +_uninstall_operation_require() { + if agmsg_install_op_require; then + return 0 + fi + agmsg_install_op_unlock + return 1 +} + +_uninstall_checked_rm() { + _uninstall_operation_require || return 1 + if ! agmsg_install_op_run_writer rm "$@"; then + agmsg_install_op_unlock + return 1 + fi + _uninstall_operation_require +} + # Removes this install's own writable_roots entries (SKILL_DIR's db/, # teams/, run/, ext-tools/) from ONE Codex config.toml, if it exists and # actually mentions them. Split out of _uninstall_one so it can be applied @@ -126,8 +206,11 @@ _uninstall_clean_codex_config() { if cmp -s "$CODEX_CONFIG" "$CODEX_CONFIG.tmp"; then rm -f "$CODEX_CONFIG.tmp" else + _uninstall_operation_require || return 1 cp "$CODEX_CONFIG" "$CODEX_CONFIG.bak" + _uninstall_operation_require || return 1 mv "$CODEX_CONFIG.tmp" "$CODEX_CONFIG" + _uninstall_operation_require || return 1 echo " - cleaned Codex writable_roots in $CODEX_CONFIG (backup: $(basename "$CODEX_CONFIG").bak)" REMOVED=true fi @@ -140,6 +223,55 @@ _uninstall_clean_codex_config() { _uninstall_one() { local SKILL_DIR="$1" local SKILL_NAME; SKILL_NAME="$(basename "$SKILL_DIR")" + + # Operation lock (agmsgd beta): held for the whole of this function, + # the same lock install.sh takes for the same + # SKILL_DIR. A failure here means another install/uninstall/enable/ + # disable is already in progress against this exact install -- refuse + # rather than race it. No trap releases this on an unexpected abort: the + # OS drops the file lock the moment this process dies; there is no + # stale-lock recovery step, so an uninstall + # that crashes mid-way is the same "held until the holder dies" state a + # crashed install.sh already leaves. + if ! agmsg_install_op_lock "$SKILL_DIR/run/install-op.lock.db"; then + echo " ! could not take the install operation lock for $SKILL_DIR: ${AGMSG_INSTALL_OP_LOCK_FAILURE_REASON:-unknown lock handshake failure}" >&2 + return 1 + fi + AGMSG_INSTALL_OP_ACTIVE=true + _uninstall_operation_require || return 1 + + local pending="$SKILL_DIR/run/install-op-incomplete.json" + local recovery_prefix recovery_entrypoint="$SKILL_DIR/uninstall.sh" running_entrypoint="" operation_mode=remove-data recovery_helper="$SKILL_DIR/run/install-op-recovery.sh" recovery_retired="" uninstaller_retired="" + if [ "$SCRIPT_DIR" = "$SKILL_DIR" ]; then + case "$(basename "$0")" in + .install-op-uninstaller-retired.*) running_entrypoint="$SKILL_DIR/$(basename "$0")" ;; + esac + fi + if [ ! -f "$recovery_entrypoint" ]; then + if [ -f "$pending" ] && agmsg_install_op_pending_validate "$pending"; then + local _recovery_entrypoint_candidate="$SKILL_DIR/.install-op-uninstaller-retired.$AGMSG_INSTALL_OP_PENDING_ID" + [ -f "$_recovery_entrypoint_candidate" ] && recovery_entrypoint="$_recovery_entrypoint_candidate" + unset _recovery_entrypoint_candidate + fi + fi + [ "$KEEP_DATA" = true ] && operation_mode=keep-data + recovery_prefix="bash $(printf '%q' "$recovery_entrypoint") --cmd $(printf '%q' "$(basename "$SKILL_DIR")")" + [ "$KEEP_DATA" = true ] && recovery_prefix="$recovery_prefix --keep-data" + [ "$AUTO_YES" = true ] && recovery_prefix="$recovery_prefix --yes" + recovery_prefix="$recovery_prefix --recover" + if [ -n "$RECOVER_ID" ]; then + agmsg_install_op_pending_recover "$pending" "$RECOVER_ID" uninstall "$operation_mode" || return 1 + RECOVER_ID="" + elif [ -e "$pending" ] || [ -L "$pending" ]; then + agmsg_install_op_pending_refuse "$pending" "$recovery_prefix" uninstall "$operation_mode" + return 1 + fi + AGMSG_INSTALL_OP_MARKER="$pending" + agmsg_install_op_pending_begin "$pending" uninstall "$SKILL_DIR" "" "$operation_mode" || return 1 + uninstaller_retired="$SKILL_DIR/.install-op-uninstaller-retired.$AGMSG_INSTALL_OP_ID" + if [ -n "$running_entrypoint" ] && [ ! -f "$SKILL_DIR/uninstall.sh" ]; then + uninstaller_retired="$running_entrypoint" + fi # This install's own path with its trailing slash (review): matching on # SKILL_NAME or a bare SKILL_DIR prefix is not a boundary -- "agmsg" is a # literal substring of "agmsg-second", and "$SKILL_DIR" (no trailing @@ -196,7 +328,7 @@ _uninstall_one() { [ -f "$cmd_file" ] || continue if grep -qF "$SKILL_DIR_SLASH" "$cmd_file" 2>/dev/null; then local cmd_name; cmd_name=$(basename "$cmd_file" .md) - rm "$cmd_file" + _uninstall_checked_rm "$cmd_file" || return 1 echo " - removed /$cmd_name command from $project" REMOVED=true fi @@ -246,7 +378,9 @@ _uninstall_one() { ); " 2>/dev/null) || true if [ -n "$UPDATED" ] && [ "$UPDATED" != "$SETTINGS_ESC" ]; then + _uninstall_operation_require || return 1 echo "$UPDATED" > "$settings_file" + _uninstall_operation_require || return 1 echo " - removed agmsg hook from $settings_file" REMOVED=true fi @@ -274,7 +408,7 @@ _uninstall_one() { [ -n "$project" ] || continue local copilot_hook="$project/.github/hooks/agmsg.json" if [ -f "$copilot_hook" ] && grep -qF "$SKILL_DIR_SLASH" "$copilot_hook" 2>/dev/null; then - rm "$copilot_hook" + _uninstall_checked_rm "$copilot_hook" || return 1 echo " - removed agmsg Copilot hook from $project" REMOVED=true fi @@ -307,7 +441,7 @@ _uninstall_one() { [ -n "$project" ] || continue local grok_rule="$project/.grok/rules/agmsg.md" if [ -f "$grok_rule" ] && grep -qF "$SKILL_DIR_SLASH" "$grok_rule" 2>/dev/null; then - rm "$grok_rule" + _uninstall_checked_rm "$grok_rule" || return 1 echo " - removed agmsg Grok Build rule from $project" REMOVED=true fi @@ -318,7 +452,7 @@ _uninstall_one() { # --- Remove Claude Code global command --- local CC_CMD="$HOME/.claude/commands/$SKILL_NAME.md" if [ -f "$CC_CMD" ]; then - rm "$CC_CMD" + _uninstall_checked_rm "$CC_CMD" || return 1 echo " - removed /$SKILL_NAME from ~/.claude/commands/" REMOVED=true fi @@ -326,7 +460,7 @@ _uninstall_one() { # --- Remove Copilot CLI skill --- local COPILOT_SKILL="$HOME/.copilot/skills/$SKILL_NAME" if [ -d "$COPILOT_SKILL" ]; then - rm -rf "$COPILOT_SKILL" + _uninstall_checked_rm -rf "$COPILOT_SKILL" || return 1 echo " - removed /$SKILL_NAME skill from ~/.copilot/skills/" REMOVED=true fi @@ -334,7 +468,7 @@ _uninstall_one() { # --- Remove Antigravity skill --- local ANTIGRAVITY_SKILL="$HOME/.gemini/config/skills/$SKILL_NAME" if [ -d "$ANTIGRAVITY_SKILL" ]; then - rm -rf "$ANTIGRAVITY_SKILL" + _uninstall_checked_rm -rf "$ANTIGRAVITY_SKILL" || return 1 echo " - removed /$SKILL_NAME skill from ~/.gemini/config/skills/" REMOVED=true fi @@ -343,7 +477,7 @@ _uninstall_one() { local helper for helper in "$AGENTS_DIR/$SKILL_NAME.ps1" "$AGENTS_DIR/$SKILL_NAME-run.sh"; do if [ -f "$helper" ]; then - rm "$helper" + _uninstall_checked_rm "$helper" || return 1 echo " - removed $helper" REMOVED=true fi @@ -353,16 +487,48 @@ _uninstall_one() { if [ "$KEEP_DATA" = true ]; then echo "" echo " Removing $SKILL_NAME skill (keeping DB and teams)..." - rm -rf "$SKILL_DIR/scripts" "$SKILL_DIR/templates" "$SKILL_DIR/agents" "$SKILL_DIR/.trash" - rm -f "$SKILL_DIR/SKILL.md" + _uninstall_checked_rm -rf "$SKILL_DIR/scripts" "$SKILL_DIR/templates" "$SKILL_DIR/agents" "$SKILL_DIR/.trash" || return 1 + _uninstall_checked_rm -f "$SKILL_DIR/SKILL.md" || return 1 echo " - removed scripts, templates, SKILL.md" echo " ~ preserved $SKILL_DIR/db/ and $SKILL_DIR/teams/" REMOVED=true else echo "" if confirm "Remove $SKILL_NAME (including DB and teams)?"; then - rm -rf "$SKILL_DIR" - echo " - removed $SKILL_DIR" + # run/install-op.lock.db is NEVER deleted, even here (agmsgd beta): + # removing a DB a waiter still has open makes a + # freshly recreated file of the same name a DIFFERENT lock than the + # one the waiter holds a reference to -- see install-op-lock.sh's own + # header). Remove every top-level entry EXCEPT run/ by name, then + # inside run/ remove everything except install-op.lock.db by name -- + # never a single recursive rm -rf "$SKILL_DIR" that cannot make this + # one exception. rmdir (not rm -rf) on SKILL_DIR itself: it correctly + # fails and is left in place, since run/install-op.lock.db means it + # is never truly empty after this. + local _entry _run_entry + for _entry in "$SKILL_DIR"/* "$SKILL_DIR"/.[!.]*; do + [ -e "$_entry" ] || continue + # Keep the installed recovery entrypoint available until the + # incomplete-operation record is cleared below. + case "$(basename "$_entry")" in + uninstall.sh|.install-op-uninstaller-retired.*) continue ;; + esac + if [ "$(basename "$_entry")" = "run" ]; then + for _run_entry in "$_entry"/*; do + [ -e "$_run_entry" ] || continue + case "$(basename "$_run_entry")" in + install-op.lock.db|install-op-incomplete.json|install-op-recovery.sh|.install-op-recovery-retired.*) continue ;; + esac + _uninstall_checked_rm -rf "$_run_entry" || return 1 + done + else + _uninstall_checked_rm -rf "$_entry" || return 1 + fi + done + unset _entry _run_entry + _uninstall_operation_require || return 1 + rmdir "$SKILL_DIR" 2>/dev/null || true + echo " - removed $SKILL_DIR (kept run/install-op.lock.db and the active operation record)" REMOVED=true fi fi @@ -375,7 +541,7 @@ _uninstall_one() { # agmsg_codex_config_paths, scripts/lib/codex-config.sh). local _codex_cfg while IFS= read -r _codex_cfg; do - _uninstall_clean_codex_config "$_codex_cfg" "$SKILL_DIR" + _uninstall_clean_codex_config "$_codex_cfg" "$SKILL_DIR" || return 1 done < <(agmsg_codex_config_paths) # --- Remove OpenCode, Hermes, and Grok Build skill files --- @@ -396,7 +562,7 @@ _uninstall_one() { _dedicated_dir="${_dedicated_dir_label%%|*}" _dedicated_label="${_dedicated_dir_label#*|}" if [ -f "$_dedicated_dir/SKILL.md" ]; then - rm -f "$_dedicated_dir/SKILL.md" + _uninstall_checked_rm -f "$_dedicated_dir/SKILL.md" || return 1 if rmdir "$_dedicated_dir" 2>/dev/null; then echo " - removed /$SKILL_NAME $_dedicated_label skill" else @@ -406,6 +572,47 @@ _uninstall_one() { fi done unset _dedicated_dir_label _dedicated_dir _dedicated_label + + if [ -f "$recovery_helper" ]; then + recovery_retired="$SKILL_DIR/run/.install-op-recovery-retired.$AGMSG_INSTALL_OP_ID" + _uninstall_operation_require || return 1 + mv "$recovery_helper" "$recovery_retired" || return 1 + _uninstall_operation_require || return 1 + elif [ -n "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ] && [ -f "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then + recovery_retired="$SKILL_DIR/run/.install-op-recovery-retired.$AGMSG_INSTALL_OP_ID" + _uninstall_operation_require || return 1 + mv "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" "$recovery_retired" || return 1 + _uninstall_operation_require || return 1 + fi + if [ "$KEEP_DATA" = false ] && [ -f "$SKILL_DIR/uninstall.sh" ]; then + _uninstall_operation_require || return 1 + if ! agmsg_install_op_run_writer mv "$SKILL_DIR/uninstall.sh" "$uninstaller_retired"; then + echo " ! could not preserve the installed recovery entrypoint: $SKILL_DIR/uninstall.sh" >&2 + return 1 + fi + _uninstall_operation_require || return 1 + fi + agmsg_install_op_pending_complete "$AGMSG_INSTALL_OP_MARKER" "$AGMSG_INSTALL_OP_ID" || { + echo " ! could not clear the completed-operation record; later changes are blocked pending recovery" >&2 + return 1 + } + if [ -n "$uninstaller_retired" ] && [ -e "$uninstaller_retired" ]; then + # Remove only this generation's retired path after the record is cleared; + # a later install writes uninstall.sh and cannot be removed by this cleanup. + rm -f "$uninstaller_retired" || { + echo " ! uninstall completed but could not remove its retired entrypoint: $uninstaller_retired" >&2 + return 1 + } + fi + if [ -n "$recovery_retired" ]; then + rm -f "$recovery_retired" || { + echo " ! uninstall completed but could not remove its temporary recovery helper: $recovery_retired" >&2 + return 1 + } + fi + agmsg_install_op_unlock + AGMSG_INSTALL_OP_ACTIVE=false + unset AGMSG_INSTALL_OP_ID AGMSG_INSTALL_OP_MARKER } # Machine-wide pieces, shared by every install: only safe to remove once NO @@ -528,7 +735,13 @@ else # pointing at each one's own uninstall.sh, or --all to remove every # install on the machine at once. SELF_SKILL_DIR="$(cd "$(dirname "$0")" && pwd)" - if [ ! -f "$SELF_SKILL_DIR/.agmsg" ]; then + if [ -n "$CMD_NAME" ]; then + SELF_SKILL_DIR="$AGENTS_DIR/skills/$CMD_NAME" + if [ ! -d "$SELF_SKILL_DIR" ]; then + echo " ! selected installation does not exist: $SELF_SKILL_DIR" >&2 + exit 1 + fi + elif [ ! -f "$SELF_SKILL_DIR/.agmsg" ]; then candidates=() for d in "$AGENTS_DIR"/skills/*/; do d="${d%/}" From 8e7ebbee678f0d813a981cf57a31439373b4ce35 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 09:33:56 -0700 Subject: [PATCH 04/97] Restore the Codex profile recording lost in the install-lock squash (#1513) ## Summary The squash of #1506 into `integration/agmsgd-beta` carried stale copies of four files and reverted #1504 in full. The #1506 diff for these files is the exact inverse of #1504's diff. Restores the state after #1505 for: - `scripts/drivers/types/codex/codex-record-session.sh`: resolve and record the effective CODEX_HOME with each seat - `scripts/lib/role-session.sh`: the `codex_home` field in the role-session record - `scripts/drivers/types/codex/template.md`: the actas step text about the profile directory - `tests/test_codex_resume.bats`: the tests for the above No other part of #1506 touched these files. ## Test plan - [x] `bats tests/test_codex_resume.bats`: 35/35 pass locally --- .../types/codex/codex-record-session.sh | 35 ++++++++++++--- scripts/drivers/types/codex/template.md | 2 +- scripts/lib/role-session.sh | 4 +- tests/test_codex_resume.bats | 44 ++++++++++++++++--- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/scripts/drivers/types/codex/codex-record-session.sh b/scripts/drivers/types/codex/codex-record-session.sh index 843dce0f4..41e29d0b4 100755 --- a/scripts/drivers/types/codex/codex-record-session.sh +++ b/scripts/drivers/types/codex/codex-record-session.sh @@ -5,8 +5,9 @@ # otherwise send-side only and never runs actas-claim, so without this a codex # role would have no role-session record and could never be resumed (spawn would # always boot it fresh). This is the codex-side equivalent: the codex actas flow -# calls it, and it writes the record so a later spawn/resume brings the role back -# into its thread. +# calls it, and it writes the thread plus the effective CODEX_HOME so a later +# spawn/resume brings the role back into its thread and profile. The /clear +# recovery in self-fix.sh calls this same script after #1470 rebinds the seat. # # Usage: codex-record-session.sh [project] # @@ -160,6 +161,28 @@ if [ "$probe_ran" = "1" ]; then [ -n "$thread" ] || exit 0 fi +# Resolve the effective profile before either fallback can infer a thread. The +# rollout index belongs to CODEX_HOME, not necessarily to the process HOME. +# Codex defaults to $HOME/.codex when CODEX_HOME is unset; resolve either +# spelling to a physical absolute path so discovery and the stored destination +# use the same profile. A missing or malformed directory is not safe to publish +# as a delivery destination, so leave the previous record untouched. +codex_home="${CODEX_HOME:-}" +if [ -z "$codex_home" ]; then + [ -n "${HOME:-}" ] || exit 0 + codex_home="$HOME/.codex" +fi +case "$codex_home" in *[[:cntrl:]]*) exit 0 ;; esac +[ -d "$codex_home" ] || exit 0 +codex_home="$(agmsg_canonical_path "$codex_home")" +# Keep the absolute path in the cross-platform form used by Node consumers; +# Git Bash's physical /c/... spelling is normalized to C:/... on Windows. +codex_home="$(agmsg_normalize_project_path "$codex_home")" +case "$codex_home" in + /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; + *) exit 0 ;; +esac + if [ -z "$thread" ]; then # No app-server to ask, or it could not be reached -- a codex session outside # monitor mode, a missing Node, a server that is not answering. The rollout scan @@ -167,10 +190,8 @@ if [ -z "$thread" ]; then # single-rollout case it always did, and on a project with history it records # nothing, which is what happens today. # - # ${HOME:-} so an unset HOME under `set -u` is a silent no-op (empty -> the - # dir check below fails -> fresh), not an unbound-variable abort (nit). - sessions_dir="${HOME:-}/.codex/sessions" - if [ -n "${HOME:-}" ] && [ -d "$sessions_dir" ]; then + sessions_dir="$codex_home/sessions" + if [ -d "$sessions_dir" ]; then # Distinct thread ids whose session_meta cwd (canonicalized -- codex records # the physical cwd while agmsg may hold a symlinked path, #160) matches the # project, among the most recent rollouts. Exactly one => unambiguously ours. @@ -212,7 +233,7 @@ fi # as-is. The project is recorded in its canonical (physical) form so records # carry one path spelling regardless of how the caller spelled the argument. agmsg_role_session_load "$TEAM" "$AGENT" 2>/dev/null || true -agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" || true +agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" "$codex_home" || true # The Codex actas flow reaches this script instead of actas-claim.sh. Publish # the same seat request here so a resumed seat's dispatcher has an authority diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 2877f0382..631887950 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -18,7 +18,7 @@ Do not use POSIX `'"'"'` quote splicing in PowerShell, and do not use escaped do If argument starts with "actas" followed by an agent name: 1. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" __AGENT_TYPE__`. If `` is not listed, join with `~/.agents/skills/__SKILL_NAME__/scripts/join.sh __AGENT_TYPE__ "$(pwd)"`. -2. Record the Codex thread so a later spawn can resume it: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. +2. Record this Codex thread and its effective profile directory so a later spawn can resume it and route notices to the right profile: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. 3. Use the role as the active FROM; monitor delivery is routed only to its recorded thread. diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index e00f926f8..71057b1c1 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -137,9 +137,10 @@ agmsg_role_session_load() { # from the type manifest. Empty when unknown. # project= the resolved project root # owner= the actas owner token written by actas-claim +# codex_home= the effective absolute Codex profile directory # updated_at= best-effort timestamp (empty if date(1) unavailable) agmsg_role_session_record() { - local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" + local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" codex_home="${7:-}" [ -n "$team" ] && [ -n "$agent" ] && [ -n "$bare_sid" ] || return 0 local path dir tmp ts named_ref="" named_epoch="" named_at="" _agmsg_role_session_path_into "$team" "$agent" @@ -163,6 +164,7 @@ agmsg_role_session_record() { printf 'type=%s\n' "$type" printf 'project=%s\n' "$project" [ -z "$owner" ] || printf 'owner=%s\n' "$owner" + [ -z "$codex_home" ] || printf 'codex_home=%s\n' "$codex_home" printf 'updated_at=%s\n' "$ts" [ -z "$named_ref" ] || printf 'named_ref=%s\n' "$named_ref" [ -z "$named_ref" ] || printf 'named_epoch=%s\n' "$named_epoch" diff --git a/tests/test_codex_resume.bats b/tests/test_codex_resume.bats index 7a4d1a830..4a3738b64 100644 --- a/tests/test_codex_resume.bats +++ b/tests/test_codex_resume.bats @@ -14,6 +14,8 @@ setup() { export RUN_DIR="$SKILL_DIR/run" mkdir -p "$RUN_DIR" export CODEX_SESSIONS="$HOME/.codex/sessions" + export CODEX_HOME="$HOME/.codex" + mkdir -p "$CODEX_HOME" } teardown() { teardown_test_env; } @@ -68,21 +70,51 @@ recorded_uuid() { agmsg_role_session_uuid "$1" "$2" } -@test "codex record: prefers CODEX_THREAD_ID (unambiguous env path)" { - local proj; proj="$(mktemp -d)" - CODEX_THREAD_ID="env-thread-1" \ +@test "codex record: stores the thread and effective profile path" { + local proj explicit_home expected_home; proj="$(mktemp -d)" + explicit_home="$TEST_SKILL_DIR/codex profile" + mkdir -p "$explicit_home" "$HOME/.codex" + CODEX_HOME="$explicit_home" CODEX_THREAD_ID="env-thread-1" \ bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" [ "$(recorded_uuid team alice)" = "env-thread-1" ] - # type is recorded as codex. source "$SKILL_DIR/scripts/lib/role-session.sh" [ "$(agmsg_role_session_get team alice type)" = "codex" ] + expected_home="$(cd "$explicit_home" && pwd -P)" + # Match the recorder's cross-platform path spelling (not raw Git Bash /c/...). + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/resolve-project.sh" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] + + # Unset CODEX_HOME uses the same default Codex uses, recorded as an absolute + # path rather than leaving a later reader to infer it from its own HOME. + env -u CODEX_HOME CODEX_THREAD_ID="env-thread-2" \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + [ "$(recorded_uuid team alice)" = "env-thread-2" ] + expected_home="$(cd "$HOME/.codex" && pwd -P)" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: falls back to the unique matching-cwd rollout when env is unset" { - local proj; proj="$(mktemp -d)" + local proj explicit_home expected_home; proj="$(mktemp -d)" + explicit_home="$TEST_SKILL_DIR/profile-B" + mkdir -p "$explicit_home" + # A matching rollout in the default profile must not outrank the selected + # profile's own unique matching rollout. + CODEX_SESSIONS="$HOME/.codex/sessions" + make_rollout "wrong-profile-uuid" "$proj" + CODEX_SESSIONS="$explicit_home/sessions" make_rollout "fallback-uuid" "$proj" - ( unset CODEX_THREAD_ID; bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) + CODEX_HOME="$explicit_home" env -u CODEX_THREAD_ID \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" [ "$(recorded_uuid team alice)" = "fallback-uuid" ] + source "$SKILL_DIR/scripts/lib/role-session.sh" + expected_home="$(cd "$explicit_home" && pwd -P)" + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/resolve-project.sh" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: records NOTHING when two recent rollouts share the cwd (ambiguous)" { From 78c3fd4f59c67ffef2416d0a3a3406b8f06cb119 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 12:55:18 -0700 Subject: [PATCH 05/97] feat: one agmsg command (npm entry dispatches to the runtime, install places a launcher) (#1515) ## Summary One `agmsg` command for 1.6.0. - `bin/agmsg.js` (the npm entry) runs `install` itself, as before, and hands every other verb to the installed bash runtime by absolute path, preserving arguments, stdio and the exit code. When no runtime is installed it prints how to install one. The old bootstrapper's guidance table is removed; compatibility with it is not kept. - `scripts/agmsg` is the runtime entry. In 1.6.0 only `daemon` is public; reserved verbs print that they are not available in this version; anything else exits 2 with the runtime location. - `scripts/lib/agmsg-launcher.sh` places a marked launcher in a fixed candidate directory (`~/.local/bin`; `~/bin` first on Windows Git Bash). It never edits shell rc files; when the directory is not on PATH it prints the one line to add. An existing entry is judged without following links: valid or broken symlinks, directories, foreign files, edited launchers and another install's launcher are left untouched. - `install.sh` places the launcher inside the install operation lock phase, on fresh install and `--update`, and marks `scripts/agmsg` and `scripts/daemon/agmsgd` executable. - `uninstall.sh` removes the launcher only when its marker, embedded install path and contents all match this install. Known limit: when an npm entry from 1.5.1 or earlier is still first on PATH, `agmsg daemon` reaches the old installer and is rejected; install prints a one-line note when it finds one, and `npm i -g agmsg` updates it. ## Test plan - [x] `tests/test_agmsg_command.bats` (new) and `tests/test_bin_agmsg.bats` (rewritten) pass locally; the launcher directory is redirected with `AGMSG_BIN_DIR`, never the real `~/.local/bin`. - [x] Launcher placement and removal, the older-npm-entry note (including an `npx` entry first on PATH), and Windows bash selection are covered by the library and entry tests above. --- .github/enforced-assertions-baseline | 2 +- bin/agmsg.js | 269 +++++++++++++-------------- install.sh | 10 + scripts/agmsg | 56 ++++++ scripts/lib/agmsg-launcher.sh | 203 ++++++++++++++++++++ tests/test_agmsg_command.bats | 164 ++++++++++++++++ tests/test_bin_agmsg.bats | 173 ++++++----------- uninstall.sh | 13 ++ 8 files changed, 631 insertions(+), 259 deletions(-) create mode 100755 scripts/agmsg create mode 100644 scripts/lib/agmsg-launcher.sh create mode 100644 tests/test_agmsg_command.bats diff --git a/.github/enforced-assertions-baseline b/.github/enforced-assertions-baseline index 7e1aa3219..282d20871 100644 --- a/.github/enforced-assertions-baseline +++ b/.github/enforced-assertions-baseline @@ -1 +1 @@ -621 +612 diff --git a/bin/agmsg.js b/bin/agmsg.js index 3237283f5..7f5d0dfb3 100755 --- a/bin/agmsg.js +++ b/bin/agmsg.js @@ -1,28 +1,22 @@ #!/usr/bin/env node -// agmsg npm bootstrapper. +// agmsg npm entry. // -// This package does NOT contain the agmsg implementation. It exists to -// reserve the "agmsg" name on npm and to give users a convenient -// `npx agmsg install` entry point that defers to the canonical shell -// installer maintained at https://github.com/fujibee/agmsg. +// This package does NOT contain the agmsg implementation. It is the single +// `agmsg` command's Node entry point, and it does two things only: // -// All real installation, configuration, and runtime logic lives in the -// canonical setup.sh. This bootstrapper fetches that script to a tempfile -// and exec's it directly — equivalent to the README's +// - `agmsg install` fetches the canonical setup.sh for this package's +// version and runs it (equivalent to the README's +// `bash <(curl -fsSL .../setup.sh)`; process substitution keeps the tty as +// stdin, see agmsg #98). +// - every other command is handed, unchanged, to the bash runtime that an +// install put on disk (scripts/agmsg). Nothing is reimplemented here. // -// bash <(curl -fsSL https://raw.githubusercontent.com/fujibee/agmsg/main/setup.sh) -// -// form, which is process-substitution and preserves the user's tty as -// stdin. We deliberately do NOT pipe the curl output into bash: piping -// makes the installer's stdin the wrapper script stream, and install.sh's -// interactive command-name prompt would `read -r` the next line of -// setup.sh as the command name. See agmsg #98. -// -// Subcommands: -// install Fetch and run the canonical setup.sh (default if no args). -// --help Print this message and exit 0. -// --version Print the bootstrapper version and exit 0. +// Usage: +// install [options] Fetch and run the canonical setup.sh. +// help, --help Print usage. +// --version Print this package's and the installed runtime's version. +// Run the installed runtime's command with the same args. const { spawnSync } = require('child_process'); const fs = require('fs'); @@ -56,46 +50,11 @@ function readVersion() { } } -// `agmsg ` is the single most common wrong guess about this project, -// and it is a guess the documentation taught: a sweep of docs/design and -// docs/spec found 34 backticked commands written as `agmsg send …`, -// `agmsg key show …`, `agmsg team list …`. There is no such CLI. This package -// installs agmsg; the commands are scripts inside the install. -// -// Saying only "unknown argument" leaves the person exactly where they were. -// It has to name what to type instead. -// -// The verb→script map is a HINT, and the two halves of that are tested -// differently (review P1): -// -// NOT promised: that it is complete. This package does not ship scripts/, -// so it cannot enumerate them. A verb missing from here falls through to -// the general form, which stays correct. -// PROMISED: that every entry present is real. A renamed or deleted script -// would otherwise make this print a specific path that does not exist — -// WORSE than the general form, not merely less precise. The first version -// of this comment claimed only precision could degrade; that was wrong, -// and it was wrong for exactly the entries most likely to rot. -// -// test_bin_agmsg.bats pins every value against the repo's scripts/ — the -// list is asserted non-empty, not at a fixed size, so adding a verb here -// costs nothing while renaming a script fails the suite. -// -// That pin is about THIS repo. It cannot speak for the tree on a user's -// disk, so printNotACommand checks the actual file there before naming it. -const SCRIPT_FOR_VERB = { - send: 'send.sh', history: 'history.sh', inbox: 'inbox.sh', join: 'join.sh', - team: 'team.sh', key: 'key.sh', remote: 'remote.sh', whoami: 'whoami.sh', - leave: 'leave.sh', rename: 'rename.sh', export: 'export.sh', config: 'config.sh', - watch: 'watch.sh', spawn: 'spawn.sh', version: 'version.sh', api: 'api.sh', -}; - -// The default install location. Checked because this package's whole job is -// to install agmsg, so a person who has never run it reaches this branch too -// (review P1) — and for them every path below is a command that fails. -// Advice that assumes the install is advice they cannot follow. -function defaultSkillDir() { - return path.join(os.homedir(), '.agents', 'skills', 'agmsg'); +// The install location. `AGMSG_CMD=` selects an install made with +// `--cmd `; without it the default install is used. A named install that +// is missing is an error -- it never falls back to the default one. +function skillsRoot() { + return path.join(os.homedir(), '.agents', 'skills'); } function exists(p) { @@ -106,82 +65,65 @@ function exists(p) { } } -function printNotACommand(verb, skillDirForTest) { - const dir = skillDirForTest || defaultSkillDir(); - const skill = '~/.agents/skills/agmsg/scripts'; - const script = Object.prototype.hasOwnProperty.call(SCRIPT_FOR_VERB, verb) - ? SCRIPT_FOR_VERB[verb] - : null; - const scriptsDir = path.join(dir, 'scripts'); - - // The contract differs by what is about to be printed, and that is the - // point (review P1): - // - // naming ONE script -> that script file must exist. The repo-side pin - // proves the map matches THIS repo; it says nothing about the tree on - // the user's disk, which can be an old version, a partial update, or a - // broken install. Checking only that scripts/ exists reproduces the - // previous P1 one layer out — a directory is not the file. - // naming the DIRECTORY -> the directory must exist. Nothing more is - // claimed, so nothing more is checked. - const haveScripts = exists(scriptsDir); - const usable = script ? exists(path.join(scriptsDir, script)) : haveScripts; - - const lines = ['agmsg: `agmsg ' + verb + '` is not a command.', '']; - - if (!usable) { - // Three situations that need different next steps, kept apart: never - // installed, installed but without this command, and installed under - // another name. Folding them together leaves someone without a recovery - // step — which is what the first version of this message did. - if (haveScripts) { - lines.push('Your agmsg install does not contain that command. It may be an'); - lines.push('older version — update it:'); - } else { - lines.push('agmsg does not look installed on this machine — this package is'); - lines.push('the installer for it. Install first:'); +// Sets out how to reach the runtime `agmsg` (scripts/agmsg, a bash script that +// install.sh copies into the install). Returns { runtime } on success or +// { error, lines } describing why it cannot be reached. +function resolveRuntime(env, skillsDirForTest) { + const root = skillsDirForTest || skillsRoot(); + const named = env.AGMSG_CMD; + if (named !== undefined && named !== '') { + if (!/^[A-Za-z0-9._-]+$/.test(named) || named === '.' || named === '..') { + return { error: true, lines: ['agmsg: AGMSG_CMD must be a plain install name, got "' + named + '".'] }; } - lines.push(''); - lines.push(' npx agmsg install'); - lines.push(''); - lines.push('After that, ' + (script ? 'that command is:' : 'the commands are:')); - lines.push(''); - lines.push(script ? ' bash ' + skill + '/' + script + ' …' - : ' bash ' + skill + '/.sh …'); - lines.push(''); - lines.push('(Already installed under a different name? Substitute it for'); - lines.push('`agmsg` in that path — nothing is put on your PATH.)'); - } else if (script) { - lines.push('This package only installs agmsg. That one lives in your install:'); - lines.push(''); - lines.push(' bash ' + skill + '/' + script + ' …'); - } else { - lines.push('This package only installs agmsg. The commands live in your install:'); - lines.push(''); - lines.push(' ls ' + skill + '/'); - lines.push(' bash ' + skill + '/.sh …'); + return checkInstall(path.join(root, named), 'AGMSG_CMD=' + named); } + const def = path.join(root, 'agmsg'); + const found = checkInstall(def, 'the default install'); + if (!found.error) return found; + if (exists(path.join(def, 'scripts'))) return found; + // No default install: list others that have a runtime, without picking one. + let others = []; + try { + others = fs.readdirSync(root).filter((n) => exists(path.join(root, n, 'scripts', 'agmsg'))); + } catch (_) { /* no skills directory */ } + if (others.length > 0) { + return { + error: true, + lines: ['agmsg: there is no default install, but these installs have the agmsg command:'] + .concat(others.map((n) => ' ' + n)) + .concat(['Pick one with AGMSG_CMD= agmsg …']) + }; + } + return found; +} - lines.push(''); - // The skill command is the path most people actually want — it is what the - // install sets up, and it needs no paths. - lines.push('Or ask your agent: run the agmsg skill command (/agmsg in Claude Code).'); - lines.push('`npx agmsg --help` covers what THIS package does.'); - console.error(lines.join('\n')); +function checkInstall(dir, label) { + const runtime = path.join(dir, 'scripts', 'agmsg'); + if (exists(runtime)) return { runtime, dir }; + if (exists(path.join(dir, 'scripts'))) { + return { error: true, lines: [ + 'agmsg: ' + label + ' has no agmsg command (an older version). Update it:', + ' agmsg install' + ] }; + } + return { error: true, lines: [ + 'agmsg: ' + label + ' is not installed. Install first:', + ' agmsg install' + ] }; } function printHelp() { process.stdout.write([ - 'agmsg — npm bootstrapper for cross-agent messaging', - '', - 'This package is a thin wrapper. The real installer lives at:', - ' ' + REPO_URL, + 'agmsg — cross-agent messaging', '', 'Usage:', - ' npx agmsg run the canonical setup.sh (same as `agmsg install`)', - ' npx agmsg install run the canonical setup.sh', - ' npx agmsg --help show this message', - ' npx agmsg --version show this bootstrapper\'s version', + ' agmsg install [options] install or update agmsg (runs the canonical setup.sh)', + ' agmsg daemon manage the agmsgd beta daemon (start|stop|status|enable|disable)', + ' agmsg --version show this package and the installed runtime version', + ' agmsg help show this message', + '', + 'Every command other than install is run by the agmsg install on this', + 'machine (AGMSG_CMD= picks an install made with `--cmd `).', '', 'After install, restart your agent (Claude Code / Codex / Gemini CLI /', 'Copilot CLI / Antigravity / OpenCode) and run the agmsg skill command', @@ -193,6 +135,42 @@ function printHelp() { ].join('\n')); } +// Runs the runtime with the arguments exactly as given: an absolute path, no +// shell, inherited stdio, and the runtime's own exit status. +function runRuntime(runtime, args) { + const bash = bashCommand(); + if (!bash) { + console.error('agmsg: Git for Windows bash was not found. Install Git for Windows, then run this again.'); + process.exit(1); + } + const result = spawnSync(bash, [toBashPath(runtime), ...args], { stdio: 'inherit' }); + if (result.error) { + console.error('agmsg: failed to launch bash:', result.error.message); + process.exit(1); + } + if (result.signal) { + process.kill(process.pid, result.signal); + return; + } + process.exit(result.status === null ? 1 : result.status); +} + +// On Windows a bare `bash` can be the WSL launcher, so only Git for Windows' +// own bash.exe is used there; when it cannot be found this returns null +// instead of falling back to a bare `bash`. +function bashCommand() { + if (process.platform === 'win32') { + const roots = [process.env.ProgramFiles, process.env.ProgramW6432, process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs')] + .filter(Boolean); + for (const root of roots) { + const gitBash = path.join(root, 'Git', 'bin', 'bash.exe'); + if (exists(gitBash)) return gitBash; + } + return null; + } + return 'bash'; +} + // Normalise a native path to the forward-slash form that bash.exe and // curl.exe accept on Windows. bash is an MSYS2 program: Windows gives it a // raw command-line string rather than a real argv[], and MSYS's own argv @@ -217,6 +195,11 @@ function runInstaller(passthroughArgs) { // correctly here is defense-in-depth and lets future interactive prompts // in setup.sh keep working for real-tty users. const ref = installRef(); + const bash = bashCommand(); + if (!bash) { + console.error('agmsg: Git for Windows bash was not found. Install Git for Windows, then run this again.'); + process.exit(1); + } const setupUrl = RAW_BASE + '/' + ref + '/setup.sh'; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agmsg-bootstrap-')); // os.tmpdir()/path.join() return backslash-separated paths on Windows. @@ -236,7 +219,7 @@ function runInstaller(passthroughArgs) { } // Pin the clone inside setup.sh to the same ref we fetched it from. - const result = spawnSync('bash', [setupPath, ...passthroughArgs], { + const result = spawnSync(bash, [setupPath, ...passthroughArgs], { stdio: 'inherit', env: Object.assign({}, process.env, { AGMSG_REF: ref }) }); @@ -253,21 +236,31 @@ function runInstaller(passthroughArgs) { function main() { const args = process.argv.slice(2); - if (args.length === 0 || args[0] === 'install') { + if (args[0] === 'install') { // Forward anything after `install` (e.g. `agmsg install --cmd m`) to // setup.sh, which passes "$@" through to install.sh. - const passthrough = args[0] === 'install' ? args.slice(1) : args; - runInstaller(passthrough); - } else if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') { + runInstaller(args.slice(1)); + } else if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') { printHelp(); - process.exit(0); + process.exit(args.length === 0 ? 2 : 0); } else if (args[0] === '--version' || args[0] === '-v') { - process.stdout.write('agmsg bootstrapper ' + readVersion() + '\n'); - process.stdout.write('canonical project: ' + REPO_URL + '\n'); + process.stdout.write('agmsg ' + readVersion() + ' (npm entry)\n'); + const found = resolveRuntime(process.env); + if (found.error) { + process.stdout.write('runtime: not available\n'); + } else { + const bash = bashCommand(); + const v = bash ? spawnSync(bash, [toBashPath(found.runtime), '--version'], { encoding: 'utf8' }) : null; + process.stdout.write('runtime: ' + (v && v.status === 0 ? v.stdout.trim() : 'unreadable') + '\n'); + } process.exit(0); } else { - printNotACommand(args[0]); - process.exit(2); + const found = resolveRuntime(process.env); + if (found.error) { + console.error(found.lines.join('\n')); + process.exit(2); + } + runRuntime(found.runtime, args); } } @@ -275,4 +268,4 @@ if (require.main === module) { main(); } -module.exports = { toBashPath, SCRIPT_FOR_VERB, printNotACommand }; +module.exports = { toBashPath, resolveRuntime, runRuntime }; diff --git a/install.sh b/install.sh index 0ad180bd9..f31b3cd2d 100755 --- a/install.sh +++ b/install.sh @@ -30,6 +30,8 @@ AGENTS_DIR="$HOME/.agents" . "$SCRIPT_DIR/scripts/lib/install-db.sh" # shellcheck disable=SC1091 . "$SCRIPT_DIR/scripts/lib/install-manifest.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/scripts/lib/agmsg-launcher.sh" # Type registry — resolve each type's SKILL command template from its manifest # (scripts/drivers/types//template.md) instead of a hardcoded templates/ path. Read-only @@ -1127,6 +1129,8 @@ $_agmsg_running_team" unset -f agmsg_shq fi chmod +x "$SKILL_DIR/scripts/"*.sh || exit 1 + # Extensionless entry points the *.sh glob does not reach. + chmod +x "$SKILL_DIR/scripts/agmsg" "$SKILL_DIR/scripts/daemon/agmsgd" 2>/dev/null || true agmsg_install_optional_codex_chmod install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" || exit 1 # Refresh the Codex monitor shim (~/.agents/bin/codex) if it's ours. --update @@ -1175,6 +1179,7 @@ $_agmsg_running_team" fi install_windows_helpers || exit 1 agmsg_install_op_phase_end || exit 1 + agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" || exit 1 agmsg_install_op_run_phase agmsg_install_write_version || exit 1 echo " + updated scripts, templates, and SKILL.md (version $INSTALLED_VERSION)" echo " ~ DB and team configs preserved" @@ -1280,6 +1285,8 @@ agmsg_install_optional_uninstaller_copy agmsg_install_optional_copy "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" chmod +x "$SKILL_DIR/scripts/"*.sh || exit 1 +# Extensionless entry points the *.sh glob does not reach. +chmod +x "$SKILL_DIR/scripts/agmsg" "$SKILL_DIR/scripts/daemon/agmsgd" 2>/dev/null || true agmsg_install_optional_codex_chmod install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" || exit 1 # Re-point an existing Codex monitor shim at the new path on a reinstall over an @@ -1307,6 +1314,9 @@ fi install_windows_helpers || exit 1 agmsg_install_op_phase_end || exit 1 +# The `agmsg` command launcher (never touches shell rc files or PATH). +agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" || exit 1 + # Marker file for uninstall detection and source provenance version are one # protected phase so neither write can occur after a lost-lock boundary. agmsg_install_op_run_phase agmsg_install_touch_marker_and_write_version || exit 1 diff --git a/scripts/agmsg b/scripts/agmsg new file mode 100755 index 000000000..8af17367c --- /dev/null +++ b/scripts/agmsg @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# The runtime `agmsg` command. Deliberately a small dispatcher: it names the +# few verbs that are public in this version and hands each one to the script +# that implements it. It does NOT forward arbitrary verbs to scripts/.sh; +# names such as send / inbox / history are not published here yet. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +_usage() { + cat <<'USAGE' +usage: agmsg [args] + +commands: + daemon start|stop|status|enable|disable manage the agmsgd beta daemon + --version print the installed version + help show this message + +Other commands are not available through `agmsg` in this version. +USAGE +} + +case "${1:-}" in + daemon) + shift + exec bash "$SCRIPT_DIR/daemon.sh" "$@" + ;; + --version|-v) + printf 'agmsg %s (install: %s)\n' "$(bash "$SCRIPT_DIR/version.sh")" "$(cd "$SCRIPT_DIR/.." && pwd)" + ;; + help|--help|-h) + _usage + ;; + install) + cat >&2 <<'MSG' +agmsg: this agmsg is already installed; `agmsg install` does not run from here. +To install or update, run one of: + npx agmsg install + bash /install.sh +MSG + exit 2 + ;; + doctor|sync|activate|deactivate|despawn|migrate) + echo "agmsg: '$1' is reserved and is not available in this version yet." >&2 + exit 2 + ;; + "") + _usage >&2 + exit 2 + ;; + *) + echo "agmsg: '$1' is not an agmsg command." >&2 + echo "Scripts for this install live in: $SCRIPT_DIR" >&2 + exit 2 + ;; +esac diff --git a/scripts/lib/agmsg-launcher.sh b/scripts/lib/agmsg-launcher.sh new file mode 100644 index 000000000..d5d81a817 --- /dev/null +++ b/scripts/lib/agmsg-launcher.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# Places (and later removes) the thin `agmsg` launcher that lets a person type +# `agmsg daemon ...` from a terminal. It never edits a shell rc file, never +# follows a symlink, and never overwrites a file it does not own. +# +# The launcher is a marked regular file, not a symlink: a symlink cannot carry +# an ownership marker, and `dirname $0` inside the runtime would then resolve +# to the bin directory instead of the install. + +# Marker prefix. The full marker line is " ". +AGMSG_LAUNCHER_MARKER='# agmsg-launcher-owner:' + +# Sets AGMSG_LAUNCHER_DIR to the directory a launcher goes in. An explicit +# AGMSG_BIN_DIR is used as given (it must be absolute); otherwise the one +# default candidate for this platform. Never searches PATH. +agmsg_launcher_pick_dir() { + AGMSG_LAUNCHER_DIR="" + if [ -n "${AGMSG_BIN_DIR:-}" ]; then + case "$AGMSG_BIN_DIR" in + /*) AGMSG_LAUNCHER_DIR="$AGMSG_BIN_DIR"; return 0 ;; + *) AGMSG_LAUNCHER_REASON="AGMSG_BIN_DIR is not an absolute path"; return 1 ;; + esac + fi + case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*) AGMSG_LAUNCHER_DIR="$HOME/bin" ;; + *) AGMSG_LAUNCHER_DIR="$HOME/.local/bin" ;; + esac +} + +# Prints the launcher file content for the install at $1. +agmsg_launcher_render() { + local skill_dir="$1" + printf '#!/usr/bin/env bash\n' + printf '# agmsg launcher -- placed by the agmsg installer; remove it with the uninstaller.\n' + printf '%s %s\n' "$AGMSG_LAUNCHER_MARKER" "$skill_dir" + printf 'exec bash %q "$@"\n' "$skill_dir/scripts/agmsg" +} + +# Sets AGMSG_LAUNCHER_KIND for the target path $1 and install $2 to one of: +# absent | own-same | own-modified | other-install | symlink | directory | +# not-regular | foreign +# The target is judged without following links. +agmsg_launcher_classify() { + local target="$1" skill_dir="$2" marker_line owner + AGMSG_LAUNCHER_KIND="" + AGMSG_LAUNCHER_OWNER="" + if [ -L "$target" ]; then AGMSG_LAUNCHER_KIND=symlink; return 0; fi + if [ ! -e "$target" ]; then AGMSG_LAUNCHER_KIND=absent; return 0; fi + if [ -d "$target" ]; then AGMSG_LAUNCHER_KIND=directory; return 0; fi + if [ ! -f "$target" ]; then AGMSG_LAUNCHER_KIND=not-regular; return 0; fi + marker_line="$(grep -m1 "^$AGMSG_LAUNCHER_MARKER " "$target" 2>/dev/null || true)" + if [ -z "$marker_line" ]; then AGMSG_LAUNCHER_KIND=foreign; return 0; fi + owner="${marker_line#"$AGMSG_LAUNCHER_MARKER "}" + AGMSG_LAUNCHER_OWNER="$owner" + if [ "$owner" != "$skill_dir" ]; then AGMSG_LAUNCHER_KIND=other-install; return 0; fi + if [ "$(agmsg_launcher_render "$skill_dir")" = "$(cat "$target")" ]; then + AGMSG_LAUNCHER_KIND=own-same + else + AGMSG_LAUNCHER_KIND=own-modified + fi +} + +# Places the launcher for the install at $1 and prints a three-part report: +# what was placed, whether this process can see it, and what to check on a +# real terminal. Always returns 0: not placing a launcher is never an install +# failure. +agmsg_launcher_install() { + local skill_dir="$1" target tmp resolved old + AGMSG_LAUNCHER_REASON="" + if ! agmsg_launcher_pick_dir; then + echo " ~ agmsg command: not placed ($AGMSG_LAUNCHER_REASON)" + return 0 + fi + target="$AGMSG_LAUNCHER_DIR/agmsg" + agmsg_launcher_classify "$target" "$skill_dir" + case "$AGMSG_LAUNCHER_KIND" in + own-same) echo " ~ agmsg command: already in place at $target" ;; + absent) + if ! mkdir -p "$AGMSG_LAUNCHER_DIR" 2>/dev/null \ + || ! tmp="$(mktemp "$AGMSG_LAUNCHER_DIR/.agmsg-launcher.XXXXXX" 2>/dev/null)"; then + echo " ~ agmsg command: not placed ($AGMSG_LAUNCHER_DIR is not writable)" + return 0 + fi + if agmsg_launcher_render "$skill_dir" > "$tmp" && chmod +x "$tmp" && mv -n "$tmp" "$target" 2>/dev/null && [ ! -e "$tmp" ]; then + agmsg_launcher_record "$skill_dir" "$target" + echo " + agmsg command: placed $target" + else + rm -f "$tmp" + echo " ~ agmsg command: not placed (could not write $target)" + return 0 + fi + ;; + other-install) + echo " ~ agmsg command: not placed ($target already belongs to the install at $AGMSG_LAUNCHER_OWNER)" + return 0 ;; + own-modified) + echo " ~ agmsg command: not placed ($target was edited; leaving it as it is)" + return 0 ;; + symlink) + echo " ~ agmsg command: not placed ($target is a symlink; agmsg never replaces one)" + return 0 ;; + directory|not-regular|foreign) + echo " ~ agmsg command: not placed ($target already exists and is not an agmsg launcher)" + return 0 ;; + esac + case ":$PATH:" in + *":$AGMSG_LAUNCHER_DIR:"*) echo " visible in this environment: yes ($AGMSG_LAUNCHER_DIR is on PATH)" ;; + *) echo " visible in this environment: no ($AGMSG_LAUNCHER_DIR is not on PATH)" ;; + esac + resolved="$(command -v agmsg 2>/dev/null || true)" + if [ -n "$resolved" ] && [ "$resolved" != "$target" ]; then + echo " note: 'agmsg' currently resolves to $resolved, which comes first on PATH" + fi + # Every PATH entry ahead of the launcher's directory is checked, not just the + # first hit: during `npx agmsg install` a temporary entry sits first and an + # older global one can sit behind it. + if old="$(agmsg_launcher_find_old_npm_entry "$AGMSG_LAUNCHER_DIR")"; then + echo " $old is an older npm agmsg (1.5.1 or earlier) that comes before the launcher on PATH;" + echo " it does not know 'agmsg daemon'. Update it with: npm i -g agmsg@latest" + echo " (or put $AGMSG_LAUNCHER_DIR before it on PATH)" + return 0 + fi + echo " on your own terminal: not checked here; open a new terminal and run: command -v agmsg" + echo " if that prints nothing, add this line to your shell startup file yourself:" + echo " export PATH=\"$AGMSG_LAUNCHER_DIR:\$PATH\"" +} + +# True when the file at $1 is the npm entry from before the single-command +# release (its header names it a bootstrapper). Reads files only. On Unix npm +# links the entry, so reading the path follows to the JS. On Windows npm writes +# a small shell wrapper next to the entry; that wrapper names +# node_modules/agmsg/bin/agmsg.js relative to its own directory, and that file +# is what is read. +agmsg_launcher_is_old_npm_entry() { + local file="$1" js + [ -f "$file" ] || return 1 + head -c 4096 "$file" 2>/dev/null | grep -q 'agmsg npm bootstrapper' && return 0 + if head -c 4096 "$file" 2>/dev/null | grep -q 'node_modules/agmsg/bin/agmsg.js'; then + js="$(dirname "$file")/node_modules/agmsg/bin/agmsg.js" + [ -f "$js" ] && head -c 4096 "$js" 2>/dev/null | grep -q 'agmsg npm bootstrapper' && return 0 + fi + return 1 +} + +# Prints the first older npm agmsg found on PATH ahead of directory $1 (or +# anywhere on PATH when $1 is not on it) and returns 0; returns 1 when none. +agmsg_launcher_find_old_npm_entry() { + local stop="$1" dir rest="$PATH:" + while [ -n "$rest" ]; do + dir="${rest%%:*}" + rest="${rest#*:}" + [ -n "$dir" ] || continue + [ "$dir" = "$stop" ] && return 1 + if agmsg_launcher_is_old_npm_entry "$dir/agmsg"; then + printf '%s\n' "$dir/agmsg" + return 0 + fi + done + return 1 +} + +# Remembers where the launcher went so uninstall can find it under a custom +# AGMSG_BIN_DIR. The record is never trusted alone; uninstall re-checks the file. +agmsg_launcher_record() { + mkdir -p "$1/run" 2>/dev/null || return 0 + printf '%s\n' "$2" > "$1/run/agmsg-launcher.path" 2>/dev/null || true +} + +# Removes the launcher for the install at $1 only if the file carries our +# marker, names this install, and still has exactly the content we wrote. +# Prints one line saying what happened. Returns non-zero only when a launcher +# that is ours could not be removed; the record is then kept. The remove +# command defaults to rm; the uninstaller passes its lock-checked remover in +# AGMSG_LAUNCHER_RM so a removal can never run after the lock was lost. +agmsg_launcher_uninstall() { + local skill_dir="$1" record="" candidate seen="" + local remover="${AGMSG_LAUNCHER_RM:-rm}" + [ -f "$skill_dir/run/agmsg-launcher.path" ] && record="$(head -n1 "$skill_dir/run/agmsg-launcher.path" 2>/dev/null || true)" + agmsg_launcher_pick_dir >/dev/null 2>&1 || true + for candidate in "$record" "${AGMSG_LAUNCHER_DIR:+$AGMSG_LAUNCHER_DIR/agmsg}"; do + [ -n "$candidate" ] || continue + case "$seen" in *"|$candidate|"*) continue ;; esac + seen="$seen|$candidate|" + agmsg_launcher_classify "$candidate" "$skill_dir" + case "$AGMSG_LAUNCHER_KIND" in + own-same) + if ! "$remover" -f "$candidate"; then + echo " ! could not remove agmsg command $candidate" >&2 + return 1 + fi + echo " - removed agmsg command $candidate" + "$remover" -f "$skill_dir/run/agmsg-launcher.path" || return 1 + return 0 ;; + own-modified) + echo " ~ left $candidate in place (it was edited)" ;; + other-install) + echo " ~ left $candidate in place (it belongs to the install at $AGMSG_LAUNCHER_OWNER)" ;; + symlink|directory|not-regular|foreign) + [ "$candidate" = "$record" ] && echo " ~ left $candidate in place (it is not an agmsg launcher)" ;; + esac + done + return 0 +} diff --git a/tests/test_agmsg_command.bats b/tests/test_agmsg_command.bats new file mode 100644 index 000000000..f7ff1144d --- /dev/null +++ b/tests/test_agmsg_command.bats @@ -0,0 +1,164 @@ +#!/usr/bin/env bats + +# The runtime `agmsg` command and the launcher install places for it. Nothing +# here swaps HOME or touches a real bin directory: the launcher goes to +# AGMSG_BIN_DIR under the test's own temp dir, and the install is a copy of +# scripts/ in TEST_SKILL_DIR. + +load test_helper + +setup() { + setup_test_env + AGMSG="$TEST_SKILL_DIR/scripts/agmsg" + chmod +x "$AGMSG" + LIB="$TEST_SKILL_DIR/scripts/lib/agmsg-launcher.sh" + BIN_DIR="$BATS_TEST_TMPDIR/bin" + mkdir -p "$BIN_DIR" + export AGMSG_BIN_DIR="$BIN_DIR" +} + +teardown() { + rm -rf "$TEST_SKILL_DIR" +} + +@test "agmsg: unknown verb exits 2 with the location, reserved verb says not yet, daemon reaches daemon.sh" { + run bash "$AGMSG" storage list + [ "$status" -eq 2 ] + grep -Fq -- "is not an agmsg command" <<<"$output" + grep -Fq -- "$TEST_SKILL_DIR/scripts" <<<"$output" + + run bash "$AGMSG" doctor + [ "$status" -eq 2 ] + grep -Fq -- "reserved" <<<"$output" + + # A published-elsewhere verb is not passed through to its script. + run bash "$AGMSG" send x y z + [ "$status" -eq 2 ] + grep -Fq -- "is not an agmsg command" <<<"$output" + + # daemon reaches daemon.sh with the arguments unchanged (its own usage text). + run bash "$AGMSG" daemon bogus + grep -Fq -- "agmsg daemon start|stop|status|enable|disable" <<<"$output" +} + +@test "launcher: placed into the chosen dir, reaches the runtime, and uninstall removes only that" { + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + [ "$status" -eq 0 ] + grep -Fq -- "placed $BIN_DIR/agmsg" <<<"$output" + grep -Fq -- "visible in this environment: no" <<<"$output" + [ -x "$BIN_DIR/agmsg" ] + + # The launcher, not a symlink, and it lands on the real scripts/agmsg. + [ ! -L "$BIN_DIR/agmsg" ] + run "$BIN_DIR/agmsg" doctor + [ "$status" -eq 2 ] + grep -Fq -- "reserved" <<<"$output" + + # Second run is a no-op. + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "already in place" <<<"$output" + + run bash -c 'source "$1"; agmsg_launcher_uninstall "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "removed agmsg command" <<<"$output" + [ ! -e "$BIN_DIR/agmsg" ] + [ -d "$BIN_DIR" ] +} + +# The four kinds of thing already sitting at the target: nothing is broken, +# nothing is followed, and the message says why. +@test "launcher: a valid symlink, a broken symlink, a directory and a foreign file are all left alone" { + local elsewhere="$BATS_TEST_TMPDIR/elsewhere" + printf 'original\n' > "$elsewhere" + + ln -s "$elsewhere" "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "not placed" <<<"$output" + grep -Fq -- "symlink" <<<"$output" + [ -L "$BIN_DIR/agmsg" ] + [ "$(cat "$elsewhere")" = "original" ] + run bash -c 'source "$1"; agmsg_launcher_uninstall "$2"' _ "$LIB" "$TEST_SKILL_DIR" + [ -L "$BIN_DIR/agmsg" ] + + rm -f "$BIN_DIR/agmsg" + ln -s "$BATS_TEST_TMPDIR/does-not-exist" "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "symlink" <<<"$output" + [ -L "$BIN_DIR/agmsg" ] + [ ! -e "$BATS_TEST_TMPDIR/does-not-exist" ] + + rm -f "$BIN_DIR/agmsg" + mkdir "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "not an agmsg launcher" <<<"$output" + [ -d "$BIN_DIR/agmsg" ] + + rmdir "$BIN_DIR/agmsg" + printf '#!/bin/sh\necho mine\n' > "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "not an agmsg launcher" <<<"$output" + [ "$(sed -n 2p "$BIN_DIR/agmsg")" = "echo mine" ] +} + +@test "launcher: another install's launcher is not taken over, an edited one is not removed" { + local other="$BATS_TEST_TMPDIR/other-install" + mkdir -p "$other/scripts" + bash -c 'source "$1"; agmsg_launcher_render "$2" > "$3"' _ "$LIB" "$other" "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "belongs to the install at $other" <<<"$output" + grep -q "$other" "$BIN_DIR/agmsg" + # This install's uninstall must not remove the other install's launcher. + run bash -c 'source "$1"; agmsg_launcher_uninstall "$2"' _ "$LIB" "$TEST_SKILL_DIR" + [ -f "$BIN_DIR/agmsg" ] + + rm -f "$BIN_DIR/agmsg" + bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" >/dev/null + printf '# edited\n' >> "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_uninstall "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "edited" <<<"$output" + [ -f "$BIN_DIR/agmsg" ] +} + +@test "launcher: a relative AGMSG_BIN_DIR is refused and nothing is written" { + cd "$BATS_TEST_TMPDIR" + run env AGMSG_BIN_DIR=relative/bin bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + [ "$status" -eq 0 ] + grep -Fq -- "not an absolute path" <<<"$output" + [ ! -e "$BATS_TEST_TMPDIR/relative" ] +} + +@test "launcher: an older npm agmsg ahead on PATH is named and the update is offered" { + local old="$BATS_TEST_TMPDIR/oldbin" + mkdir -p "$old" + printf '#!/usr/bin/env node\n// agmsg npm bootstrapper.\n' > "$old/agmsg" + chmod +x "$old/agmsg" + # A newer entry sits first on PATH (as during an npx install); the older + # global one behind it must still be found. + local newer="$BATS_TEST_TMPDIR/newerbin" + mkdir -p "$newer" + printf '#!/usr/bin/env node\n// agmsg npm entry.\n' > "$newer/agmsg" + chmod +x "$newer/agmsg" + run env PATH="$newer:$old:$BIN_DIR:$PATH" bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "older npm agmsg" <<<"$output" + grep -Fq -- "npm i -g agmsg@latest" <<<"$output" + # It is only reported; nothing of the old entry is touched. + grep -q 'agmsg npm bootstrapper' "$old/agmsg" +} + +@test "launcher: a failed removal keeps the record and reports failure" { + bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" >/dev/null + [ -f "$TEST_SKILL_DIR/run/agmsg-launcher.path" ] + run env AGMSG_LAUNCHER_RM=false bash -c 'source "$1"; agmsg_launcher_uninstall "$2"' _ "$LIB" "$TEST_SKILL_DIR" + [ "$status" -ne 0 ] + [ -f "$BIN_DIR/agmsg" ] + [ -f "$TEST_SKILL_DIR/run/agmsg-launcher.path" ] +} + +@test "launcher: an older npm agmsg behind a Windows-style wrapper is recognised through the JS it names" { + local wrap="$BATS_TEST_TMPDIR/npmwrap" + mkdir -p "$wrap/node_modules/agmsg/bin" + printf '// agmsg npm bootstrapper.\n' > "$wrap/node_modules/agmsg/bin/agmsg.js" + printf '#!/bin/sh\nexec node "$basedir/node_modules/agmsg/bin/agmsg.js" "$@"\n' > "$wrap/agmsg" + chmod +x "$wrap/agmsg" + run env PATH="$wrap:$BIN_DIR:$PATH" bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "older npm agmsg" <<<"$output" +} diff --git a/tests/test_bin_agmsg.bats b/tests/test_bin_agmsg.bats index d10c40a1a..0d0558deb 100644 --- a/tests/test_bin_agmsg.bats +++ b/tests/test_bin_agmsg.bats @@ -2,129 +2,62 @@ BIN="$BATS_TEST_DIRNAME/../bin/agmsg.js" -@test "bin/agmsg.js: --version exits successfully" { - run node "$BIN" --version - [ "$status" -eq 0 ] - [[ "$output" =~ "agmsg bootstrapper" ]] -} - -@test "bin/agmsg.js: --help exits successfully" { +@test "bin/agmsg.js: --help lists install and daemon and exits 0" { run node "$BIN" --help [ "$status" -eq 0 ] - [[ "$output" =~ "npm bootstrapper for cross-agent messaging" ]] -} - -# `agmsg ` is the wrong guess the docs taught — a sweep of docs/design -# and docs/spec found 34 backticked commands assuming a CLI that does not -# exist. Fixing the documents does not help the person who types from memory, -# so the refusal has to name the real form. -# -# Asserted on the PATH being present, not on the wording: the value of this -# message is that it tells you what to type instead, and a test that only -# checked for "not a command" would pass on the old text. -@test "bin/agmsg.js: an unknown verb names the script to run instead" { - run node "$BIN" send hello - [ "$status" -eq 2 ] - [[ "$output" =~ "is not a command" ]] - [[ "$output" =~ "scripts/send.sh" ]] -} - -# A verb with no mapping still has to answer "then what do I type", because -# the mapping is a hint that is allowed to be incomplete. `storage` is one the -# docs use and no script implements. -@test "bin/agmsg.js: an unmapped verb still points at the install" { - run node "$BIN" storage list - [ "$status" -eq 2 ] - [[ "$output" =~ "is not a command" ]] - [[ "$output" =~ "scripts/" ]] -} - -# EVERY entry in the map, not the one verb a hand-written test happened to -# pick (review P1). The map is allowed to be incomplete — a new verb -# missing from it costs nothing — but an entry that is PRESENT and stale makes -# the message name a path that does not exist, which is worse than the general -# advice it replaced. Completeness is not pinned; correctness of what is -# listed is. -@test "bin/agmsg.js: every mapped verb names a script that exists" { - run node -e ' - const { SCRIPT_FOR_VERB } = require(process.argv[1]); - const fs = require("fs"), path = require("path"); - const dir = path.join(path.dirname(process.argv[1]), "..", "scripts"); - const missing = Object.entries(SCRIPT_FOR_VERB) - .filter(([, s]) => !fs.existsSync(path.join(dir, s))) - .map(([v, s]) => v + " -> " + s); - if (missing.length) { console.error(missing.join("\n")); process.exit(1); } - console.log("checked " + Object.keys(SCRIPT_FOR_VERB).length); - ' "$BIN" - [ "$status" -eq 0 ] - # Non-empty, not an exact count (review): pinning the number would mean adding - # a legitimate verb to the map fails this test, which makes the map harder - # to extend for no safety gained. What must not pass is a map emptied to {} - # reporting "nothing missing". - [[ "$output" =~ checked\ [1-9] ]] + grep -Fq -- "agmsg install" <<<"$output" + grep -Fq -- "agmsg daemon" <<<"$output" } -# The person this package exists for has NOT installed yet, and they reach -# this same branch (review P1). Telling them to `bash ` is -# telling them to run a command that fails. HOME is redirected to an empty -# directory; nothing here touches the network. -@test "bin/agmsg.js: with nothing installed, it says to install first" { - local fresh="$BATS_TEST_TMPDIR/fresh-home" - mkdir -p "$fresh" - run env HOME="$fresh" node "$BIN" send hello +@test "bin/agmsg.js: no arguments prints usage and exits 2 (it must not start an install)" { + run node "$BIN" [ "$status" -eq 2 ] - [[ "$output" =~ "does not look installed" ]] - [[ "$output" =~ "npx agmsg install" ]] - # Still names the eventual command, so the person knows where they are going. - [[ "$output" =~ "scripts/send.sh" ]] -} - -@test "bin/agmsg.js: with the script present, it names it and says nothing about installing" { - local home="$BATS_TEST_TMPDIR/has-install" - mkdir -p "$home/.agents/skills/agmsg/scripts" - # The FILE, not just the directory. An earlier version of this test created - # an empty scripts/ and accepted advice pointing at a send.sh that was not - # there — the same defect as the map pin, one layer out (review P1). - touch "$home/.agents/skills/agmsg/scripts/send.sh" - run env HOME="$home" node "$BIN" send hello - [ "$status" -eq 2 ] - [[ ! "$output" =~ "does not look installed" ]] - [[ ! "$output" =~ "does not contain that command" ]] - [[ "$output" =~ "scripts/send.sh" ]] -} - -# An install that exists but lacks the command — an old version, or a partial -# update. The repo-side pin cannot see this: it proves the map matches THIS -# repo, not the tree on someone's disk. Distinct from "never installed" -# because the recovery is update, not install. -@test "bin/agmsg.js: an install without that script says update, not install" { - local home="$BATS_TEST_TMPDIR/stale-install" - mkdir -p "$home/.agents/skills/agmsg/scripts" - touch "$home/.agents/skills/agmsg/scripts/history.sh" # some other command - run env HOME="$home" node "$BIN" send hello - [ "$status" -eq 2 ] - [[ "$output" =~ "does not contain that command" ]] - [[ ! "$output" =~ "does not look installed" ]] - [[ "$output" =~ "npx agmsg install" ]] -} - -# An unmapped verb names the DIRECTORY, so the directory is all that is -# checked — the contract matches what is printed. -@test "bin/agmsg.js: an unmapped verb is satisfied by the directory alone" { - local home="$BATS_TEST_TMPDIR/dir-only" - mkdir -p "$home/.agents/skills/agmsg/scripts" - run env HOME="$home" node "$BIN" storage list - [ "$status" -eq 2 ] - [[ ! "$output" =~ "does not look installed" ]] - [[ "$output" =~ "ls " ]] -} - -@test "bin/agmsg.js: toBashPath converts backslashes to forward slashes (#262)" { - run node -e 'const { toBashPath } = require(process.argv[1]); const input = String.raw`C:\Users\me\AppData\Local\Temp\agmsg-bootstrap-abc123\setup.sh`; const expected = "C:/Users/me/AppData/Local/Temp/agmsg-bootstrap-abc123/setup.sh"; if (toBashPath(input) !== expected) process.exit(1);' "$BIN" - [ "$status" -eq 0 ] -} - -@test "bin/agmsg.js: toBashPath is a no-op on POSIX paths" { - run node -e 'const { toBashPath } = require(process.argv[1]); const p = "/tmp/agmsg-bootstrap-abc123/setup.sh"; if (toBashPath(p) !== p) process.exit(1);' "$BIN" - [ "$status" -eq 0 ] + grep -Fq -- "Usage:" <<<"$output" +} + +@test "bin/agmsg.js: the runtime is handed the arguments unchanged and its exit status comes back" { + local rt="$BATS_TEST_TMPDIR/agmsg" + cat > "$rt" <<'RT' +#!/usr/bin/env bash +printf 'argc=%s\n' "$#" +for a in "$@"; do printf '[%s]\n' "$a"; done +exit 7 +RT + run node -e 'require(process.argv[1]).runRuntime(process.argv[2], ["daemon", "two words", "日本語", "it'"'"'s"])' "$BIN" "$rt" + [ "$status" -eq 7 ] + grep -Fq -- "argc=4" <<<"$output" + grep -Fq -- "[two words]" <<<"$output" + grep -Fq -- "[日本語]" <<<"$output" + grep -Fq -- "[it's]" <<<"$output" +} + +# Which install the entry hands off to: the default one, or the one AGMSG_CMD +# names -- and a named install that is missing must never fall back. +@test "bin/agmsg.js: install resolution (default, AGMSG_CMD, no fallback, several candidates)" { + local root="$BATS_TEST_TMPDIR/skills" + mkdir -p "$root/other/scripts" "$root/third/scripts" + touch "$root/other/scripts/agmsg" "$root/third/scripts/agmsg" + + # No default install, two others: list them, choose nothing. + run node -e 'const r=require(process.argv[1]).resolveRuntime({}, process.argv[2]); console.log(JSON.stringify(r))' "$BIN" "$root" + grep -Fq -- '"error":true' <<<"$output" + grep -Fq -- "other" <<<"$output" + grep -Fq -- "third" <<<"$output" + + # Named install: used. + run node -e 'const r=require(process.argv[1]).resolveRuntime({AGMSG_CMD:"other"}, process.argv[2]); console.log(r.runtime)' "$BIN" "$root" + [ "$output" = "$root/other/scripts/agmsg" ] + + # Named install that does not exist: an error, even though a default exists. + mkdir -p "$root/agmsg/scripts"; touch "$root/agmsg/scripts/agmsg" + run node -e 'const r=require(process.argv[1]).resolveRuntime({AGMSG_CMD:"missing"}, process.argv[2]); console.log(JSON.stringify(r))' "$BIN" "$root" + grep -Fq -- '"error":true' <<<"$output" + + # Default install present and no AGMSG_CMD: the default. + run node -e 'const r=require(process.argv[1]).resolveRuntime({}, process.argv[2]); console.log(r.runtime)' "$BIN" "$root" + [ "$output" = "$root/agmsg/scripts/agmsg" ] + + # A name that is not a plain install name is refused. + run node -e 'const r=require(process.argv[1]).resolveRuntime({AGMSG_CMD:"../x"}, process.argv[2]); console.log(JSON.stringify(r))' "$BIN" "$root" + grep -Fq -- '"error":true' <<<"$output" } diff --git a/uninstall.sh b/uninstall.sh index 5ba2e28ba..acfe27234 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -483,6 +483,19 @@ _uninstall_one() { fi done + # --- Remove the agmsg command launcher this install placed --- + # Only a launcher that carries our marker, names this install, and still has + # the content we wrote is removed. Older installs have no launcher library; + # there is then nothing of ours to remove. + local _launcher_lib + for _launcher_lib in "$SKILL_DIR/scripts/lib/agmsg-launcher.sh" "$SCRIPT_DIR/scripts/lib/agmsg-launcher.sh"; do + [ -r "$_launcher_lib" ] || continue + # shellcheck disable=SC1090 + . "$_launcher_lib" + AGMSG_LAUNCHER_RM=_uninstall_checked_rm agmsg_launcher_uninstall "$SKILL_DIR" || return 1 + break + done + # --- Remove the skill directory --- if [ "$KEEP_DATA" = true ]; then echo "" From c8e2ae9588fe12c8726f322c12e90f67f3dbfb16 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 12:55:56 -0700 Subject: [PATCH 06/97] fix(agmsgd): recheck install state before ownership claim (#1514) The entrypoint captures the verified manifest and schema while holding the install-operation lock. Before ownership claim it reacquires the lock, refuses an incomplete operation, and rechecks the manifest generation, install ID, and full scripts tree; it then opens install.db and applies the captured schema while protected. The lock remains held through ownership claim and control-socket readiness, so install.db is never opened outside the lock. A regression test injects the incomplete-operation marker immediately after the initial lock is released and verifies agmsgd exits with status 75 without claiming daemon ownership. Focused validation: the new entrypoint race test, the existing incomplete-operation refusal test, and the real entrypoint start/status/stop test pass; startup unit tests pass 2/2; the enforced-assertions checker passes at the existing baseline. --- scripts/daemon/agmsgd | 168 ++++++++++++++++++------------ scripts/daemon/main.mjs | 57 ++++++---- scripts/daemon/owner.mjs | 9 +- tests/test_agmsgd_entrypoint.bats | 22 ++++ 4 files changed, 166 insertions(+), 90 deletions(-) diff --git a/scripts/daemon/agmsgd b/scripts/daemon/agmsgd index 9b556e632..9e3df7837 100755 --- a/scripts/daemon/agmsgd +++ b/scripts/daemon/agmsgd @@ -50,6 +50,67 @@ function sha256File(installRoot, relPath) { return createHash("sha256").update(readFileSync(join(installRoot, relPath))).digest("hex"); } +function verifyInstallGeneration(installRoot, { incompleteOperationPath, manifestPath, dbPath, includeSchema = false }) { + if (existsSync(incompleteOperationPath)) { + fail(75, "an install or uninstall operation is incomplete; recover it before starting agmsgd"); + } + + if (!existsSync(manifestPath)) { + fail(0, "no completion record -- install.sh needs to run again"); + } + const manifestText = readFileSync(manifestPath, "utf8"); + const manifest = JSON.parse(manifestText); + + const selfPath = "scripts/daemon/agmsgd"; + const selfEntry = manifest.files.find((f) => f.path === selfPath); + if (!selfEntry) fail(75, `completion record has no entry for ${selfPath}`); + const selfDigest = sha256File(installRoot, selfPath); + if (selfDigest !== selfEntry.digest) { + fail(75, "this file's own digest does not match the completion record"); + } + + if (manifest.bootstrap_version !== BOOTSTRAP_VERSION) { + fail(75, `bootstrap version mismatch: this file is ${BOOTSTRAP_VERSION}, record has ${manifest.bootstrap_version}`); + } + + const installDb = new DatabaseSync(dbPath, { readOnly: true }); + let recordedInstallId; + try { + recordedInstallId = installDb.prepare("SELECT install_id FROM meta").get().install_id; + } finally { + installDb.close(); + } + if (recordedInstallId !== manifest.install_id) { + fail(75, `install_id mismatch: install.db has ${recordedInstallId ?? "(none)"}, manifest has ${manifest.install_id}`); + } + + if (manifest.digest_algo !== "sha256") { + fail(75, `unsupported digest_algo: ${manifest.digest_algo}`); + } + let onDisk; + try { + onDisk = collectScriptFiles(installRoot); + } catch (error) { + fail(75, `digest verification failed: ${error.message}`); + } + const expected = [...manifest.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const onDiskSet = new Set(onDisk); + const expectedPaths = expected.map((f) => f.path); + const expectedSet = new Set(expectedPaths); + const missing = expectedPaths.filter((p) => !onDiskSet.has(p)); + if (missing.length > 0) fail(75, `digest verification failed: missing file(s): ${missing.join(", ")}`); + const extra = onDisk.filter((p) => !expectedSet.has(p)); + if (extra.length > 0) fail(75, `digest verification failed: unexpected file(s): ${extra.join(", ")}`); + for (const { path, digest } of expected) { + if (sha256File(installRoot, path) !== digest) fail(75, `digest verification failed: digest mismatch: ${path}`); + } + + const schemaText = includeSchema + ? readFileSync(join(dirname(fileURLToPath(import.meta.url)), "schema.sql"), "utf8") + : undefined; + return { manifest, manifestText, schemaText }; +} + async function main() { const [installRoot, desired, opGenStr] = process.argv.slice(2); if (!installRoot || !desired || !opGenStr) { @@ -64,68 +125,10 @@ async function main() { const lockDb = new DatabaseSync(lockPath); lockDb.exec("BEGIN EXCLUSIVE;"); - let manifest; + let verified; let mainModule; try { - if (existsSync(incompleteOperationPath)) { - fail(75, "an install or uninstall operation is incomplete; recover it before starting agmsgd"); - } - - // 1. An install generation exists at all. - if (!existsSync(manifestPath)) { - fail(0, "no completion record -- install.sh needs to run again"); - } - manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - - // 2. This file's own digest is in the record, at its own path. - const selfPath = "scripts/daemon/agmsgd"; - const selfEntry = manifest.files.find((f) => f.path === selfPath); - if (!selfEntry) fail(75, `completion record has no entry for ${selfPath}`); - const selfDigest = sha256File(installRoot, selfPath); - if (selfDigest !== selfEntry.digest) { - fail(75, "this file's own digest does not match the completion record"); - } - - // 3. Bootstrap version matches; the launcher checks its own version too. - if (manifest.bootstrap_version !== BOOTSTRAP_VERSION) { - fail(75, `bootstrap version mismatch: this file is ${BOOTSTRAP_VERSION}, record has ${manifest.bootstrap_version}`); - } - - // 4. install_id: install.db's meta is the source of truth (PR 2); the - // manifest carries only a copy. - const installDb = new DatabaseSync(dbPath, { readOnly: true }); - let recordedInstallId; - try { - recordedInstallId = installDb.prepare("SELECT install_id FROM meta").get().install_id; - } finally { - installDb.close(); - } - if (recordedInstallId !== manifest.install_id) { - fail(75, `install_id mismatch: install.db has ${recordedInstallId ?? "(none)"}, manifest has ${manifest.install_id}`); - } - - // 5. Full digest verification: every file the manifest lists, no extras, - // no missing files, and no symlinks. - if (manifest.digest_algo !== "sha256") { - fail(75, `unsupported digest_algo: ${manifest.digest_algo}`); - } - let onDisk; - try { - onDisk = collectScriptFiles(installRoot); - } catch (error) { - fail(75, `digest verification failed: ${error.message}`); - } - const expected = [...manifest.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); - const onDiskSet = new Set(onDisk); - const expectedPaths = expected.map((f) => f.path); - const expectedSet = new Set(expectedPaths); - const missing = expectedPaths.filter((p) => !onDiskSet.has(p)); - if (missing.length > 0) fail(75, `digest verification failed: missing file(s): ${missing.join(", ")}`); - const extra = onDisk.filter((p) => !expectedSet.has(p)); - if (extra.length > 0) fail(75, `digest verification failed: unexpected file(s): ${extra.join(", ")}`); - for (const { path, digest } of expected) { - if (sha256File(installRoot, path) !== digest) fail(75, `digest verification failed: digest mismatch: ${path}`); - } + verified = verifyInstallGeneration(installRoot, { incompleteOperationPath, manifestPath, dbPath, includeSchema: true }); // All checks passed -- now, and only now, import the rest of this // component. main.mjs's own module graph (owner/control/lifecycle/ @@ -137,15 +140,48 @@ async function main() { lockDb.close(); } - const db = new DatabaseSync(dbPath); - db.exec(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "schema.sql"), "utf8")); - await mainModule.main(db, { + async function prepareClaim() { + let claimLock; + try { + claimLock = new DatabaseSync(lockPath); + claimLock.exec("BEGIN EXCLUSIVE;"); + } catch (error) { + claimLock?.close(); + fail(75, `cannot recheck install state before daemon ownership claim: ${error.message}`); + } + let handedOff = false; + try { + const current = verifyInstallGeneration(installRoot, { incompleteOperationPath, manifestPath, dbPath }); + if (current.manifestText !== verified.manifestText) { + fail(75, "install generation changed before daemon ownership claim"); + } + const db = new DatabaseSync(dbPath); + db.exec(verified.schemaText); + const release = () => { + try { + claimLock.exec("COMMIT;"); + } finally { + claimLock.close(); + } + }; + handedOff = true; + return { db, release }; + } finally { + if (!handedOff) { + claimLock.exec("COMMIT;"); + claimLock.close(); + } + } + } + + await mainModule.main(null, { installRoot, - manifest, - manifestText: readFileSync(manifestPath, "utf8"), + manifest: verified.manifest, + manifestText: verified.manifestText, expectedDesired: desired, expectedOpGen, - version: manifest.version, + version: verified.manifest.version, + prepareClaim, }); } diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs index 0cf626002..28762f5a7 100644 --- a/scripts/daemon/main.mjs +++ b/scripts/daemon/main.mjs @@ -23,26 +23,39 @@ import { classify } from "./status.mjs"; export const POLL_INTERVAL_MS = 5000; // Takes ownership and binds the control socket. Returns -// {ok: true, gen, controlHandle} or {ok: false, reason} -- a refusal from -// takeOwnership OR a bind failure, both reported the same shape so the -// caller (main()) can log+exit either without a separate branch. A bind -// failure additionally reverts daemon_owner back to 'none' (the -// step-2 failure path) before returning. -export async function startup(db, { installRoot, expectedDesired, expectedOpGen, version, handlers }) { - const owned = takeOwnership(db, { installRoot, expectedDesired, expectedOpGen, version }); - if (!owned.ok) return owned; - - let controlHandle; +// {ok: true, gen, controlHandle, db} or {ok: false, reason, db} -- a +// refusal from takeOwnership OR a bind failure, both reported the same +// shape so the caller (main()) can log+exit either without a separate +// branch. A bind failure additionally reverts daemon_owner back to 'none'. +// When supplied, prepareClaim rechecks the install while holding its lock +// and returns the database plus a release callback; that lock stays held +// through ownership claim and socket readiness. +export async function startup(db, { installRoot, expectedDesired, expectedOpGen, version, handlers, prepareClaim }) { + let releaseInstallLock = () => {}; try { - controlHandle = await createControlServer(owned.socket, handlers); - } catch (error) { - revertToNone(db, owned.gen, "bind_failed"); - logLine(installRoot, `startup: bind failed for gen ${owned.gen}: ${error.message}`); - return { ok: false, reason: `bind failed: ${error.message}` }; + if (prepareClaim) { + const prepared = await prepareClaim(); + db = prepared.db; + releaseInstallLock = prepared.release; + } + + const owned = takeOwnership(db, { installRoot, expectedDesired, expectedOpGen, version }); + if (!owned.ok) return { ...owned, db }; + + let controlHandle; + try { + controlHandle = await createControlServer(owned.socket, handlers); + } catch (error) { + revertToNone(db, owned.gen, "bind_failed"); + logLine(installRoot, `startup: bind failed for gen ${owned.gen}: ${error.message}`); + return { ok: false, reason: `bind failed: ${error.message}`, db }; + } + markReady(db, owned.gen); + logLine(installRoot, `startup: gen ${owned.gen} ready at ${owned.socket}`); + return { ok: true, gen: owned.gen, controlHandle, db }; + } finally { + releaseInstallLock(); } - markReady(db, owned.gen); - logLine(installRoot, `startup: gen ${owned.gen} ready at ${owned.socket}`); - return { ok: true, gen: owned.gen, controlHandle }; } // One poll cycle. Returns @@ -90,7 +103,7 @@ export async function gracefulStop(db, installRoot, gen, controlHandle, channelH // already verified. Exit codes: 0 for a declined // start or a normal/SIGTERM stop, 75 for stepping aside for an update, 1 // for a bind failure or any other unexpected error. -export async function main(db, { installRoot, manifest, manifestText, expectedDesired, expectedOpGen, version }) { +export async function main(db, { installRoot, manifest, manifestText, expectedDesired, expectedOpGen, version, prepareClaim }) { const channelHooks = []; // beta's Codex-queue channel plugs in here, separately. let controlHandle; let gen; @@ -109,6 +122,11 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe expectedDesired, expectedOpGen, version, + prepareClaim: prepareClaim ? async () => { + const prepared = await prepareClaim(); + db = prepared.db; + return prepared; + } : undefined, handlers: { onStop: async () => { // Fires from inside a control-socket request; the response itself @@ -120,6 +138,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe onStatus: async () => classify({ owner: { gen, state: "ready", version }, intent: {}, alive: true, reachable: true }), }, }); + db = started.db ?? db; if (!started.ok) { logLine(installRoot, `startup declined: ${started.reason}`); db.prepare("INSERT INTO daemon_start_attempts (at, reason, executor_pid) VALUES (?, ?, ?)").run( diff --git a/scripts/daemon/owner.mjs b/scripts/daemon/owner.mjs index 48d4e0235..780e6bb85 100644 --- a/scripts/daemon/owner.mjs +++ b/scripts/daemon/owner.mjs @@ -5,11 +5,10 @@ // never from here. This file only READS daemon_intent, to condition taking // ownership on it still matching what the launcher observed. // -// The install-op lock (run/install-op.lock.db, PR 2) is a SEPARATE -// mechanism the entrypoint holds only while loading code; by the time -// takeOwnership() runs, that lock has already been -// released. daemon_owner's own gen-conditioned CAS is self-contained and -// needs no external lock. +// The install-op lock (run/install-op.lock.db) is separate from the +// daemon_owner CAS. The entrypoint holds it while revalidating the install +// generation and through takeOwnership() plus control-socket readiness, so +// an install cannot begin between validation and this claim. import { join } from "node:path"; import { currentExecutor, isAlive } from "./executor.mjs"; diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats index 3216ae390..55d4ef381 100644 --- a/tests/test_agmsgd_entrypoint.bats +++ b/tests/test_agmsgd_entrypoint.bats @@ -60,6 +60,18 @@ _write_completion_record() { sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET install_id = '$install_id', node_path = '$(command -v node)'; UPDATE daemon_intent SET desired = 'on', op_gen = 0;" } +_inject_incomplete_marker_after_initial_unlock() { + node - "$TEST_SKILL_DIR/scripts/daemon/agmsgd" <<'NODE' +const { readFileSync, writeFileSync } = require('node:fs'); +const scriptPath = process.argv[2]; +const source = readFileSync(scriptPath, 'utf8'); +const needle = ' let claimLock;\n try {\n'; +if (source.split(needle).length !== 2) throw new Error('claim-lock insertion point must occur exactly once'); +const insert = ' await import("node:fs/promises").then(({ writeFile }) => writeFile(incompleteOperationPath, JSON.stringify({ operation_id: "injected-after-unlock" })));\n'; +writeFileSync(scriptPath, source.replace(needle, insert + needle)); +NODE +} + @test "agmsgd end-to-end: starts, answers status over its real control socket, and stops cleanly on request" { _write_completion_record @@ -124,3 +136,13 @@ _write_completion_record() { [ "$status" -eq 75 ] [[ "$output" == *"operation is incomplete"* ]] } + +@test "agmsgd does not claim ownership when an install starts after bootstrap unlock" { + _inject_incomplete_marker_after_initial_unlock + _write_completion_record + + run node "$SCRIPTS/daemon/agmsgd" "$TEST_SKILL_DIR" on 0 + [ "$status" -eq 75 ] + printf '%s\n' "$output" | grep -Fq "operation is incomplete" + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" = "none" ] +} From cc16cae3771488c9e2691805dde2560d83d5823f Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 13:40:06 -0700 Subject: [PATCH 07/97] tests: keep install cancellation children from holding Bats pipes (#1517) Fixes a macOS CI hang in the install cancellation test. Its deliberately blocked copy writer inherited Bats file descriptors 3/4 and standard streams, and the test did not register the lock child with teardown. A process left with those descriptors could keep the test job output pipe open after the assertions finished. The test now disconnects both background installer invocations from Bats descriptors and stdin, closes the fake writer descriptors, and registers the writer and SQLite lock child with teardown. Teardown checks each command before signaling and waits for confirmed exit. Validation: the focused cancellation test passes locally, and a post-run process check found no matching writer, lock, or installer processes. --- tests/test_install.bats | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_install.bats b/tests/test_install.bats index decf4ee65..574a70a6d 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -2415,13 +2415,13 @@ CYG 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/cancel-writer}"' \ 'target="$test_home/.agents/skills/agmsg/scripts/"' \ 'dest=""; for arg do dest="$arg"; done' \ - 'if [ "$dest" = "$target" ]; then exec 8>&- 9>&-; printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer.pid"; touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer-entered"; if [ -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-kill-mode" ]; then printf "%s\\n" "$2" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/stage-source"; while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-release" ]; do sleep 0.02; done; exit 0; fi; while :; do sleep 1; done; fi' \ + 'if [ "$dest" = "$target" ]; then exec 8>&- 9>&- 3>&- 4>&- /dev/null 2>&1; printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer.pid"; touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer-entered"; if [ -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-kill-mode" ]; then printf "%s\\n" "$2" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/stage-source"; while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-release" ]; do sleep 0.02; done; exit 0; fi; while :; do sleep 1; done; fi' \ "exec $cp_q \"\$@\"" > "$bin/cp" chmod +x "$bin/cp" HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ - bash "$REPO_ROOT/install.sh" --update > "$inject_dir/install.out" 2>&1 & + bash "$REPO_ROOT/install.sh" --update "$inject_dir/install.out" 2>&1 3>&- 4>&- & install_pid=$! _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" for ((i = 0; i < 250; i++)); do @@ -2431,7 +2431,9 @@ CYG done [ -e "$inject_dir/writer-entered" ] writer_pid="$(cat "$inject_dir/writer.pid")" + lock_pid="$(cat "$inject_dir/lock.pid")" _agmsg_watch_pid "$writer_pid" "$bin/cp" + _agmsg_watch_pid "$lock_pid" "$SK/run/install-op.lock.db" [ -e "$SK/run/install-op-incomplete.json" ] kill -TERM "$install_pid" @@ -2445,7 +2447,7 @@ CYG rm -f "$inject_dir/writer-entered" "$inject_dir/hard-release" HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ - bash "$REPO_ROOT/install.sh" --update > "$inject_dir/hard-kill-install.out" 2>&1 & + bash "$REPO_ROOT/install.sh" --update "$inject_dir/hard-kill-install.out" 2>&1 3>&- 4>&- & install_pid=$! _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" for ((i = 0; i < 250; i++)); do @@ -2457,6 +2459,7 @@ CYG writer_pid="$(cat "$inject_dir/writer.pid")" lock_pid="$(cat "$inject_dir/lock.pid")" _agmsg_watch_pid "$writer_pid" "$bin/cp" + _agmsg_watch_pid "$lock_pid" "$SK/run/install-op.lock.db" op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" kill -KILL "$install_pid" if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi From 96b43b51bf2c8e16cd96955a7fbab17f4f26e2b6 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 15:20:45 -0700 Subject: [PATCH 08/97] Add Codex queue delivery and seat status (#1520) The daemon now tracks Codex seat destinations and reads existing message stores without selecting message bodies. For an addressable seat, it queues one nonce-bearing inbox nudge, records the queued item, and confirms delivery from the queue row or rollout; unreadable observations remain pending and visible in status. Per-seat status reports addressable, unaddressable, blocked, or bridged, including a reason when delivery cannot be established. Queue writes are serialized by CODEX_HOME. Windows queue delivery remains blocked with an explicit status reason until delivery to a live conversation is verified. Local checks: focused Codex queue, daemon main, and status Bats tests; syntax checks for changed modules. --- scripts/daemon.sh | 3 +- scripts/daemon/channels/codex-queue-io.mjs | 347 ++++++++++++ scripts/daemon/channels/codex-queue-store.mjs | 217 ++++++++ scripts/daemon/channels/codex-queue.mjs | 515 ++++++++++++++++++ scripts/daemon/channels/process-group.mjs | 88 +++ scripts/daemon/main.mjs | 46 +- scripts/daemon/status.mjs | 18 +- tests/agmsgd_codex_queue.test.mjs | 422 ++++++++++++++ tests/agmsgd_main.test.mjs | 28 +- tests/test_agmsgd_codex_queue.bats | 6 + tests/test_agmsgd_daemon_sh.bats | 27 +- 11 files changed, 1698 insertions(+), 19 deletions(-) create mode 100644 scripts/daemon/channels/codex-queue-io.mjs create mode 100644 scripts/daemon/channels/codex-queue-store.mjs create mode 100644 scripts/daemon/channels/codex-queue.mjs create mode 100644 scripts/daemon/channels/process-group.mjs create mode 100644 tests/agmsgd_codex_queue.test.mjs create mode 100644 tests/test_agmsgd_codex_queue.bats diff --git a/scripts/daemon.sh b/scripts/daemon.sh index eb6ad7f1b..2c6cc2e70 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -281,7 +281,8 @@ _start_registered_service() { } _start_unregistered_launcher() { - bash "$LAUNCHER" /dev/null 2>&1 3>&- 4>&- & + local stderr_log="${AGMSGD_TEST_LAUNCH_STDERR_LOG:-/dev/null}" + bash "$LAUNCHER" /dev/null 2>"$stderr_log" 3>&- 4>&- & disown 2>/dev/null || true } diff --git a/scripts/daemon/channels/codex-queue-io.mjs b/scripts/daemon/channels/codex-queue-io.mjs new file mode 100644 index 000000000..1a6a231bb --- /dev/null +++ b/scripts/daemon/channels/codex-queue-io.mjs @@ -0,0 +1,347 @@ +// Codex queue I/O for the beta channel. This file knows only Codex's local +// profile format and CLI contract; the daemon loop and its install.db schema +// remain owned by the caller. + +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { createReadStream, lstatSync, readdirSync } from "node:fs"; +import { createInterface } from "node:readline"; +import { isAbsolute, join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const MAX_OUTPUT_CHARS = 64 * 1024; +export const QUEUE_CONFIRMATION_TTL_MS = 60_000; + +export function newNonce() { + return randomUUID(); +} + +export function inboxNudge(nonce) { + if (typeof nonce !== "string" || !/^[A-Za-z0-9-]{1,80}$/.test(nonce)) { + throw new TypeError("nonce must be a short token"); + } + return `[agmsg:${nonce}] New messages are waiting. Check your agmsg inbox.`; +} + +function appendBounded(current, chunk) { + if (current.length >= MAX_OUTPUT_CHARS) return current; + return current + chunk.toString("utf8").slice(0, MAX_OUTPUT_CHARS - current.length); +} + +function signalProcessGroup(child, signal) { + if (process.platform !== "win32" && Number.isInteger(child.pid) && child.pid > 1) { + try { + process.kill(-child.pid, signal); + return; + } catch (error) { + if (error?.code !== "ESRCH") return; + } + } + try { + child.kill(signal); + } catch { + // The close event remains the authority for whether the child exited. + } +} + +// Runs one queue operation without a shell and waits for close (not just exit) +// so stdout/stderr are fully captured before its result is interpreted. +export async function runCodexQueue({ + executable, + codexHome, + thread, + message, + timeoutMs, + signal, + onChildStart, + spawnProcess = spawn, +}) { + if (typeof executable !== "string" || executable.length === 0) throw new TypeError("missing Codex executable"); + if (typeof codexHome !== "string" || !isAbsolute(codexHome)) throw new TypeError("CODEX_HOME must be absolute"); + if (!UUID.test(thread ?? "")) throw new TypeError("thread must be a Codex UUID"); + if (typeof message !== "string" || !message) throw new TypeError("message must not be empty"); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new TypeError("timeoutMs must be a positive integer"); + if (signal?.aborted) return { kind: "cancelled", reason: "daemon_stopping" }; + + const args = ["queue", "--thread", thread, "--message", message]; + let child; + try { + child = spawnProcess(executable, args, { + env: { ...process.env, CODEX_HOME: codexHome }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + detached: process.platform !== "win32", + }); + } catch (error) { + return { kind: "failed", reason: `spawn_failed: ${error.message}` }; + } + + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); }); + child.stderr?.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); }); + + const closed = new Promise((resolveClose) => { + let settled = false; + const settle = (value) => { + if (settled) return; + settled = true; + resolveClose(value); + }; + child.once("error", (error) => settle({ error })); + child.once("close", (code, signal) => settle({ code, signal })); + }); + + let timedOut = false; + let aborted = false; + const killTimers = []; + const onAbort = () => { + aborted = true; + signalProcessGroup(child, "SIGTERM"); + killTimers.push(setTimeout(() => signalProcessGroup(child, "SIGKILL"), 1000)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + const timer = setTimeout(() => { + timedOut = true; + signalProcessGroup(child, "SIGTERM"); + }, timeoutMs); + const hardKill = setTimeout(() => { + if (timedOut) signalProcessGroup(child, "SIGKILL"); + }, timeoutMs + 1000); + let childRecordFailed = false; + if (onChildStart) { + try { + onChildStart(child.pid); + } catch { + childRecordFailed = true; + signalProcessGroup(child, "SIGTERM"); + killTimers.push(setTimeout(() => signalProcessGroup(child, "SIGKILL"), 1000)); + } + } + const result = await closed; + clearTimeout(timer); + clearTimeout(hardKill); + for (const killTimer of killTimers) clearTimeout(killTimer); + signal?.removeEventListener("abort", onAbort); + + if (childRecordFailed) return { kind: "failed", reason: "child_record_failed", stdout, stderr }; + if (aborted) return { kind: "cancelled", reason: "daemon_stopping", stdout, stderr }; + if (timedOut) return { kind: "timeout", stdout, stderr }; + if (result.error) return { kind: "failed", reason: `spawn_failed: ${result.error.message}`, stdout, stderr }; + const combined = `${stdout}\n${stderr}`; + if (result.code !== 0) { + if (combined.includes("code -32600")) return { kind: "archived", reason: "thread_archived" }; + if (combined.includes("code -32603")) return { kind: "no_rollout", reason: "thread_rollout_not_found" }; + return { kind: "failed", reason: `queue_exit_${result.code ?? result.signal ?? "unknown"}` }; + } + + const queued = stdout.match(/^Queued message ([0-9a-f-]{36}) for thread ([0-9a-f-]{36})\.?\s*$/m); + if (!queued || queued[2].toLowerCase() !== thread.toLowerCase() || !UUID.test(queued[1])) { + return { kind: "failed", reason: "queue_output_unrecognized" }; + } + return { kind: "queued", queueItemId: queued[1] }; +} + +// Tri-state by design: an unreadable DB is not an empty queue. A row is +// corroborating evidence only when both its id and thread match. +export function readQueuedItem(codexHome, queueItemId, thread) { + if (!isAbsolute(codexHome ?? "") || !UUID.test(queueItemId ?? "") || !UUID.test(thread ?? "")) { + return { state: "unreadable", reason: "invalid_queue_lookup" }; + } + const dbPath = join(codexHome, "queue_1.sqlite"); + let db; + try { + if (!lstatSync(dbPath).isFile()) return { state: "unreadable", reason: "queue_db_missing" }; + db = new DatabaseSync(dbPath, { readOnly: true, allowExtension: false }); + db.exec("PRAGMA busy_timeout = 1000;"); + const row = db.prepare("SELECT thread_id FROM queued_items WHERE id = ?").get(queueItemId); + if (!row) return { state: "absent" }; + if (row.thread_id !== thread) return { state: "unreadable", reason: "queue_thread_mismatch" }; + return { state: "present" }; + } catch { + return { state: "unreadable", reason: "queue_db_read_failed" }; + } finally { + try { db?.close(); } catch { /* the query result already carries the observation */ } + } +} + +function matchingRollouts(sessionsDir, thread) { + const matches = []; + const walk = (dir, depth) => { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isSymbolicLink()) throw new Error("symlink_in_sessions"); + if (entry.isDirectory() && depth < 3) walk(path, depth + 1); + else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(`-${thread}.jsonl`)) matches.push(path); + } + }; + walk(sessionsDir, 0); + return matches; +} + +function userText(row) { + if (row?.type === "event_msg" && row.payload?.type === "user_message") { + const value = row.payload.message; + return typeof value === "string" ? { kind: "text", text: value } : { kind: "unreadable" }; + } + if (row?.type === "response_item" && row.payload?.type === "message" && row.payload.role === "user") { + if (!Array.isArray(row.payload.content) || row.payload.content.length === 0) return { kind: "unreadable" }; + const pieces = []; + for (const part of row.payload.content) { + if (typeof part?.text !== "string") return { kind: "unreadable" }; + pieces.push(part.text); + } + return { kind: "text", text: pieces.join("\n") }; + } + return { kind: "other" }; +} + +async function rolloutHasNonce(path, thread, nonce) { + let malformed = false; + let first = true; + let sawUserRow = false; + let sawUnreadableUserRow = false; + const input = createInterface({ input: createReadStream(path, { encoding: "utf8" }), crlfDelay: Infinity }); + try { + for await (const line of input) { + if (!line) continue; + let row; + try { + row = JSON.parse(line); + } catch { + malformed = true; + continue; + } + if (first) { + first = false; + if (row?.type !== "session_meta" || row.payload?.id !== thread) return { state: "unreadable", reason: "rollout_thread_mismatch" }; + } + const observation = userText(row); + if (observation.kind === "unreadable") { + sawUnreadableUserRow = true; + } else if (observation.kind === "text") { + sawUserRow = true; + if (observation.text.includes(nonce)) return { state: "present" }; + } + } + } catch { + return { state: "unreadable", reason: "rollout_read_failed" }; + } + if (first) return { state: "unreadable", reason: "rollout_empty" }; + if (malformed) return { state: "unreadable", reason: "rollout_malformed" }; + if (sawUnreadableUserRow) return { state: "unreadable", reason: "rollout_user_row_unrecognized" }; + if (!sawUserRow) return { state: "unreadable", reason: "rollout_user_rows_unrecognized" }; + return { state: "absent" }; +} + +// This observation may not yet be available on unauthenticated runs. Unknown +// is deliberately separate from absent so callers can leave the row pending. +export async function readRolloutNonce(codexHome, thread, nonce) { + if (!isAbsolute(codexHome ?? "") || !UUID.test(thread ?? "") || !/^[A-Za-z0-9-]{1,80}$/.test(nonce ?? "")) { + return { state: "unreadable", reason: "invalid_rollout_lookup" }; + } + const sessionsDir = join(codexHome, "sessions"); + let matches; + try { + let sessionsStat; + try { + sessionsStat = lstatSync(sessionsDir); + } catch (error) { + if (error?.code === "ENOENT") return { state: "unreadable", reason: "sessions_dir_missing" }; + throw error; + } + if (sessionsStat.isSymbolicLink() || !sessionsStat.isDirectory()) { + return { state: "unreadable", reason: "sessions_path_not_directory" }; + } + matches = matchingRollouts(sessionsDir, thread); + } catch { + return { state: "unreadable", reason: "sessions_scan_failed" }; + } + if (matches.length === 0) return { state: "unreadable", reason: "thread_rollout_missing" }; + if (matches.length !== 1) return { state: "unreadable", reason: "multiple_thread_rollouts" }; + return rolloutHasNonce(matches[0], thread, nonce); +} + +// A positive observation is enough to confirm. Two readable negative +// observations wait for the retry window, then expire. Any unreadable or +// malformed observation remains pending; callers must surface that state +// instead of treating it as an empty queue or an absent nonce. +export function resolvePendingQueue({ queueObservation, rolloutObservation, ageMs }) { + if (queueObservation?.state === "present") { + return { state: "confirmed", reason: "queue_item_present" }; + } + if (rolloutObservation?.state === "present") { + return { state: "confirmed", reason: "nonce_observed" }; + } + + const knownState = (observation) => + observation?.state === "absent" || observation?.state === "unreadable"; + if (!knownState(queueObservation) || !knownState(rolloutObservation)) { + return { state: "pending", reason: "verification_unreadable" }; + } + if (queueObservation.state === "unreadable" || rolloutObservation.state === "unreadable") { + return { state: "pending", reason: "verification_unreadable" }; + } + if (!Number.isSafeInteger(ageMs) || ageMs < 0) { + return { state: "pending", reason: "pending_age_unreadable" }; + } + if (ageMs >= QUEUE_CONFIRMATION_TTL_MS) { + return { state: "expired", reason: "confirmation_timeout" }; + } + return { state: "pending", reason: "awaiting_confirmation" }; +} + +// Call immediately after the caller has durably stored queueItemId. Check the +// queue first: Codex may consume the item before the rollout can be inspected. +// A present row is already sufficient evidence, so that fast path does not +// depend on the not-yet-measured processed-message serialization. +export async function verifyPendingDelivery({ + codexHome, + queueItemId, + thread, + nonce, + ageMs, + readQueue = readQueuedItem, + readRollout = readRolloutNonce, +}) { + let queueObservation; + try { + queueObservation = readQueue(codexHome, queueItemId, thread); + } catch { + queueObservation = { state: "unreadable", reason: "queue_db_read_failed" }; + } + if (queueObservation?.state === "present") { + return { state: "confirmed", reason: "queue_item_present" }; + } + + let rolloutObservation; + try { + rolloutObservation = await readRollout(codexHome, thread, nonce); + } catch { + rolloutObservation = { state: "unreadable", reason: "rollout_read_failed" }; + } + return resolvePendingQueue({ queueObservation, rolloutObservation, ageMs }); +} + +export function classifyCodexSeat({ roleSession, bridgeState, rolloutState }) { + if (bridgeState === "running") return { state: "bridged", reason: "bridge_running" }; + if (!roleSession || !roleSession.thread || !roleSession.codex_home) { + return { state: "unaddressable", reason: "role_session_missing" }; + } + if (!UUID.test(roleSession.thread)) return { state: "unaddressable", reason: "thread_id_invalid" }; + if (!isAbsolute(roleSession.codex_home)) return { state: "unaddressable", reason: "codex_home_invalid" }; + if (bridgeState !== "stopped") return { state: "blocked", reason: "bridge_state_unknown" }; + if (rolloutState === "valid") return { state: "addressable", reason: "" }; + if (rolloutState === "archived") return { state: "blocked", reason: "thread_archived" }; + if (rolloutState === "no_rollout") return { state: "blocked", reason: "codex_home_mismatch" }; + return { state: "blocked", reason: "thread_observation_failed" }; +} diff --git a/scripts/daemon/channels/codex-queue-store.mjs b/scripts/daemon/channels/codex-queue-store.mjs new file mode 100644 index 000000000..733c58d33 --- /dev/null +++ b/scripts/daemon/channels/codex-queue-store.mjs @@ -0,0 +1,217 @@ +// Read-only view of agmsg's existing message stores plus the daemon's small +// Codex-channel tables. The daemon never initializes or repairs a team store. + +import { DatabaseSync } from "node:sqlite"; +import { lstatSync, readFileSync, readdirSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; + +export const CODEX_CHANNEL_SCHEMA = ` +CREATE TABLE IF NOT EXISTS beta_codex_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + seat TEXT NOT NULL, + codex_home TEXT NOT NULL, + thread TEXT NOT NULL, + up_to TEXT NOT NULL, + nonce TEXT NOT NULL, + queue_item_id TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'confirmed', 'expired')), + children TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS beta_codex_queue_one_pending + ON beta_codex_queue(seat) WHERE state = 'pending'; +CREATE INDEX IF NOT EXISTS beta_codex_queue_latest + ON beta_codex_queue(seat, id DESC); +CREATE TABLE IF NOT EXISTS beta_codex_seat ( + seat TEXT PRIMARY KEY, + thread TEXT, + codex_home TEXT, + state TEXT NOT NULL CHECK (state IN ('addressable', 'unaddressable', 'blocked', 'bridged')), + reason TEXT NOT NULL, + checked_at TEXT NOT NULL +); +`; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function ensureCodexChannelSchema(db) { + db.exec("BEGIN IMMEDIATE;"); + try { + db.exec(CODEX_CHANNEL_SCHEMA); + db.exec("COMMIT;"); + } catch (error) { + try { db.exec("ROLLBACK;"); } catch { /* preserve the schema error */ } + throw error; + } +} +function readJson(path) { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("not_regular_file"); + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") return { missing: true }; + throw error; + } +} + +function encodeName(value) { + return [...Buffer.from(value, "utf8")].map((byte) => { + const char = String.fromCharCode(byte); + return /^[A-Za-z0-9._-]$/.test(char) ? char : `%${byte.toString(16).padStart(2, "0").toUpperCase()}`; + }).join(""); +} + +export function roleSessionPath(runDir, team, agent) { + return join(runDir, `role-session.${encodeName(team)}__${encodeName(agent)}`); +} + +export function readRoleSession(runDir, team, agent) { + const path = roleSessionPath(runDir, team, agent); + let stat; + try { + stat = lstatSync(path); + } catch (error) { + if (error?.code === "ENOENT") return { state: "absent" }; + return { state: "unreadable", reason: "role_session_read_failed" }; + } + if (!stat.isFile() || stat.isSymbolicLink()) return { state: "unreadable", reason: "role_session_not_regular_file" }; + try { + const fields = Object.create(null); + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { + const at = line.indexOf("="); + if (at < 1) continue; + const key = line.slice(0, at); + if (!(key in fields)) fields[key] = line.slice(at + 1); + } + return { + state: "present", + record: { + team: fields.team ?? "", + agent: fields.agent ?? "", + type: fields.type ?? "", + project: fields.project ?? "", + thread: fields.session ?? "", + codex_home: fields.codex_home ?? "", + }, + }; + } catch { + return { state: "unreadable", reason: "role_session_read_failed" }; + } +} + +// Enumerates only roles whose current registration explicitly names Codex. +// A malformed team record is reported, not interpreted as an empty roster. +export function listCodexRegistrations(teamsDir) { + let entries; + try { + entries = readdirSync(teamsDir, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return { state: "ok", seats: [] }; + return { state: "unreadable", reason: "team_roster_unreadable" }; + } + const seats = []; + for (const entry of entries) { + if (entry.isSymbolicLink()) return { state: "unreadable", reason: "team_roster_symlink" }; + if (!entry.isDirectory()) continue; + const result = readJson(join(teamsDir, entry.name, "config.json")); + if (result.missing) continue; + const config = result; + if (!config || typeof config !== "object" || !config.agents || typeof config.agents !== "object" || Array.isArray(config.agents)) { + return { state: "unreadable", reason: "team_roster_malformed" }; + } + for (const [agent, value] of Object.entries(config.agents)) { + const regs = value && Array.isArray(value.registrations) ? value.registrations : []; + if (regs.some((registration) => registration?.type === "codex")) { + seats.push({ team: entry.name, agent, teamConfig: config }); + } + } + } + return { state: "ok", seats }; +} + +export function readStorageDriver(configPath) { + const result = readJson(configPath); + if (result.missing) return { state: "ok", driver: "sqlite" }; + if (!result || typeof result !== "object" || Array.isArray(result)) { + return { state: "unreadable", reason: "storage_config_malformed" }; + } + const driver = result.storage || "sqlite"; + return typeof driver === "string" && driver.length > 0 + ? { state: "ok", driver } + : { state: "unreadable", reason: "storage_driver_unreadable" }; +} + +export function messageStorePath({ storageDir, team, teamConfig }) { + if (typeof team !== "string" || !team || team.includes("/") || team.includes("\\") || team === "." || team === "..") { + throw new TypeError("invalid_team_name"); + } + const partition = teamConfig?.drivers?.partition || "shared"; + if (partition === "shared") return join(storageDir, "messages.db"); + if (partition === "per-team") return join(storageDir, "teams", team, "messages.db"); + throw new Error("storage_partition_unsupported"); +} + +export function openMessageStore(path) { + if (!isAbsolute(path)) throw new TypeError("message_store_path_not_absolute"); + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("message_store_not_regular_file"); + const db = new DatabaseSync(path, { readOnly: true, allowExtension: false }); + try { + db.exec("PRAGMA busy_timeout = 1000; PRAGMA query_only = ON;"); + // Validate the expected read schema now. An absent table is not an empty inbox. + db.prepare("SELECT seq FROM events LIMIT 0").all(); + db.prepare("SELECT id FROM messages LIMIT 0").all(); + db.prepare("SELECT local_position FROM read_cursors LIMIT 0").all(); + return db; + } catch (error) { + db.close(); + throw error; + } +} + +function cursorValue(raw) { + if (!raw) return { eventSeq: 0, legacyId: 0 }; + try { + const cursor = JSON.parse(raw); + if (Number.isSafeInteger(cursor.eventSeq) && cursor.eventSeq >= 0 && Number.isSafeInteger(cursor.legacyId) && cursor.legacyId >= 0) { + return cursor; + } + } catch { /* malformed cursor must not become a guessed zero */ } + throw new Error("notification_cursor_unreadable"); +} + +// Uses one read transaction so the cursor never advances past a concurrently +// inserted message. Bodies are not selected or returned. +export function readUnreadSnapshot(db, team, agent, encodedCursor = "") { + const cursor = cursorValue(encodedCursor); + db.exec("BEGIN;"); + try { + const tip = db.prepare("SELECT COALESCE((SELECT seq FROM sqlite_sequence WHERE name='events'), 0) AS n").get().n; + const legacyTip = db.prepare("SELECT COALESCE(MAX(id), 0) AS n FROM messages WHERE team = ?").get(team).n; + const eventUnread = db.prepare(` + SELECT 1 AS yes FROM events e + WHERE e.type='message_sent' AND e.team=? AND e.to_agent=? + AND e.seq>? AND e.seq<=? + AND e.seq>COALESCE((SELECT local_position FROM read_cursors WHERE team=? AND agent=?), 0) + AND NOT EXISTS (SELECT 1 FROM events r WHERE r.type='message_read' AND r.team=e.team AND r.agent=? AND r.msg_id=e.id) + LIMIT 1 + `).get(team, agent, cursor.eventSeq, tip, team, agent, agent); + const legacyUnread = db.prepare(` + SELECT 1 AS yes FROM messages m + WHERE m.team=? AND m.to_agent=? AND m.id>? AND m.id<=? AND m.read_at IS NULL + AND NOT EXISTS (SELECT 1 FROM events r WHERE r.type='message_read' AND r.team=m.team AND r.agent=? AND r.msg_id=CAST(m.id AS TEXT)) + AND NOT EXISTS (SELECT 1 FROM events e2 WHERE e2.legacy_id=m.id AND e2.seq>0) + LIMIT 1 + `).get(team, agent, cursor.legacyId, legacyTip, agent); + db.exec("COMMIT;"); + return { + state: "ok", + unread: Boolean(eventUnread || legacyUnread), + upTo: JSON.stringify({ eventSeq: tip, legacyId: legacyTip }), + }; + } catch (error) { + try { db.exec("ROLLBACK;"); } catch { /* keep the observation error */ } + throw error; + } +} diff --git a/scripts/daemon/channels/codex-queue.mjs b/scripts/daemon/channels/codex-queue.mjs new file mode 100644 index 000000000..569fb7c7a --- /dev/null +++ b/scripts/daemon/channels/codex-queue.mjs @@ -0,0 +1,515 @@ +// Polls the existing message stores and nudges registered Codex seats through +// the local Codex queue. It never writes message data or read cursors. + +import { createHash } from "node:crypto"; +import { homedir, hostname } from "node:os"; +import { isAbsolute, join, resolve, sep } from "node:path"; +import { lstatSync, readFileSync, readdirSync } from "node:fs"; +import { logLine } from "../log.mjs"; +import { withImmediateTransaction } from "../db.mjs"; +import { + listCodexRegistrations, + messageStorePath, + openMessageStore, + readRoleSession, + readStorageDriver, + readUnreadSnapshot, +} from "./codex-queue-store.mjs"; +import { + classifyCodexSeat, + inboxNudge, + newNonce, + readQueuedItem, + readRolloutNonce, + resolvePendingQueue, + runCodexQueue, +} from "./codex-queue-io.mjs"; +import { captureProcessGroup, observeProcessGroup } from "./process-group.mjs"; +import { processStartWitness } from "./process-group.mjs"; + +const QUEUE_TIMEOUT_MS = 10_000; + +function seatKey(team, agent) { + return JSON.stringify([team, agent]); +} + +function sameProject(left, right) { + if (typeof left !== "string" || !left || typeof right !== "string" || !right) return false; + if (!isAbsolute(left) || !isAbsolute(right)) return false; + const normalize = (value) => { + let candidate = value; + if (process.platform === "win32") { + candidate = candidate.replace(/\\/g, "/"); + const gitBash = candidate.match(/^\/([A-Za-z])(?:\/(.*))?$/); + if (gitBash) candidate = `${gitBash[1]}:/${gitBash[2] ?? ""}`; + candidate = candidate.replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`); + } + let result = resolve(candidate).split(sep).join("/"); + if (process.platform === "win32") result = result.toLowerCase(); + return result; + }; + const a = normalize(left); + const b = normalize(right); + return a === b; +} + +function registrationProjects(teamConfig, agent) { + const registrations = teamConfig?.agents?.[agent]?.registrations; + return Array.isArray(registrations) + ? registrations.filter((item) => item?.type === "codex").map((item) => item.project).filter((value) => typeof value === "string" && value) + : []; +} + +function readPending(db, seat) { + return db.prepare(` + SELECT id, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at + FROM beta_codex_queue WHERE seat = ? AND state = 'pending' ORDER BY id DESC LIMIT 1 + `).get(seat); +} + +function pendingSeats(db) { + const rows = db.prepare("SELECT DISTINCT seat FROM beta_codex_queue WHERE state = 'pending'").all(); + const seats = []; + for (const row of rows) { + try { + const pair = JSON.parse(row.seat); + if (!Array.isArray(pair) || pair.length !== 2) continue; + const [team, agent] = pair; + const validSegment = (value) => typeof value === "string" && value.length > 0 && value !== "." && value !== ".." && + !value.startsWith("-") && !/[\\/\u0000-\u001f\u007f]/.test(value); + if (validSegment(team) && validSegment(agent)) seats.push({ team, agent }); + } catch { /* malformed historical key cannot safely identify a seat */ } + } + return seats; +} + +function latestConfirmedCursor(db, seat) { + const row = db.prepare(` + SELECT up_to FROM beta_codex_queue WHERE seat = ? AND state = 'confirmed' + ORDER BY id DESC LIMIT 1 + `).get(seat); + return row?.up_to ?? ""; +} + +function saveSeat(db, { seat, thread = null, codexHome = null, state, reason = "" }, now) { + withImmediateTransaction(db, () => { + db.prepare(` + INSERT INTO beta_codex_seat (seat, thread, codex_home, state, reason, checked_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(seat) DO UPDATE SET thread=COALESCE(excluded.thread, beta_codex_seat.thread), + codex_home=COALESCE(excluded.codex_home, beta_codex_seat.codex_home), + state=excluded.state, reason=excluded.reason, checked_at=excluded.checked_at + `).run(seat, thread, codexHome, state, reason, new Date(now()).toISOString()); + }); +} + +function setQueueState(db, id, state, queueItemId = undefined) { + withImmediateTransaction(db, () => { + if (queueItemId === undefined) { + db.prepare("UPDATE beta_codex_queue SET state = ? WHERE id = ? AND state = 'pending'").run(state, id); + } else { + db.prepare("UPDATE beta_codex_queue SET state = ?, queue_item_id = ? WHERE id = ? AND state = 'pending'") + .run(state, queueItemId, id); + } + }); +} + +function recordChildStart(db, id, pid, captureGroup) { + let group; + try { + group = captureGroup(pid); + } catch { + group = { state: "unreadable", reason: "child_start_witness_unavailable" }; + } + withImmediateTransaction(db, () => { + db.prepare("UPDATE beta_codex_queue SET children = ? WHERE id = ? AND state = 'pending'") + .run(JSON.stringify(group), id); + }); +} + +function recordChildResult(db, id, result) { + withImmediateTransaction(db, () => { + const row = db.prepare("SELECT children FROM beta_codex_queue WHERE id = ? AND state = 'pending'").get(id); + if (!row) return; + let children; + try { children = JSON.parse(row.children); } catch { children = { state: "unreadable", reason: "child_group_record_malformed" }; } + if (children.state === "incomplete" && result.kind === "failed" && result.reason?.startsWith("spawn_failed:")) { + children = { state: "absent", kind: result.kind }; + } else { + children.result = result.kind; + } + db.prepare("UPDATE beta_codex_queue SET children = ? WHERE id = ? AND state = 'pending'") + .run(JSON.stringify(children), id); + }); +} + +function createPending(db, { seat, codexHome, thread, upTo, nonce, expectedOpGen }, now) { + let rowId; + withImmediateTransaction(db, () => { + const intent = db.prepare("SELECT desired, op_gen FROM daemon_intent").get(); + if (intent?.desired !== "on" || intent.op_gen !== expectedOpGen) { + throw new Error("daemon_intent_changed"); + } + const current = db.prepare("SELECT id FROM beta_codex_queue WHERE seat = ? AND state = 'pending'").get(seat); + if (current) throw new Error("seat_already_pending"); + const result = db.prepare(` + INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) + VALUES (?, ?, ?, ?, ?, NULL, 'pending', 'incomplete', ?) + `).run(seat, codexHome, thread, upTo, nonce, new Date(now()).toISOString()); + rowId = Number(result.lastInsertRowid); + }); + return rowId; +} + +function storagePaths(installRoot, env) { + return { + storageDir: env.AGMSG_STORAGE_PATH || join(installRoot, "db"), + configPath: env.AGMSG_CONFIG || join(homedir(), ".agents", "agmsg", "config.json"), + }; +} + +function bridgeState(runDir, team, agent, projects) { + let files; + try { + files = readdirSync(runDir).filter((name) => name.startsWith("codex-bridge.") && name.endsWith(".pid")); + } catch (error) { + if (error?.code === "ENOENT") return "stopped"; + return "unknown"; + } + const identity = `${team}/${agent}`; + for (const pidName of files) { + const pidPath = join(runDir, pidName); + const metaPath = `${pidPath.slice(0, -4)}.meta`; + let pidText; + let metaText; + try { + const pidStat = lstatSync(pidPath); + if (!pidStat.isFile() || pidStat.isSymbolicLink()) return "unknown"; + const metaStat = lstatSync(metaPath); + if (!metaStat.isFile() || metaStat.isSymbolicLink()) return "unknown"; + pidText = readFileSync(pidPath, "utf8").trim(); + metaText = readFileSync(metaPath, "utf8"); + } catch { + return "unknown"; + } + const fields = Object.create(null); + for (const line of metaText.split(/\r?\n/)) { + const at = line.indexOf("="); + if (at > 0 && !(line.slice(0, at) in fields)) fields[line.slice(0, at)] = line.slice(at + 1); + } + if (!fields.identities) return "unknown"; + if (!fields.identities.split(",").includes(identity) || fields.type !== "codex") continue; + if (!/^\d+$/.test(pidText) || fields.pid !== pidText) return "unknown"; + if (!projects.some((project) => sameProject(project, fields.project))) return "unknown"; + // A live PID alone can be a recycled process. Require the bridge's own + // per-PID lease, matching its recorded pair/project set and start token. + const leasePath = join(runDir, `codex-bridge-lease.${pidText}`); + let leaseText; + try { + const leaseStat = lstatSync(leasePath); + if (!leaseStat.isFile() || leaseStat.isSymbolicLink()) return "unknown"; + leaseText = readFileSync(leasePath, "utf8"); + } catch { + return "unknown"; + } + const lease = Object.create(null); + for (const line of leaseText.split(/\r?\n/)) { + const at = line.indexOf("="); + if (at > 0 && !(line.slice(0, at) in lease)) lease[line.slice(0, at)] = line.slice(at + 1); + else if (at > 0) return "unknown"; + } + const leaseKeys = Object.keys(lease).sort().join(","); + if (leaseKeys !== "host,pairs,pid,project,start,startsrc,v") return "unknown"; + const pairHashes = fields.identities.split(",").map((pair) => createHash("sha1").update(pair.replace("/", "\t")).digest("hex")).sort(); + const expectedPairs = createHash("sha1").update(pairHashes.join("\n")).digest("hex"); + const expectedProject = createHash("sha1").update(fields.project).digest("hex"); + if (lease.v !== "1" || lease.pid !== pidText || lease.project !== expectedProject || lease.pairs !== expectedPairs || lease.host !== hostname() || !lease.start) return "unknown"; + const startPrefix = lease.startsrc === "proc" ? "linux" : lease.startsrc === "ps" ? "darwin" : lease.startsrc === "pwsh" ? "windows" : ""; + if (!startPrefix || processStartWitness(Number(pidText)) !== `${startPrefix}:${lease.start}`) return "unknown"; + try { + process.kill(Number(pidText), 0); + return "running"; + } catch (error) { + if (error?.code === "ESRCH") continue; + if (error?.code === "EPERM") return "running"; + return "unknown"; + } + } + return "stopped"; +} + +// This is intentionally fail-closed. A pending reservation left by a daemon +// crash before the child result was recorded cannot safely be retried here. +async function pendingObservation(pending, now, observeGroup) { + let children; + try { children = JSON.parse(pending.children); } catch { return { state: "pending", reason: "child_group_record_unreadable" }; } + if (children.state === "incomplete") return { state: "pending", reason: "queue_child_state_incomplete", childrenUnresolved: true }; + if (children.state === "complete") { + let group; + try { group = observeGroup(children); } catch { group = { state: "unreadable", reason: "child_group_observation_failed" }; } + if (group?.state !== "absent") return { state: "pending", reason: group?.reason ?? "queue_child_still_running", childrenUnresolved: true }; + } else if (children.state !== "absent") { + return { state: "pending", reason: children.reason ?? "child_group_record_unreadable", childrenUnresolved: true }; + } + const createdAt = Date.parse(pending.created_at); + const ageMs = Number.isFinite(createdAt) ? now() - createdAt : NaN; + const queueObservation = pending.queue_item_id + ? readQueuedItem(pending.codex_home, pending.queue_item_id, pending.thread) + : { state: "absent" }; + return readRolloutNonce(pending.codex_home, pending.thread, pending.nonce).then((rolloutObservation) => + resolvePendingQueue({ queueObservation, rolloutObservation, ageMs })); +} + +export function createCodexQueueChannel({ + db, + installRoot, + expectedOpGen, + env = process.env, + executable = "codex", + timeoutMs = QUEUE_TIMEOUT_MS, + now = Date.now, + log = (message) => logLine(installRoot, message), + listSeats = listCodexRegistrations, + readSession = readRoleSession, + readDriver = readStorageDriver, + openStore = openMessageStore, + readSnapshot = readUnreadSnapshot, + queue = runCodexQueue, + captureGroup = captureProcessGroup, + observeGroup = observeProcessGroup, + hostPlatform = process.platform, +}) { + let stopped = false; + let activeController = null; + let activePoll = null; + + async function pollSeat(registration, paths, blockedCodexHomes) { + const { team, agent, teamConfig, registered = true } = registration; + const seat = seatKey(team, agent); + const registeredProjects = registrationProjects(teamConfig, agent); + const bridge = bridgeState(join(installRoot, "run"), team, agent, registeredProjects); + const pending = readPending(db, seat); + if (pending) { + const observation = await pendingObservation(pending, now, observeGroup); + if (observation.childrenUnresolved && pending.codex_home) blockedCodexHomes.add(pending.codex_home); + if (!registered) { + if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); + else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); + saveSeat(db, { seat, thread: pending.thread, codexHome: pending.codex_home, state: "unaddressable", reason: "seat_registration_missing" }, now); + return; + } + if (hostPlatform === "win32") { + if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); + else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); + const reason = observation.state === "pending" + ? `windows_live_delivery_unverified;pending:${observation.reason}` + : "windows_live_delivery_unverified"; + saveSeat(db, { seat, thread: pending.thread, codexHome: pending.codex_home, state: "blocked", reason }, now); + return; + } + if (observation.state === "confirmed") { + setQueueState(db, pending.id, "confirmed"); + const knownBridge = bridge === "stopped" || bridge === "running"; + saveSeat(db, { seat, thread: pending.thread, codexHome: pending.codex_home, state: !knownBridge ? "blocked" : bridge === "running" ? "bridged" : "addressable", reason: !knownBridge ? "bridge_state_unknown" : bridge === "running" ? "bridge_running" : "" }, now); + return; + } else if (observation.state === "expired") { + setQueueState(db, pending.id, "expired"); + } else { + const knownBridge = bridge === "stopped" || bridge === "running"; + saveSeat(db, { + seat, + thread: pending.thread, + codexHome: pending.codex_home, + state: !knownBridge ? "blocked" : bridge === "running" ? "bridged" : "addressable", + reason: !knownBridge ? `bridge_state_unknown;pending:${observation.reason}` : bridge === "running" ? `bridge_running;pending:${observation.reason}` : observation.reason, + }, now); + return; + } + } + if (!registered) return; + if (bridge !== "stopped" && bridge !== "running") { + saveSeat(db, { seat, state: "blocked", reason: "bridge_state_unknown" }, now); + return; + } + if (bridge === "running") { + saveSeat(db, { seat, state: "bridged", reason: "bridge_running" }, now); + return; + } + const session = readSession(join(installRoot, "run"), team, agent); + if (session.state !== "present") { + saveSeat(db, { seat, state: "unaddressable", reason: session.reason ?? "role_session_missing" }, now); + return; + } + const record = session.record; + if (record.team !== team || record.agent !== agent) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "role_session_identity_mismatch" }, now); + return; + } + if (record.type !== "codex") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "role_session_type_mismatch" }, now); + return; + } + if (hostPlatform === "win32") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "windows_live_delivery_unverified" }, now); + return; + } + let driver; + try { + driver = readDriver(paths.configPath); + } catch (error) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: `storage_config_unreadable:${error.message}` }, now); + return; + } + if (driver.state !== "ok") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: driver.reason }, now); + return; + } + if (driver.driver !== "sqlite") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "storage_driver_unsupported" }, now); + return; + } + const projects = registeredProjects; + if (projects.length === 0 || !projects.some((project) => sameProject(project, record.project))) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "role_session_project_mismatch" }, now); + return; + } + let storePath; + try { + storePath = messageStorePath({ storageDir: paths.storageDir, team, teamConfig }); + } catch (error) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: `message_store_path_unreadable:${error.message}` }, now); + return; + } + let store; + try { + store = openStore(storePath); + } catch (error) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: `message_store_unreadable:${error.message}` }, now); + return; + } + + let snapshot; + try { + snapshot = readSnapshot(store, team, agent, latestConfirmedCursor(db, seat)); + } catch (error) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: `message_store_unreadable:${error.message}` }, now); + return; + } finally { + try { store.close(); } catch { /* keep the read result */ } + } + + const nonce = newNonce(); + const rollout = await readRolloutNonce(record.codex_home, record.thread, nonce); + const classification = classifyCodexSeat({ roleSession: record, bridgeState: "stopped", rolloutState: rollout.state === "absent" || rollout.state === "present" ? "valid" : "invalid" }); + if (classification.state !== "addressable") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: classification.state, reason: classification.reason }, now); + return; + } + if (blockedCodexHomes.has(record.codex_home)) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "addressable", reason: "shared_codex_home_queue_pending" }, now); + return; + } + if (!snapshot.unread || stopped) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "addressable" }, now); + return; + } + + let id; + try { + id = createPending(db, { seat, codexHome: record.codex_home, thread: record.thread, upTo: snapshot.upTo, nonce, expectedOpGen }, now); + } catch (error) { + if (error.message !== "daemon_intent_changed" && error.message !== "seat_already_pending") throw error; + return; + } + + activeController = new AbortController(); + let result; + try { + result = await queue({ + executable, + codexHome: record.codex_home, + thread: record.thread, + message: inboxNudge(nonce), + timeoutMs, + signal: activeController.signal, + onChildStart: (pid) => recordChildStart(db, id, pid, captureGroup), + }); + } finally { + activeController = null; + } + recordChildResult(db, id, result); + if (result.kind === "archived" || result.kind === "no_rollout") { + setQueueState(db, id, "expired"); + const reason = result.kind === "archived" ? "thread_archived" : "codex_home_mismatch"; + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason }, now); + return; + } + if (result.kind === "queued") { + setQueueState(db, id, "pending", result.queueItemId); + const firstCheck = await pendingObservation(readPending(db, seat), now, observeGroup); + if (firstCheck.state === "confirmed") { + setQueueState(db, id, "confirmed"); + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "addressable" }, now); + return; + } + if (firstCheck.childrenUnresolved && record.codex_home) blockedCodexHomes.add(record.codex_home); + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "addressable", reason: firstCheck.reason }, now); + return; + } + saveSeat(db, { + seat, + thread: record.thread, + codexHome: record.codex_home, + state: "addressable", + reason: `queue_${result.kind}:${result.reason ?? "unknown"}`, + }, now); + } + + async function pollOnce() { + if (stopped) return; + if (activePoll) return activePoll; + activePoll = (async () => { + const roster = listSeats(join(installRoot, "teams")); + if (roster.state !== "ok") { + log(`channel: Codex roster unavailable (${roster.reason})`); + return; + } + const paths = storagePaths(installRoot, env); + const blockedCodexHomes = new Set(); + const seats = [...roster.seats]; + const registeredKeys = new Set(seats.map(({ team, agent }) => seatKey(team, agent))); + const unsettledSeats = pendingSeats(db); + const pendingKeys = new Set(unsettledSeats.map(({ team, agent }) => seatKey(team, agent))); + for (const { team, agent } of unsettledSeats) { + const key = seatKey(team, agent); + if (!registeredKeys.has(key)) seats.push({ team, agent, teamConfig: null, registered: false }); + } + const knownKeys = new Set([...registeredKeys, ...pendingKeys]); + for (const row of db.prepare("SELECT seat FROM beta_codex_seat").all()) { + if (!knownKeys.has(row.seat)) { + saveSeat(db, { seat: row.seat, state: "unaddressable", reason: "seat_registration_missing" }, now); + } + } + // Sequential processing also serializes codex queue writes that share a + // CODEX_HOME, avoiding Codex's measured concurrent-write loss. + for (const seat of seats) { + if (stopped) break; + try { + await pollSeat(seat, paths, blockedCodexHomes); + } catch (error) { + log(`channel: Codex seat ${seat.team}/${seat.agent} could not be checked (${error.message})`); + } + } + })().finally(() => { activePoll = null; }); + return activePoll; + } + + return { + pollOnce, + async stop() { + stopped = true; + activeController?.abort(); + await activePoll; + }, + }; +} diff --git a/scripts/daemon/channels/process-group.mjs b/scripts/daemon/channels/process-group.mjs new file mode 100644 index 000000000..4ad9d2c6f --- /dev/null +++ b/scripts/daemon/channels/process-group.mjs @@ -0,0 +1,88 @@ +// Conservative evidence for a short-lived command launched in its own group. +// If the platform cannot prove the group is empty or still belongs to the +// recorded leader, callers must treat it as unknown and keep the seat pending. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { platform } from "node:os"; +import { bootId } from "../executor.mjs"; + +export function processStartWitness(pid) { + if (!Number.isSafeInteger(pid) || pid < 2) return null; + const os = platform(); + if (os === "linux") { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const rest = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/); + const start = rest[19]; + return /^\d+$/.test(start ?? "") ? `linux:${start}` : null; + } catch { + return null; + } + } + if (os === "darwin") { + try { + const start = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8" }).trim(); + return start ? `darwin:${start}` : null; + } catch { + return null; + } + } + if (os === "win32") { + for (const executable of ["powershell.exe", "pwsh.exe"]) { + try { + const start = execFileSync(executable, [ + "-NoProfile", "-NonInteractive", "-Command", + `(Get-Process -Id ${pid}).StartTime.Ticks`, + ], { encoding: "utf8", windowsHide: true }).trim(); + if (/^\d+$/.test(start)) return `windows:${start}`; + } catch { + // Try the other supported PowerShell executable. + } + } + } + return null; +} + +export function captureProcessGroup(pgid) { + const witness = processStartWitness(pgid); + if (!witness) return { state: "unreadable", reason: "child_start_witness_unavailable" }; + return { + state: "complete", + kind: platform() === "win32" ? "windows-native-process" : "posix-process-group", + pgid, + boot_id: bootId(), + witness, + }; +} + +export function observeProcessGroup(record) { + if (!record || record.state !== "complete" || !Number.isSafeInteger(record.pgid) || record.pgid < 2 || !record.boot_id || !record.witness) { + return { state: "unreadable", reason: "child_group_record_unreadable" }; + } + if (platform() === "win32") { + if (record.kind !== "windows-native-process") return { state: "unreadable", reason: "child_group_kind_mismatch" }; + try { + process.kill(record.pgid, 0); + } catch (error) { + if (error?.code === "ESRCH") return { state: "absent" }; + if (error?.code !== "EPERM") return { state: "unreadable", reason: "child_process_observation_failed" }; + } + const currentWitness = processStartWitness(record.pgid); + if (!currentWitness) return { state: "unreadable", reason: "child_process_witness_unavailable" }; + if (currentWitness !== record.witness) return { state: "unreadable", reason: "child_process_pid_reused" }; + return { state: "present" }; + } + if (record.kind !== "posix-process-group") return { state: "unreadable", reason: "child_group_kind_mismatch" }; + if (bootId() !== record.boot_id) return { state: "absent" }; + try { + process.kill(-record.pgid, 0); + } catch (error) { + if (error?.code === "ESRCH") return { state: "absent" }; + if (error?.code !== "EPERM") return { state: "unreadable", reason: "child_group_observation_failed" }; + } + const currentWitness = processStartWitness(record.pgid); + if (!currentWitness) return { state: "unreadable", reason: "child_group_leader_unreadable" }; + if (currentWitness !== record.witness) return { state: "unreadable", reason: "child_group_leader_reused" }; + return { state: "present" }; +} diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs index 28762f5a7..4f1d4b76c 100644 --- a/scripts/daemon/main.mjs +++ b/scripts/daemon/main.mjs @@ -8,20 +8,35 @@ // without wiring real signal handlers or a real interval timer, which // main() (the actual CLI entrypoint) only assembles. // -// channelHooks (an array of {stop()}) is beta's plug point for the Codex -// queue channel -- a separate PR/component this file does not own. Empty -// in this PR; gracefulStop() already calls stop() on every registered -// hook, in the correct place in the shutdown order, so that PR only needs -// to register its hook, not restructure this file. +// channelHooks are polled from the daemon's single event loop and stopped +// before the control socket closes. import { takeOwnership, markReady, markStopping, revertToNone, stopNormally } from "./owner.mjs"; import { createControlServer } from "./control.mjs"; import { captureWatchState, watchForDrift } from "./lifecycle.mjs"; import { logLine } from "./log.mjs"; import { classify } from "./status.mjs"; +import { createCodexQueueChannel } from "./channels/codex-queue.mjs"; +import { ensureCodexChannelSchema } from "./channels/codex-queue-store.mjs"; export const POLL_INTERVAL_MS = 5000; +export async function prepareClaimWithCodexSchema(prepareClaim) { + const prepared = await prepareClaim(); + ensureCodexChannelSchema(prepared.db); + return prepared; +} + +export async function pollChannelHooks(channelHooks, log = () => {}) { + for (const hook of channelHooks) { + try { + await hook.pollOnce(); + } catch (error) { + log(`channel: poll failed: ${error.message}`); + } + } +} + // Takes ownership and binds the control socket. Returns // {ok: true, gen, controlHandle, db} or {ok: false, reason, db} -- a // refusal from takeOwnership OR a bind failure, both reported the same @@ -104,7 +119,7 @@ export async function gracefulStop(db, installRoot, gen, controlHandle, channelH // start or a normal/SIGTERM stop, 75 for stepping aside for an update, 1 // for a bind failure or any other unexpected error. export async function main(db, { installRoot, manifest, manifestText, expectedDesired, expectedOpGen, version, prepareClaim }) { - const channelHooks = []; // beta's Codex-queue channel plugs in here, separately. + const channelHooks = []; let controlHandle; let gen; let stopping = false; @@ -123,7 +138,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe expectedOpGen, version, prepareClaim: prepareClaim ? async () => { - const prepared = await prepareClaim(); + const prepared = await prepareClaimWithCodexSchema(prepareClaim); db = prepared.db; return prepared; } : undefined, @@ -151,13 +166,24 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe } gen = started.gen; controlHandle = started.controlHandle; + channelHooks.push(createCodexQueueChannel({ db, installRoot, expectedOpGen })); process.on("SIGTERM", () => doStop("normal")); const watchState = await captureWatchState(installRoot, manifest, manifestText); + let polling = false; const timer = setInterval(async () => { - if (stopping) return; - const verdict = await pollOnce(db, installRoot, { gen, expectedOpGen, watchState }); - if (verdict.action !== "continue") await doStop(verdict.reason); + if (stopping || polling) return; + polling = true; + try { + const verdict = await pollOnce(db, installRoot, { gen, expectedOpGen, watchState }); + if (verdict.action !== "continue") { + await doStop(verdict.reason); + return; + } + await pollChannelHooks(channelHooks, (message) => logLine(installRoot, message)); + } finally { + polling = false; + } }, POLL_INTERVAL_MS); } diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs index a11079ffa..775ebf3c5 100644 --- a/scripts/daemon/status.mjs +++ b/scripts/daemon/status.mjs @@ -100,11 +100,21 @@ export function readOwnerAndIntentReadOnly(installRoot) { try { const owner = db.prepare("SELECT * FROM daemon_owner").get(); const intent = db.prepare("SELECT * FROM daemon_intent").get(); + let codexSeats; + try { + codexSeats = { + state: "ok", + seats: db.prepare("SELECT seat, thread, codex_home, state, reason, checked_at FROM beta_codex_seat ORDER BY seat").all(), + }; + } catch (error) { + if (!String(error?.message ?? "").includes("no such table")) throw error; + codexSeats = { state: "unavailable", reason: "channel_not_initialized", seats: [] }; + } const alive = owner.state === "none" || owner.executor_pid == null ? null : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); - return { owner, intent, alive }; + return { owner, intent, alive, codexSeats }; } finally { db.close(); } @@ -145,9 +155,9 @@ async function main() { process.stderr.write("usage: status.mjs \n"); process.exit(2); } - let owner, intent, alive; + let owner, intent, alive, codexSeats; try { - ({ owner, intent, alive } = readOwnerAndIntentReadOnly(installRoot)); + ({ owner, intent, alive, codexSeats } = readOwnerAndIntentReadOnly(installRoot)); } catch (error) { // An input that can be detected as an error is reported at // that entry point, with a nonzero exit -- not a raw stack trace, and @@ -172,7 +182,7 @@ async function main() { // unbuffered) looked completely fine. Setting exitCode and letting the // event loop drain naturally waits for the flush first. process.stdout.write( - `${JSON.stringify({ ...result, node_sqlite_experimental: true, node_version: process.version })}\n`, + `${JSON.stringify({ ...result, codex_seats: codexSeats, node_sqlite_experimental: true, node_version: process.version })}\n`, ); process.exitCode = result.exitCode; } diff --git a/tests/agmsgd_codex_queue.test.mjs b/tests/agmsgd_codex_queue.test.mjs new file mode 100644 index 000000000..99ceafbec --- /dev/null +++ b/tests/agmsgd_codex_queue.test.mjs @@ -0,0 +1,422 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; + +import { + classifyCodexSeat, + inboxNudge, + newNonce, + readQueuedItem, + readRolloutNonce, + resolvePendingQueue, + runCodexQueue, + verifyPendingDelivery, +} from '../scripts/daemon/channels/codex-queue-io.mjs'; +import { + CODEX_CHANNEL_SCHEMA, + ensureCodexChannelSchema, + listCodexRegistrations, + messageStorePath, + readRoleSession, + readStorageDriver, + readUnreadSnapshot, +} from '../scripts/daemon/channels/codex-queue-store.mjs'; +import { createCodexQueueChannel } from '../scripts/daemon/channels/codex-queue.mjs'; +import { openInstallDb } from '../scripts/daemon/db.mjs'; +import { readOwnerAndIntentReadOnly } from '../scripts/daemon/status.mjs'; +import { processStartWitness } from '../scripts/daemon/channels/process-group.mjs'; + +const thread = '01a0ea97-c011-73f3-8470-fd59db15adba'; +const itemId = '01a0ea98-2235-7f02-b477-7c130e602fcd'; +const notYetVisibleItemId = '01a0ea99-3509-7d03-a588-8d241f7130de'; + +function temporaryDirectory(t) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'agmsgd-codex-queue-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function fakeChild({ stdout, stderr = '', code = 0, autoClose = true }) { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.pid = 4242; + child.kill = () => true; + if (autoClose) { + setImmediate(() => { + child.stdout.end(stdout); + child.stderr.end(stderr); + child.emit('close', code, null); + }); + } + return child; +} + +test('queue invocation is shell-free, pins CODEX_HOME, and parses only a successful matching receipt', async () => { + const calls = []; + const nonce = newNonce(); + const message = inboxNudge(nonce); + assert.match(message, new RegExp(`^\\[agmsg:${nonce}\\]`)); + + const result = await runCodexQueue({ + executable: '/opt/codex', + codexHome: '/tmp/profile with spaces', + thread, + message, + timeoutMs: 5000, + spawnProcess: (file, args, options) => { + calls.push({ file, args, options }); + return fakeChild({ stdout: `Queued message ${itemId} for thread ${thread}.\n` }); + }, + }); + + assert.deepEqual(result, { kind: 'queued', queueItemId: itemId }); + assert.equal(calls[0].file, '/opt/codex'); + assert.deepEqual(calls[0].args, ['queue', '--thread', thread, '--message', message]); + assert.equal(calls[0].options.env.CODEX_HOME, '/tmp/profile with spaces'); + assert.equal(calls[0].options.detached, process.platform !== 'win32'); + + const archived = await runCodexQueue({ + executable: '/opt/codex', codexHome: '/tmp/profile', thread, message, timeoutMs: 5000, + spawnProcess: () => fakeChild({ stdout: 'failed (code -32600)', code: 1 }), + }); + assert.deepEqual(archived, { kind: 'archived', reason: 'thread_archived' }); + + let timeoutSignal; + const timedOut = await runCodexQueue({ + executable: '/opt/codex', codexHome: '/tmp/profile', thread, message, timeoutMs: 10, + spawnProcess: () => { + const child = fakeChild({ stdout: '', autoClose: false }); + child.pid = 1; + child.kill = (signal) => { + timeoutSignal = signal; + setImmediate(() => child.emit('close', null, signal)); + return true; + }; + return child; + }, + }); + assert.equal(timedOut.kind, 'timeout'); + assert.equal(timeoutSignal, 'SIGTERM'); +}); + +test('queue DB and rollout observations distinguish positive, absent, and unreadable evidence', async (t) => { + const home = temporaryDirectory(t); + const db = new DatabaseSync(path.join(home, 'queue_1.sqlite')); + db.exec('CREATE TABLE queued_items (id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, payload_json TEXT NOT NULL)'); + db.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run(itemId, thread, '{}'); + db.close(); + + assert.deepEqual(readQueuedItem(home, itemId, thread), { state: 'present' }); + assert.deepEqual(readQueuedItem(home, '01a0ea98-2235-7f02-b477-7c130e602fce', thread), { state: 'absent' }); + assert.equal(readQueuedItem(path.join(home, 'missing'), itemId, thread).state, 'unreadable'); + assert.equal(readQueuedItem(home, itemId, '01a0ea97-c011-73f3-8470-fd59db15adbb').state, 'unreadable'); + + const rolloutDir = path.join(home, 'sessions', '2026', '09', '29'); + mkdirSync(rolloutDir, { recursive: true }); + const rollout = path.join(rolloutDir, `rollout-2026-09-29T00-00-00-${thread}.jsonl`); + const nonce = 'pending-nonce-123'; + writeFileSync(rollout, [ + JSON.stringify({ type: 'session_meta', payload: { id: thread } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: inboxNudge(nonce) } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: inboxNudge('second-nonce') }] } }), + '', + ].join('\n')); + assert.deepEqual(await readRolloutNonce(home, thread, nonce), { state: 'present' }); + assert.deepEqual(await readRolloutNonce(home, thread, 'second-nonce'), { state: 'present' }); + assert.deepEqual(await readRolloutNonce(home, thread, 'not-yet-seen'), { state: 'absent' }); + assert.deepEqual(await readRolloutNonce(path.join(home, 'missing-profile'), thread, nonce), { state: 'unreadable', reason: 'sessions_dir_missing' }); + + writeFileSync(rollout, [ + JSON.stringify({ type: 'session_meta', payload: { id: thread } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'future_text_shape', value: inboxNudge(nonce) }] } }), + '', + ].join('\n')); + assert.deepEqual(await readRolloutNonce(home, thread, nonce), { state: 'unreadable', reason: 'rollout_user_row_unrecognized' }); + + writeFileSync(rollout, '{malformed json}\n'); + assert.equal((await readRolloutNonce(home, thread, nonce)).state, 'unreadable'); +}); + +test('pending delivery confirms on either positive witness, expires only on two readable absences, and otherwise stays pending', () => { + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'present' }, + rolloutObservation: { state: 'unreadable', reason: 'rollout_read_failed' }, + ageMs: 100, + }), { state: 'confirmed', reason: 'queue_item_present' }); + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'unreadable', reason: 'queue_db_read_failed' }, + rolloutObservation: { state: 'present' }, + ageMs: 100, + }), { state: 'confirmed', reason: 'nonce_observed' }); + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'absent' }, rolloutObservation: { state: 'absent' }, ageMs: 59_999, + }), { state: 'pending', reason: 'awaiting_confirmation' }); + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'absent' }, rolloutObservation: { state: 'absent' }, ageMs: 60_000, + }), { state: 'expired', reason: 'confirmation_timeout' }); + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'absent' }, + rolloutObservation: { state: 'unreadable', reason: 'thread_rollout_missing' }, + ageMs: 120_000, + }), { state: 'pending', reason: 'verification_unreadable' }); + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'absent' }, rolloutObservation: { state: 'absent' }, ageMs: -1, + }), { state: 'pending', reason: 'pending_age_unreadable' }); +}); + +test('immediate post-receipt check confirms a still-queued item before rollout inspection', async () => { + const events = ['queue_item_id_persisted']; + const result = await verifyPendingDelivery({ + codexHome: '/tmp/profile', queueItemId: itemId, thread, nonce: 'pending-nonce', ageMs: 5, + readQueue: () => { + events.push('queue_read'); + return { state: 'present' }; + }, + readRollout: async () => { + events.push('rollout_read'); + return { state: 'unreadable', reason: 'rollout_not_ready' }; + }, + }); + assert.deepEqual(result, { state: 'confirmed', reason: 'queue_item_present' }); + assert.deepEqual(events, ['queue_item_id_persisted', 'queue_read']); + + const consumedEarly = await verifyPendingDelivery({ + codexHome: '/tmp/profile', queueItemId: itemId, thread, nonce: 'pending-nonce', ageMs: 60_000, + readQueue: () => ({ state: 'absent' }), + readRollout: async () => ({ state: 'unreadable', reason: 'rollout_user_row_unrecognized' }), + }); + assert.deepEqual(consumedEarly, { state: 'pending', reason: 'verification_unreadable' }); +}); + +test('seat classification never treats an uncertain destination or bridge as addressable', () => { + const record = { thread, codex_home: '/tmp/profile' }; + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped', rolloutState: 'valid' }), { state: 'addressable', reason: '' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'running' }), { state: 'bridged', reason: 'bridge_running' }); + assert.deepEqual(classifyCodexSeat({ roleSession: null }), { state: 'unaddressable', reason: 'role_session_missing' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped', rolloutState: 'archived' }), { state: 'blocked', reason: 'thread_archived' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped', rolloutState: 'no_rollout' }), { state: 'blocked', reason: 'codex_home_mismatch' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'unknown' }), { state: 'blocked', reason: 'bridge_state_unknown' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record }), { state: 'blocked', reason: 'bridge_state_unknown' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped' }), { state: 'blocked', reason: 'thread_observation_failed' }); +}); + +test('store discovery is fail-closed and unread snapshots advance both event and legacy cursors', async (t) => { + const root = temporaryDirectory(t); + const teamsDir = path.join(root, 'teams'); + const runDir = path.join(root, 'run'); + const storageDir = path.join(root, 'store'); + mkdirSync(path.join(teamsDir, 'alpha'), { recursive: true }); + mkdirSync(runDir, { recursive: true }); + mkdirSync(storageDir, { recursive: true }); + writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ + agents: { + alice: { registrations: [{ type: 'codex', project: root }] }, + bob: { registrations: [{ type: 'claude-code', project: root }] }, + }, + })); + writeFileSync(path.join(runDir, 'role-session.alpha__alice'), `team=alpha\nagent=alice\ntype=codex\nsession=${thread}\ncodex_home=/tmp/codex-profile\n`); + writeFileSync(path.join(root, 'config.json'), JSON.stringify({ storage: 'jsonl' })); + + assert.deepEqual(listCodexRegistrations(teamsDir).seats.map(({ team, agent }) => `${team}:${agent}`), ['alpha:alice']); + assert.deepEqual(readRoleSession(runDir, 'alpha', 'alice').record, { + team: 'alpha', agent: 'alice', type: 'codex', project: '', thread, codex_home: '/tmp/codex-profile', + }); + assert.deepEqual(readStorageDriver(path.join(root, 'config.json')), { state: 'ok', driver: 'jsonl' }); + assert.equal(messageStorePath({ storageDir, team: 'alpha', teamConfig: {} }), path.join(storageDir, 'messages.db')); + assert.equal(messageStorePath({ storageDir, team: 'alpha', teamConfig: { drivers: { partition: 'per-team' } } }), path.join(storageDir, 'teams', 'alpha', 'messages.db')); + + const installDb = new DatabaseSync(path.join(root, 'install.db')); + ensureCodexChannelSchema(installDb); + assert.equal(installDb.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name LIKE 'beta_codex_%'").get().n, 2); + assert.match(CODEX_CHANNEL_SCHEMA, /beta_codex_queue_one_pending/); + installDb.close(); + + const msgDb = new DatabaseSync(path.join(storageDir, 'messages.db')); + msgDb.exec(` + CREATE TABLE events (seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, id TEXT, team TEXT, from_agent TEXT, to_agent TEXT, body TEXT, msg_id TEXT, agent TEXT, at TEXT, legacy_id INTEGER); + CREATE TABLE messages (id INTEGER PRIMARY KEY, team TEXT, to_agent TEXT, read_at TEXT, created_at TEXT, body TEXT); + CREATE TABLE read_cursors (team TEXT, agent TEXT, local_position INTEGER, PRIMARY KEY(team, agent)); + `); + msgDb.prepare('INSERT INTO events VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run('message_sent', 'event-1', 'alpha', 'sender', 'alice', 'secret body', null, null, '2026-09-29T00:00:00Z', null); + msgDb.prepare('INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)').run(1, 'alpha', 'alice', null, '2026-09-29T00:00:00Z', 'legacy body'); + msgDb.close(); + + const readDb = new DatabaseSync(path.join(storageDir, 'messages.db'), { readOnly: true }); + const first = readUnreadSnapshot(readDb, 'alpha', 'alice'); + assert.equal(first.state, 'ok'); + assert.equal(first.unread, true); + assert.equal(first.upTo, JSON.stringify({ eventSeq: 1, legacyId: 1 })); + assert.equal(readUnreadSnapshot(readDb, 'alpha', 'alice', first.upTo).unread, false); + readDb.close(); +}); + +test('Codex channel queues one unread snapshot and confirms it before advancing that seat', async (t) => { + const root = temporaryDirectory(t); + const teamsDir = path.join(root, 'teams'); + const runDir = path.join(root, 'run'); + const storageDir = path.join(root, 'store'); + const codexHome = path.join(root, 'codex'); + const sessionDir = path.join(codexHome, 'sessions', '2026', '09', '29'); + mkdirSync(path.join(teamsDir, 'alpha'), { recursive: true }); + mkdirSync(runDir, { recursive: true }); + mkdirSync(storageDir, { recursive: true }); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ + agents: { alice: { registrations: [{ type: 'codex', project: root }] } }, + })); + writeFileSync(path.join(runDir, 'role-session.alpha__alice'), `team=alpha\nagent=alice\ntype=codex\nproject=${root}\nsession=${thread}\ncodex_home=${codexHome}\n`); + writeFileSync(path.join(sessionDir, `rollout-2026-09-29T00-00-00-${thread}.jsonl`), [ + JSON.stringify({ type: 'session_meta', payload: { id: thread } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'an earlier prompt' } }), + '', + ].join('\n')); + + const messageDb = new DatabaseSync(path.join(storageDir, 'messages.db')); + messageDb.exec(` + CREATE TABLE events (seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, id TEXT, team TEXT, from_agent TEXT, to_agent TEXT, body TEXT, msg_id TEXT, agent TEXT, at TEXT, legacy_id INTEGER); + CREATE TABLE messages (id INTEGER PRIMARY KEY, team TEXT, to_agent TEXT, read_at TEXT, created_at TEXT, body TEXT); + CREATE TABLE read_cursors (team TEXT, agent TEXT, local_position INTEGER, PRIMARY KEY(team, agent)); + `); + messageDb.prepare('INSERT INTO events VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run('message_sent', 'event-1', 'alpha', 'sender', 'alice', 'do not include this in the nudge', null, null, '2026-09-29T00:00:00Z', null); + messageDb.close(); + + const queueDb = new DatabaseSync(path.join(codexHome, 'queue_1.sqlite')); + queueDb.exec('CREATE TABLE queued_items (id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, payload_json TEXT NOT NULL)'); + queueDb.close(); + + const installDb = openInstallDb(path.join(runDir, 'install.db')); + ensureCodexChannelSchema(installDb); + installDb.exec("UPDATE daemon_intent SET desired='on', op_gen=4"); + const queued = []; + let childGroupState = 'present'; + const channel = createCodexQueueChannel({ + db: installDb, + installRoot: root, + expectedOpGen: 4, + env: { AGMSG_STORAGE_PATH: storageDir }, + readDriver: () => ({ state: 'ok', driver: 'sqlite' }), + captureGroup: (pid) => ({ state: 'complete', pgid: pid, boot_id: 'test-boot', witness: 'test-start' }), + observeGroup: () => ({ state: childGroupState }), + queue: async (request) => { + queued.push(request); + request.onChildStart(4242); + const db = new DatabaseSync(path.join(codexHome, 'queue_1.sqlite')); + db.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run(itemId, thread, '{}'); + db.close(); + return { kind: 'queued', queueItemId: itemId }; + }, + }); + + await channel.pollOnce(); + assert.equal(queued.length, 1); + assert.equal(queued[0].thread, thread); + assert.match(queued[0].message, /^\[agmsg:[A-Za-z0-9-]+\] New messages are waiting\./); + assert.doesNotMatch(queued[0].message, /do not include this/); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); + await channel.pollOnce(); + assert.equal(queued.length, 1, 'a still-running child group must not be retried on the next poll'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); + childGroupState = 'absent'; + await channel.pollOnce(); + assert.equal(queued.length, 1, 'the queued snapshot is not retried after the child exits'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'addressable'); + const status = readOwnerAndIntentReadOnly(root); + assert.equal(status.codexSeats.state, 'ok'); + assert.equal(status.codexSeats.seats[0].state, 'addressable'); + + await channel.pollOnce(); + assert.equal(queued.length, 1, 'the confirmed cursor prevents another nudge for the same unread snapshot'); + await channel.stop(); + + const windowsChannel = createCodexQueueChannel({ + db: installDb, + installRoot: root, + expectedOpGen: 4, + env: { AGMSG_STORAGE_PATH: storageDir }, + hostPlatform: 'win32', + queue: async () => { throw new Error('Windows queue must stay closed until live delivery is verified'); }, + }); + installDb.prepare(` + INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) + `).run(JSON.stringify(['alpha', 'alice']), codexHome, thread, JSON.stringify({ eventSeq: 1, legacyId: 0 }), 'windows-pending-nonce', notYetVisibleItemId, JSON.stringify({ state: 'absent' }), new Date().toISOString()); + await windowsChannel.pollOnce(); + const windowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); + assert.equal(windowsSeat.state, 'blocked'); + assert.equal(windowsSeat.reason, 'windows_live_delivery_unverified;pending:awaiting_confirmation'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); + const windowsQueueDb = new DatabaseSync(path.join(codexHome, 'queue_1.sqlite')); + windowsQueueDb.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run(notYetVisibleItemId, thread, '{}'); + windowsQueueDb.close(); + await windowsChannel.pollOnce(); + const confirmedWindowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); + assert.equal(confirmedWindowsSeat.state, 'blocked'); + assert.equal(confirmedWindowsSeat.reason, 'windows_live_delivery_unverified'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); + await windowsChannel.stop(); + + writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ agents: {} })); + const missingSeatChannel = createCodexQueueChannel({ db: installDb, installRoot: root, expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir } }); + await missingSeatChannel.pollOnce(); + const missingSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); + assert.equal(missingSeat.state, 'unaddressable'); + assert.equal(missingSeat.reason, 'seat_registration_missing'); + await missingSeatChannel.stop(); + + installDb.prepare(` + INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) + `).run(JSON.stringify(['alpha', 'alice']), codexHome, thread, JSON.stringify({ eventSeq: 1, legacyId: 0 }), 'retired-seat-nonce', itemId, JSON.stringify({ state: 'absent' }), new Date().toISOString()); + const retiredChannel = createCodexQueueChannel({ db: installDb, installRoot: root, expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir } }); + await retiredChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); + const retiredSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); + assert.equal(retiredSeat.state, 'unaddressable'); + assert.equal(retiredSeat.reason, 'seat_registration_missing'); + await retiredChannel.stop(); + + writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ + agents: { alice: { registrations: [{ type: 'codex', project: root }] } }, + })); + const bridgePid = String(process.pid); + const bridgeBase = path.join(runDir, 'codex-bridge.alpha.alice'); + const bridgeWitness = processStartWitness(process.pid); + assert.ok(bridgeWitness, 'the test process has a readable start witness'); + const witnessSeparator = bridgeWitness.indexOf(':'); + const witnessPlatform = bridgeWitness.slice(0, witnessSeparator); + const witnessToken = bridgeWitness.slice(witnessSeparator + 1); + const bridgePairHash = createHash('sha1').update('alpha\talice').digest('hex'); + const bridgePairsHash = createHash('sha1').update(bridgePairHash).digest('hex'); + const bridgeProjectHash = createHash('sha1').update(root).digest('hex'); + const startsrc = { linux: 'proc', darwin: 'ps', windows: 'pwsh' }[witnessPlatform]; + writeFileSync(`${bridgeBase}.pid`, `${bridgePid}\n`); + writeFileSync(`${bridgeBase}.meta`, `pid=${bridgePid}\nproject=${root}\nidentities=alpha/alice\ntype=codex\n`); + writeFileSync(path.join(runDir, `codex-bridge-lease.${bridgePid}`), [ + 'v=1', `project=${bridgeProjectHash}`, `pairs=${bridgePairsHash}`, `host=${os.hostname()}`, + `pid=${bridgePid}`, `start=${witnessToken}`, `startsrc=${startsrc}`, '', + ].join('\n')); + const bridgedChannel = createCodexQueueChannel({ + db: installDb, + installRoot: root, + expectedOpGen: 4, + env: { AGMSG_STORAGE_PATH: storageDir }, + queue: async () => { throw new Error('a verified live bridge retains delivery ownership'); }, + }); + await bridgedChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'bridged'); + await bridgedChannel.stop(); + unlinkSync(`${bridgeBase}.pid`); + unlinkSync(`${bridgeBase}.meta`); + unlinkSync(path.join(runDir, `codex-bridge-lease.${bridgePid}`)); + installDb.close(); +}); diff --git a/tests/agmsgd_main.test.mjs b/tests/agmsgd_main.test.mjs index e0e00627d..48dddd407 100644 --- a/tests/agmsgd_main.test.mjs +++ b/tests/agmsgd_main.test.mjs @@ -5,7 +5,7 @@ import { join } from "node:path"; import test from "node:test"; import { openInstallDb } from "../scripts/daemon/db.mjs"; import { readOwner } from "../scripts/daemon/owner.mjs"; -import { gracefulStop, pollOnce, startup } from "../scripts/daemon/main.mjs"; +import { gracefulStop, pollChannelHooks, pollOnce, prepareClaimWithCodexSchema, startup } from "../scripts/daemon/main.mjs"; import { captureWatchState } from "../scripts/daemon/lifecycle.mjs"; import { createHash } from "node:crypto"; @@ -152,3 +152,29 @@ test("gracefulStop: a channel hook that throws is logged but does not stop the r rmSync(root, { recursive: true, force: true }); } }); + +test("channel setup is inside the prepared install lock and channel polls stay sequential", async () => { + const { root } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + let released = false; + const prepared = await prepareClaimWithCodexSchema(async () => ({ + db, + release: () => { released = true; }, + })); + assert.equal(released, false, "schema creation must finish before the caller releases the install lock"); + assert.doesNotThrow(() => db.prepare("SELECT state FROM beta_codex_seat").all()); + prepared.release(); + assert.equal(released, true); + + const order = []; + await pollChannelHooks([ + { pollOnce: async () => { order.push("first:start"); await Promise.resolve(); order.push("first:end"); } }, + { pollOnce: async () => { order.push("second"); } }, + ]); + assert.deepEqual(order, ["first:start", "first:end", "second"]); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/test_agmsgd_codex_queue.bats b/tests/test_agmsgd_codex_queue.bats new file mode 100644 index 000000000..7520649da --- /dev/null +++ b/tests/test_agmsgd_codex_queue.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd Codex queue support tests" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_codex_queue.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index 879b54f6e..2c4e2b67e 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -5,6 +5,8 @@ load test_helper setup() { setup_test_env DAEMON="$SCRIPTS/daemon.sh" + AGMSGD_TEST_LAUNCH_STDERR_LOG="$TEST_SKILL_DIR/run/agmsgd.stderr.log" + export AGMSGD_TEST_LAUNCH_STDERR_LOG chmod +x "$SCRIPTS/daemon/agmsgd" "$SCRIPTS/daemon/agmsgd-launch.sh" } @@ -140,12 +142,31 @@ _run_with_deadline() { rm -f "$outfile" "$exitfile" } +_assert_start_result() { + local expected="$1" + if [ "$status" -ne 0 ] || [[ "$output" != *"$expected"* ]]; then + printf '\n--- run/agmsgd.log ---\n' >&2 + if [ -f "$TEST_SKILL_DIR/run/agmsgd.log" ]; then + tail -n 80 "$TEST_SKILL_DIR/run/agmsgd.log" >&2 + else + printf '(not present)\n' >&2 + fi + printf '\n--- run/agmsgd.stderr.log ---\n' >&2 + if [ -f "$AGMSGD_TEST_LAUNCH_STDERR_LOG" ]; then + tail -n 80 "$AGMSGD_TEST_LAUNCH_STDERR_LOG" >&2 + else + printf '(not present)\n' >&2 + fi + fi + [ "$status" -eq 0 ] + [[ "$output" == *"$expected"* ]] +} + @test "daemon.sh start: a real run against the real entrypoint becomes ready" { _seed_install_db _write_completion_record _run_with_deadline 15 bash "$DAEMON" start - [ "$status" -eq 0 ] - printf '%s\n' "$output" | grep -Fq 'running' + _assert_start_result 'running' bash "$DAEMON" stop >/dev/null 2>&1 || true } @@ -179,7 +200,7 @@ _run_with_deadline() { local iteration for iteration in 1 2 3; do _run_with_deadline 15 bash "$DAEMON" start - [ "$status" -eq 0 ] + _assert_start_result 'running' _run_with_deadline 10 bash "$DAEMON" status [ "$status" -eq 0 ] From 20b71f8c97bfd14ea92c364aea43691a36174704 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 20:50:07 -0700 Subject: [PATCH 09/97] Add daemon beta switching and recovery guidance (#1523) When the daemon beta is enabled, new Codex launches now bypass the bridge app-server and dispatcher. Existing bridge sessions continue until restarted. Ordinary script operations warn when the enabled daemon is unavailable, with recovery commands and an install-wide ten-minute warning limit; daemon status and doctor also report the failure without requiring Node for the fallback check. Start and enable show existing bridge sessions and missing destination records. Disable distinguishes sessions that already have a bridge from sessions that need restarting, preserves unread messages, and warns about unsupported JSONL storage. README and design notes describe the optional 1.6.0 beta, macOS/Linux support, Windows delivery remaining unsupported pending measurement, and the installed command path to use when agmsg is absent from PATH. Known limitation: concurrent operations spanning a ten-minute slot boundary can emit two warnings less than ten minutes apart; cleanup preserves current and newer claims. Validation: `bats tests/test_agmsgd_switch.bats tests/test_agmsgd_status.bats` passed all 17 checks, including enabled/off/missing/corrupt records, direct wrapper bypass, concurrent warning suppression, a rolling ten-minute interval, past-slot-only cleanup, helper-free non-daemon operations, suppression in the watcher, hooks, one-shot polling, and their child operations, skipping repeated health queries, Node-unavailable status and doctor, executor boot evidence, and disable inventory. Shellcheck at warning severity passed with the existing SC1091 and SC2034 exclusions; `git diff --check` passed. Fixtures retain HOME and use disposable scripts, data, and CODEX_HOME. No live user service or live Codex queue was exercised. The isolated entrypoint fixture exercises a real daemon control socket. CI follow-up: watcher and hook entrypoints export notice suppression before starting child operations, ordinary operations skip loading the daemon helper when install.db is absent, and a recent notice skips the health query. The entrypoint readiness fixture now gives its concurrent SQLite read a bounded five-second busy timeout. macOS CI timing logs showed watch continuing through test 37 at job timeout, rather than hanging: the new test file changed shard assignment, placing the 604-second Jev fixture and the active watch suite together while the weighting table still estimated watch at its historical quarantined two seconds. The table now records Jev at 604 seconds (run 36657411860), watch at 215 seconds (an unchanged 40-test run on local macOS at 96b43b51), and the new daemon switching fixture at 10 seconds (16 checks in 9.74s on local macOS). This targeted weighting update will be replaced by the rebuilt main timing table when main is brought into the integration branch. Follow-up validation: entrypoint 3/3 PASS, CI sharding 20/20 PASS, and unchanged watch 40/40 PASS on macOS. Initial sequential timing was 215.18s at 96b43b51 versus 197.90s at 4c49efe1 (39/40; the fixed-three-second DB-health fixture failed with empty stdout). Its subsequent full run passed 40/40 in 338.71s while the sharding tests ran concurrently, so that rerun is not a like-for-like timing comparison. The job-timeout test 38 also passed alone. The shard balance and coverage checks pass; weighting estimates are not a claim of a completed CI run. Targets `integration/agmsgd-beta`, based on `96b43b51`. --- .github/scripts/bats-file-seconds.tsv | 38 ++- README.md | 19 ++ docs/design/agmsgd-architecture.md | 2 + docs/design/agmsgd-rfc.ja.md | 2 + docs/design/agmsgd-rfc.md | 2 + scripts/check-inbox.sh | 2 + scripts/daemon.sh | 52 +++- scripts/daemon/status.mjs | 25 +- scripts/doctor.sh | 16 ++ scripts/drivers/types/codex/codex-monitor.sh | 15 ++ scripts/drivers/types/codex/codex-shim.sh | 19 ++ scripts/drivers/types/codex/watch-once.sh | 2 + scripts/lib/daemon-seats.sh | 80 ++++++ scripts/lib/daemon-state.sh | 119 +++++++++ scripts/lib/storage.sh | 16 ++ scripts/session-end.sh | 2 + scripts/session-start.sh | 2 + scripts/watch.sh | 2 + tests/agmsgd_status.test.mjs | 11 + tests/test_agmsgd_entrypoint.bats | 3 +- tests/test_agmsgd_switch.bats | 262 +++++++++++++++++++ 21 files changed, 655 insertions(+), 36 deletions(-) create mode 100644 scripts/lib/daemon-seats.sh create mode 100644 scripts/lib/daemon-state.sh create mode 100644 tests/test_agmsgd_switch.bats diff --git a/.github/scripts/bats-file-seconds.tsv b/.github/scripts/bats-file-seconds.tsv index 5dbeebd6a..f055e461c 100644 --- a/.github/scripts/bats-file-seconds.tsv +++ b/.github/scripts/bats-file-seconds.tsv @@ -21,16 +21,10 @@ # is two real perf fixes, not broad drift: test_remote_engine_start_refusal.bats # 660s->43s and test_remote_status_liveness.bats 346s->80s are both #1252 # (shortened the two slowest tests' production-readiness wait). A third -# file, test_watch.bats, measured 317s->2s in this same run -- that IS a -# real 2s today (the file is quarantined as a whole for #1262, setup() -# skips every test unconditionally, see that row's own comment), not a -# stale or misleading number: this table's job is to weight the partition -# by what a file actually costs the CURRENT run, and 2s is what it costs -# while quarantined. (A pre-quarantine 317s was pinned here briefly and -# reverted -- it made the on-paper balance look nicer but actively -# unbalanced the real schedule, handing ~315s of work to a shard that -# would not actually spend it; review finding, see the row's own comment -# for the reasoning #1318 needs when it un-quarantines the file.) Every +# file, test_watch.bats, measured 317s->2s in that run because the whole +# file was quarantined for #1262. Its setup skipped every test, so 2s was +# the actual cost while quarantined. The targeted refresh below restores +# its active cost after the quarantine was removed. Every # other file moved by single-digit seconds either way. So this window's # imbalance was not organic, even drift -- it was two real fixes plus one # quarantine, and neither is the kind of thing that keeps happening on a @@ -49,9 +43,20 @@ # run's shard skew (as happened here) triggers a refresh. Refresh by # re-running this extraction against a green run's bats-timings artifacts. # +# Targeted refresh, 2026-09-29: test_ext_tool_jev_handle.bats completed in +# 604s in macOS job 109704655665, run 36657411860, head 4c49efe1. +# test_watch.bats is no longer quarantined: its unchanged 40-test suite +# completed in 215.18s locally on macOS at integration head 96b43b51. +# test_agmsgd_switch.bats completed its 16 checks in 9.74s locally on macOS +# after the internal-entrypoint suppression fix; rounded to 10 seconds. +# Those two costs replace the missing-file estimate and historical 2s row. +# Adding a test file changes the greedy partition, so stale costs can move +# both expensive suites into a shard that exceeds the 30-minute job budget. +# # Columns: basenameseconds. Sorted by basename for a legible diff. test_actas_integration.bats 54 test_actas_lock.bats 34 +test_agmsgd_switch.bats 10 test_antigravity_resume.bats 1 test_antigravity_transport.bats 20 test_api.bats 63 @@ -86,6 +91,7 @@ test_endpoint_table_node.bats 1 test_enforced_assertions.bats 1 test_engine_inherited_fds.bats 21 test_export.bats 30 +test_ext_tool_jev_handle.bats 604 test_harness_self_name_off.bats 1 test_hash.bats 7 test_herdr_cli_routing.bats 2 @@ -167,16 +173,8 @@ test_transcript_exists.bats 2 test_type_registry.bats 35 test_unguarded_env_reads.bats 2 test_wait_helpers.bats 41 -# 2s is real, not stale -- the whole file is quarantined for #1262 -# (setup() skips every test unconditionally, tracked by #1318), so this is -# what it actually costs THIS run, which is what this table exists to -# reflect (a pinned pre-quarantine value was tried and reverted: it gave a -# nicer-looking on-paper balance but actively unbalanced the real schedule, -# handing 315s of imaginary work to whichever shard it landed in while that -# shard really finished ~300s early). #1318 must re-measure and update this -# row when it un-quarantines the file -- until then, reuse 2, not a stale -# pre-quarantine number. -test_watch.bats 2 +# Re-measured after the whole-file quarantine was removed; see refresh above. +test_watch.bats 215 test_watch_install_changed.bats 34 test_watch_once.bats 53 test_watch_process_count.bats 18 diff --git a/README.md b/README.md index ca29849ce..062dead97 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,25 @@ Git Bash PATH). There is no PowerShell reimplementation. Set-Alias bash 'C:\Program Files\Git\bin\bash.exe' ``` +### agmsgd beta (1.6.0) + +The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, replace other agents' monitors, or take over remote sync. macOS and Linux are supported. Windows delivery is unsupported until it has been measured on a real Windows session. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. + +Requires Node >= 22.13.0 with `node:sqlite` (the experimental SQLite warning is expected). Install Node from [nodejs.org](https://nodejs.org/en/download). Enable and inspect the beta with: + +```bash +agmsg daemon enable +agmsg daemon status +``` + +If `agmsg` is not found, use `/scripts/agmsg daemon enable` (and the same path for `status`, `start`, `stop`, or `disable`); `` is the installation directory printed by the installer. Follow its PATH instructions to make `agmsg` available in your shell. + +While enabled, new Codex launches through the shim use plain Codex: no bridge app-server or dispatcher is started. Sessions that already have a bridge keep using it until restarted; agmsgd skips those sessions. `enable` and `start` list the remaining bridge sessions and missing destination records. Restart Codex and run `$agmsg actas ` to record a missing destination. Existing remote sync processes remain separate, so this beta does not yet reduce all background work to one process. + +If agmsgd stops while enabled, the next ordinary agmsg script operation warns at most once every ten minutes per install. Codex startup also warns, and `daemon status` and `scripts/doctor.sh` report the failure with exit code 1. Run `agmsg daemon start` to recover. For a missing or outdated Node executable, install Node and run `agmsg daemon enable` again. Unread messages are preserved. + +To return to bridge notices, run `agmsg daemon disable`, then restart the Codex sessions listed as having no bridge (for example, `codex resume`, followed by `$agmsg actas `). They receive no new notices until restarted. Existing bridge sessions need no restart, and subsequent Codex launches get a bridge. Notices already accepted by the Codex queue are not cancelled and may appear once more. `stop` stops the daemon without removing its resident service; `disable` also unregisters that service. + ## First run Open your project in your agent (Claude Code, Codex, Gemini CLI, etc.) and run: diff --git a/docs/design/agmsgd-architecture.md b/docs/design/agmsgd-architecture.md index 3d41ff4ff..72cc850b1 100644 --- a/docs/design/agmsgd-architecture.md +++ b/docs/design/agmsgd-architecture.md @@ -2,6 +2,8 @@ This is the architecture design for agmsgd: the approved direction in [the RFC](agmsgd-rfc.md), worked down to how each part behaves. It describes the current design only; the reasoning behind each choice lives in the discussion and in the ADRs that will follow. Implementation has not started. The technical design (where the source lives, how it is built and shipped, the main components) comes next. +**1.6.0 beta scope:** the optional, default-off daemon queues inbox notices for Codex sessions against the existing SQLite store, before the 2.0.0 migration. Existing bridges and remote sync processes remain separate; a live bridge continues until its Codex session restarts. `agmsg daemon enable` changes new Codex launches, while `agmsg daemon disable` lists sessions that need restarting to restore bridge notices. If `agmsg` is not found, use `/scripts/agmsg daemon enable|disable`. macOS and Linux are supported. Windows delivery remains unsupported pending measurement, and JSONL storage is unsupported. The beta does not implement the 2.0.0 storage or delivery changes described below. + Status: 2026-09-28. [日本語版](agmsgd-architecture.ja.md) ## 1. Overview diff --git a/docs/design/agmsgd-rfc.ja.md b/docs/design/agmsgd-rfc.ja.md index bfad5092e..9813eb4d8 100644 --- a/docs/design/agmsgd-rfc.ja.md +++ b/docs/design/agmsgd-rfc.ja.md @@ -44,6 +44,8 @@ flowchart LR この順で出す: +**1.6.0 beta note:** agmsgd is optional and off by default before the 2.0.0 store migration. It only queues inbox notices for Codex sessions against the existing SQLite store. Existing Codex bridges and the remote sync engine remain separate processes; bridge sessions continue until restarted. Use `agmsg daemon enable` for new Codex launches, or `agmsg daemon disable` followed by restarting sessions that have no bridge. If `agmsg` is not found, use `/scripts/agmsg daemon enable|disable`. macOS and Linux are supported; Windows delivery remains unsupported pending measurement, and JSONL storage is unsupported. The beta does not bring forward the 2.0.0 data migration. + 1. **1.3.1** — まだ知らないメッセージの項目を読み飛ばせるようにする(出荷済み)。 2. **古い版が脇によけたものの読み直し** — 上げたあと、以前は理解できなかった保存済みの原文を読み直す(出荷済み)。 3. **新しいメッセージすべてに、共通の id・件名・要約を付ける**(1.x)。 diff --git a/docs/design/agmsgd-rfc.md b/docs/design/agmsgd-rfc.md index dadaec91c..ae3029fab 100644 --- a/docs/design/agmsgd-rfc.md +++ b/docs/design/agmsgd-rfc.md @@ -44,6 +44,8 @@ The trigger is concrete bugs. Several problems users actually hit came from mana It ships in this order: +The 1.6.0 agmsgd beta is an optional, default-off exception before the 2.0.0 store migration: it only queues inbox notices for Codex sessions against the existing SQLite store. Existing Codex bridges and the remote sync engine remain available as separate processes; bridge sessions continue until restarted. `agmsg daemon enable` switches new Codex launches to daemon notices, and `agmsg daemon disable` requires restarting sessions that have no bridge. If `agmsg` is not found, use `/scripts/agmsg daemon enable` or `/scripts/agmsg daemon disable`. The beta supports macOS and Linux; Windows delivery remains unsupported pending measurement, and JSONL storage is unsupported. It does not bring forward the 2.0.0 data migration. + 1. **1.3.1** — clients skip message fields they do not know yet (released). 2. **Re-reading what an older client set aside** — after an upgrade, the client re-reads the stored originals it could not understand before (released). 3. **A shared id, subject and summary on every new message** (1.x). diff --git a/scripts/check-inbox.sh b/scripts/check-inbox.sh index 5aba3caa2..cca13a198 100755 --- a/scripts/check-inbox.sh +++ b/scripts/check-inbox.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +# Hooks and their child operations must not run user recovery notices. +export AGMSG_DAEMON_NOTICE_SKIP=1 # shellcheck disable=SC1091 source "$(cd "$(dirname "$0")" && pwd)/lib/compat.sh" diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 2c6cc2e70..180a76280 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -1,10 +1,8 @@ #!/usr/bin/env bash # agmsgd's CLI ("agmsg daemon start|stop|status|enable|disable"). -# Written to take the subcommand as its own first argument so -# the future `agmsg` dispatcher (a separate PR/design, decided 2026-09-29) -# can wrap this file directly without restructuring it. Every message this -# file prints therefore already says `agmsg daemon ...`, the form users -# will actually type once that dispatcher exists. +# Takes the subcommand as its first argument so the `agmsg` dispatcher +# wraps this file directly. User-facing instructions use `agmsg daemon ...` +# and give the installed dispatcher's absolute path when PATH is not set. # # No Node is required for `status` on the fast path: it reads # install.db directly with sqlite3 first, and only shells out to Node @@ -17,6 +15,16 @@ SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" INSTALL_DB="$SKILL_DIR/run/install.db" LOCK_DB="$SKILL_DIR/run/install-op.lock.db" LAUNCHER="$SCRIPT_DIR/daemon/agmsgd-launch.sh" +# shellcheck source=lib/daemon-state.sh +source "$SCRIPT_DIR/lib/daemon-state.sh" +# shellcheck source=lib/storage.sh +source "$SCRIPT_DIR/lib/storage.sh" +# shellcheck source=lib/daemon-seats.sh +source "$SCRIPT_DIR/lib/daemon-seats.sh" + +_path_hint() { + printf 'If agmsg is not found, use "%s/scripts/agmsg" daemon %s.\n' "$SKILL_DIR" "$1" +} # Overridable so tests never register anything into the REAL resident # manager (the real gui launchd domain, the real systemd --user, the real @@ -392,6 +400,8 @@ cmd_start() { state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;")" if [ "$state" = "ready" ] && node "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" >/dev/null 2>&1; then echo "agmsg daemon start: already running" + agmsg_daemon_seat_report start + _path_hint status return 0 fi @@ -408,8 +418,12 @@ cmd_start() { fi if _wait_for_ready "$new_op_gen"; then echo "agmsg daemon start: running" + agmsg_daemon_seat_report start + _path_hint status else echo "agmsg daemon start: did not become ready in time -- check 'agmsg daemon status'" >&2 + agmsg_daemon_warn_if_stopped always + _path_hint status >&2 return 1 fi } @@ -465,11 +479,23 @@ cmd_stop() { cmd_status() { _require_install_db + agmsg_daemon_read_state + local health_rc=0 + if [ "${AGMSGD_DESIRED:-unknown}" = on ] && [ "${AGMSGD_HEALTH:-unknown}" != ready ]; then + agmsg_daemon_recovery_text + health_rc=1 + elif [ "${AGMSGD_HEALTH:-unknown}" = unknown ]; then + echo 'agmsg daemon status: install record could not be read; health is unknown' + health_rc=1 + fi local node_path node_path="$(_resolve_node node 2>/dev/null || true)" if [ -n "$node_path" ]; then - "$node_path" "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" - return $? + local status_rc=0 + "$node_path" "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" || status_rc=$? + agmsg_daemon_seat_report status + [ "$health_rc" -eq 0 ] || return 1 + return "$status_rc" fi # K14 fallback: no usable Node at all -- a minimal, honest read straight # from install.db rather than the fuller status.mjs decision text. @@ -477,6 +503,9 @@ cmd_status() { state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || echo "unknown")" desired="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || echo "unknown")" echo "agmsg daemon status: state=$state intent=$desired (no usable Node -- a fuller check needs 'agmsg daemon enable')" + agmsg_daemon_seat_report status + _path_hint enable + return "$health_rc" } cmd_enable() { @@ -484,6 +513,8 @@ cmd_enable() { local node_path node_path="$(_resolve_node node)" || { echo "agmsg daemon enable: no usable Node found (need >= 22.13.0 with node:sqlite). Install one and try again." >&2 + echo 'Install Node from https://nodejs.org/en/download, then run agmsg daemon enable.' >&2 + _path_hint enable >&2 return 1 } local node_version @@ -517,8 +548,11 @@ cmd_disable() { echo "agmsg daemon disable: service was unregistered but agmsgd has not confirmed stopping -- check 'agmsg daemon status'" >&2 return 1 fi - echo "agmsg daemon disable: agmsgd turned off. Codex messages will go through the existing bridge again." - echo "agmsg daemon disable: any Codex session started while agmsgd was in use needs to be restarted to get its bridge back." + echo 'agmsg daemon disable: agmsgd turned off. Existing bridges continue to deliver notices; new Codex launches get a bridge.' + echo 'Codex sessions without a bridge must be restarted. Until then they receive no new notices; unread messages are preserved. Restart with codex resume, then enter $agmsg actas in that Codex session.' + echo 'Notices already accepted by the Codex queue are not cancelled and may appear once more.' + agmsg_daemon_seat_report disable + _path_hint disable } case "${1:-}" in diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs index 775ebf3c5..9562bdcdc 100644 --- a/scripts/daemon/status.mjs +++ b/scripts/daemon/status.mjs @@ -29,11 +29,23 @@ import { PROTOCOL_VERSION } from "./control.mjs"; const STARTING_STOPPING_GRACE_MS = 30_000; // The pure decision. `now` is injectable for tests. -export function classify({ owner, intent, alive, reachable }, now = Date.now()) { +export function classify({ owner, intent, alive, reachable, lastAttempt }, now = Date.now()) { const desired = intent?.desired ?? null; + if (desired === "on" && !(owner.state === "ready" && alive === true && reachable)) { + const reason = lastAttempt?.reason ?? owner.last_end_reason ?? + (owner.state === "ready" ? "executor or control socket unavailable" : owner.state); + const detail = owner.last_end_reason === "bind_failed" ? "failed to start" : + owner.last_end_reason === "stepped_aside_for_update" ? "stopped for an update; the new version has not started yet" : "stopped"; + return { + text: `agmsgd is ${detail} (intent is on; reason: ${reason}${owner.state === "ready" && !reachable ? "; does not answer on its control socket" : ""}). Run agmsg daemon start (recommended), or agmsg daemon disable and restart Codex. Unread messages are preserved.`, + exitCode: 1, + gen: owner.gen, + }; + } + if (owner.state === "ready") { - if (reachable) { + if (reachable && alive === true) { return { text: `agmsgd is running (gen ${owner.gen}, version ${owner.version ?? "unknown"})`, exitCode: 0, @@ -100,6 +112,7 @@ export function readOwnerAndIntentReadOnly(installRoot) { try { const owner = db.prepare("SELECT * FROM daemon_owner").get(); const intent = db.prepare("SELECT * FROM daemon_intent").get(); + const lastAttempt = db.prepare("SELECT at, reason FROM daemon_start_attempts WHERE at > ? ORDER BY at DESC, rowid DESC LIMIT 1").get(owner.last_end_at ?? ""); let codexSeats; try { codexSeats = { @@ -114,7 +127,7 @@ export function readOwnerAndIntentReadOnly(installRoot) { owner.state === "none" || owner.executor_pid == null ? null : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); - return { owner, intent, alive, codexSeats }; + return { owner, intent, alive, codexSeats, lastAttempt }; } finally { db.close(); } @@ -155,9 +168,9 @@ async function main() { process.stderr.write("usage: status.mjs \n"); process.exit(2); } - let owner, intent, alive, codexSeats; + let owner, intent, alive, codexSeats, lastAttempt; try { - ({ owner, intent, alive, codexSeats } = readOwnerAndIntentReadOnly(installRoot)); + ({ owner, intent, alive, codexSeats, lastAttempt } = readOwnerAndIntentReadOnly(installRoot)); } catch (error) { // An input that can be detected as an error is reported at // that entry point, with a nonzero exit -- not a raw stack trace, and @@ -166,7 +179,7 @@ async function main() { process.exit(1); } const reachable = owner.state === "ready" && owner.socket ? await probeSocket(owner.socket) : false; - const result = classify({ owner, intent, alive, reachable }); + const result = classify({ owner, intent, alive, reachable, lastAttempt }); // The node:sqlite experimental-feature warning is surfaced here, always // -- not hidden, not treated as a failure. diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 9a5bfa313..371f5895b 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -682,6 +682,22 @@ if [ -n "$GLOBAL_WATCH_LINE" ]; then _warn "watcher pidfile present but process not running, installation-wide (see the 'watch processes' line above)" fi fi +# Daemon health is install-wide, including when Node is unavailable. Read +# without starting, repairing or registering anything. +if [ -f "$RUN_DIR/install.db" ]; then + # shellcheck disable=SC1091 + source "$SCRIPT_DIR/lib/daemon-state.sh" + # shellcheck disable=SC1091 + source "$SCRIPT_DIR/lib/daemon-seats.sh" + agmsg_daemon_read_state + if [ "${AGMSGD_DESIRED:-unknown}" = on ] && [ "${AGMSGD_HEALTH:-unknown}" != ready ]; then + _warn "$(_redact_text "$(agmsg_daemon_recovery_text)" "$SKILL_DIR")" + elif [ "${AGMSGD_HEALTH:-unknown}" = unknown ]; then + _warn 'agmsgd: install record could not be read; daemon health is unknown' + fi + REPORT_BLOCKS="${REPORT_BLOCKS}agmsgd beta: intent=${AGMSGD_DESIRED:-unknown} health=${AGMSGD_HEALTH:-unknown}"$'\n' + REPORT_BLOCKS="${REPORT_BLOCKS}$(agmsg_daemon_seat_report status)"$'\n\n' +fi WARN_COUNT="$(printf '%s\n' "$WARNINGS" | grep -c . || true)" echo "$TEAM_COUNT team(s), $TOTAL_PAIR_COUNT registration(s), $WARN_COUNT warning(s)" diff --git a/scripts/drivers/types/codex/codex-monitor.sh b/scripts/drivers/types/codex/codex-monitor.sh index a24f2e3e5..4ebc4c6a4 100755 --- a/scripts/drivers/types/codex/codex-monitor.sh +++ b/scripts/drivers/types/codex/codex-monitor.sh @@ -103,6 +103,21 @@ esac PROJECT="$(cd "$PROJECT" && pwd)" +# Direct monitor-wrapper users follow the same intent rule as shim users. +# This path must run before creating the seat or any long-lived process. +# shellcheck source=../../../lib/daemon-state.sh +source "$SCRIPT_DIR/../../../lib/daemon-state.sh" +agmsg_daemon_read_state +if [ "${AGMSGD_DESIRED:-unknown}" = on ]; then + echo 'agmsgd handles Codex notices; starting plain Codex without a bridge.' >&2 + agmsg_daemon_warn_if_stopped always + cd "$PROJECT" + case "$CODEX_COMMAND" in + codex) exec "$REAL_CODEX" ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} ;; + resume) exec "$REAL_CODEX" resume ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} ;; + esac +fi + # Fail-open: never let a broken bridge block codex. If the agmsg app-server can't # be brought up — e.g. a codex release changes the app-server interface and the # launch/port detection fails — hand off to a plain codex session (no --remote diff --git a/scripts/drivers/types/codex/codex-shim.sh b/scripts/drivers/types/codex/codex-shim.sh index ea4d4dbbf..d33d4b72d 100755 --- a/scripts/drivers/types/codex/codex-shim.sh +++ b/scripts/drivers/types/codex/codex-shim.sh @@ -159,6 +159,25 @@ fi project="$(project_from_args "$@")" command_name="$(first_non_option "$@" || true)" +# agmsgd owns newly launched seats while intent is on, even when the daemon +# is down. Do not start any app-server, dispatcher or bridge as a fallback. +daemon_state_lib="$SCRIPT_DIR/../../../lib/daemon-state.sh" +if [ -f "$daemon_state_lib" ]; then + # shellcheck disable=SC1090 + source "$daemon_state_lib" + agmsg_daemon_read_state + if [ "${AGMSGD_DESIRED:-unknown}" = on ]; then + case "$command_name" in + app-server|exec|e|login|logout|mcp|plugin|remote-control|completion|update|doctor|cloud|exec-server|features|debug|apply|a|review|sandbox|help|--help|-h|version|--version|-V) ;; + *) + echo 'agmsgd handles Codex notices; starting plain Codex without a bridge.' >&2 + agmsg_daemon_warn_if_stopped always + ;; + esac + exec "$real_codex" "$@" + fi +fi + if ! is_monitor_project "$project"; then exec "$real_codex" "$@" fi diff --git a/scripts/drivers/types/codex/watch-once.sh b/scripts/drivers/types/codex/watch-once.sh index adea69b7a..6c798e235 100755 --- a/scripts/drivers/types/codex/watch-once.sh +++ b/scripts/drivers/types/codex/watch-once.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +# Internal polling and its child operations must not run user notices. +export AGMSG_DAEMON_NOTICE_SKIP=1 # One-shot pending-message oracle for the Codex app-server bridge. # diff --git a/scripts/lib/daemon-seats.sh b/scripts/lib/daemon-seats.sh new file mode 100644 index 000000000..2c5c39303 --- /dev/null +++ b/scripts/lib/daemon-seats.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# User-facing transition inventory. Reads current registrations and bridge +# evidence rather than trusting the daemon's last poll after it has stopped. +[ -n "${_AGMSG_DAEMON_SEATS_SH:-}" ] && return 0 +_AGMSG_DAEMON_SEATS_SH=1 +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/instance-id.sh" +# shellcheck disable=SC1091 +source "$SKILL_DIR/scripts/lib/role-session.sh" + +agmsg_daemon_seat_report() { + local action="${1:-status}" cfg cfg_sql roster team agent project pid meta_type meta_project meta_pid + local bridge record_status cached key driver record_type record_home label + driver="$(agmsg_storage_driver)" || driver=unknown + for cfg in "$SKILL_DIR/teams/"*/config.json; do + [ -f "$cfg" ] || continue + cfg_sql="$(agmsg_sql_readfile_path "$cfg")" + roster="$(sqlite3 -separator $'\t' :memory: " + WITH cfg(j) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)) + SELECT DISTINCT json_extract(cfg.j,'\$.name'), a.key, json_extract(r.value,'\$.project') + FROM cfg, json_each(cfg.j,'\$.agents') a, json_each(a.value,'\$.registrations') r + WHERE json_extract(r.value,'\$.type')='codex'; + " 2>/dev/null)" || { echo 'Codex seats: registration inventory could not be read'; continue; } + while IFS=$'\t' read -r team agent project; do + [ -n "$team" ] && [ -n "$agent" ] || continue + [ -z "${FILTER_TEAM:-}" ] || [ "$FILTER_TEAM" = "$team" ] || continue + [ -z "${FILTER_PROJECT:-}" ] || [ "$FILTER_PROJECT" = "$project" ] || continue + [ -z "${FILTER_TYPE:-}" ] || [ "$FILTER_TYPE" = codex ] || continue + if ! agmsg_validate_team_name "$team" >/dev/null 2>&1 || ! agmsg_validate_agent_name "$agent" >/dev/null 2>&1; then continue; fi + bridge=stopped + if [ -e "$SKILL_DIR/run/codex-bridge.$team.$agent.pid" ]; then + pid="$(cat "$SKILL_DIR/run/codex-bridge.$team.$agent.pid" 2>/dev/null)" || pid="" + meta_pid=""; meta_type=""; meta_project="" + if [ -r "$SKILL_DIR/run/codex-bridge.$team.$agent.meta" ]; then + local field value + while IFS='=' read -r field value; do + case "$field" in pid) meta_pid="$value" ;; type) meta_type="$value" ;; project) meta_project="$value" ;; esac + done < "$SKILL_DIR/run/codex-bridge.$team.$agent.meta" + fi + case "$pid" in ''|*[!0-9]*) bridge=unknown ;; *) + if [ "$meta_pid" != "$pid" ] || [ "$meta_type" != codex ] || [ "$meta_project" != "$project" ]; then + bridge=unknown + elif _agmsg_pid_alive "$pid"; then + bridge=running + fi ;; + esac + fi + agmsg_role_session_load "$team" "$agent" 2>/dev/null || true + record_type="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" type)" + record_home="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" codex_home)" + record_status='destination recorded' + if [ "$record_type" != codex ] || [ -z "${AGMSG_ROLE_SESSION_UUID:-}" ] || [ -z "$record_home" ]; then + record_status='no destination record; restart Codex and run actas again to record it' + fi + key="$(printf '%s' "$team" | sed "s/'/''/g")" + local agent_sql + agent_sql="$(printf '%s' "$agent" | sed "s/'/''/g")" + local thread_sql + thread_sql="$(printf '%s' "${AGMSG_ROLE_SESSION_UUID:-}" | sed "s/'/''/g")" + cached="$(sqlite3 -readonly -cmd '.timeout 100' "$SKILL_DIR/run/install.db" "SELECT reason FROM beta_codex_seat WHERE seat=json_array('$key','$agent_sql') AND thread='$thread_sql';" 2>/dev/null)" || cached="" + case "$cached" in + thread_archived*) record_status='conversation archived; resume an active conversation and run actas again' ;; + codex_home_mismatch*) record_status='incorrect CODEX_HOME record; run actas from the correct Codex profile' ;; + esac + label="Codex $team/$agent" + if [ "${REDACTED:-0}" = 1 ]; then label='Codex seat (redacted)'; cached=""; fi + case "$action:$bridge" in + disable:running) echo "$label: already using a bridge; no restart needed" ;; + disable:stopped) echo "$label: no bridge attached; restart Codex to restore notices ($record_status)" ;; + disable:unknown) echo "$label: bridge state unknown; inspect delivery.sh status codex before restarting ($record_status)" ;; + *:running) echo "$label: still using a bridge; restart Codex to move to agmsgd" ;; + *:unknown) echo "$label: bridge state unknown ($record_status)" ;; + *) echo "$label: $record_status${cached:+; last channel observation: $cached}" ;; + esac + if [ "$driver" != sqlite ]; then + echo "$label: agmsgd beta does not support $driver storage (including JSONL). Keep using the bridge; disable agmsgd before restarting this seat." + fi + done <<< "$roster" + done +} diff --git a/scripts/lib/daemon-state.sh b/scripts/lib/daemon-state.sh new file mode 100644 index 000000000..6b1edb500 --- /dev/null +++ b/scripts/lib/daemon-state.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Read-only daemon health, shared by ordinary operations, the shim and doctor. +# Resolve from this install, never from the message-storage override. +[ -n "${_AGMSG_DAEMON_STATE_SH:-}" ] && return 0 +_AGMSG_DAEMON_STATE_SH=1 +_AGMSG_DAEMON_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +agmsg_daemon_read_state() { + AGMSGD_DESIRED=unknown; AGMSGD_HEALTH=unknown; AGMSGD_REASON="install record unavailable" + local row state pid boot recorded_start current_boot process_start started_epoch + [ -f "$_AGMSG_DAEMON_ROOT/run/install.db" ] || return 0 + # One sqlite invocation, read-only, bounded when another writer is busy. + row="$(sqlite3 -readonly -cmd '.timeout 100' -separator $'\t' "$_AGMSG_DAEMON_ROOT/run/install.db" " + SELECT COALESCE(i.desired,'unset'), o.state, COALESCE(o.executor_pid,0), + COALESCE(o.executor_boot_id,'-'), COALESCE(o.executor_started_at,'-'), + replace(replace(replace(COALESCE( + (SELECT reason FROM daemon_start_attempts WHERE at > COALESCE(o.last_end_at,'') ORDER BY at DESC, rowid DESC LIMIT 1), + o.last_end_reason, 'executor unavailable'),char(9),' '),char(10),' '),char(13),' ') + FROM daemon_owner o CROSS JOIN daemon_intent i; + " 2>/dev/null)" || return 0 + [ -n "$row" ] || return 0 + IFS=$'\t' read -r AGMSGD_DESIRED state pid boot recorded_start AGMSGD_REASON <<< "$row" + AGMSGD_HEALTH=stopped + [ "$state" = ready ] || { AGMSGD_REASON="$state: $AGMSGD_REASON"; return 0; } + case "$pid" in ''|*[!0-9]*|0|1) return 0 ;; esac + case "$(uname -s)" in + Darwin) + current_boot="$(sysctl -n kern.boottime 2>/dev/null | sed -n 's/.*sec = \([0-9]*\),.*/\1/p')" + process_start="$(LC_ALL=C ps -p "$pid" -o lstart= 2>/dev/null)" || process_start="" + started_epoch="$(LC_ALL=C date -j -f '%a %b %e %T %Y' "$process_start" +%s 2>/dev/null)" || started_epoch="" + ;; + Linux) + current_boot="$(sed -n 's/^btime //p' /proc/stat 2>/dev/null)" + local proc_stat ticks hz + proc_stat="$(cat "/proc/$pid/stat" 2>/dev/null)" || proc_stat="" + ticks="$(printf '%s' "${proc_stat##*) }" | awk '{print $20}')" + hz="$(getconf CLK_TCK 2>/dev/null)" || hz="" + started_epoch="" + case "$ticks:$hz:$current_boot" in *[!0-9:]*|:*|*::*|*:) ;; *) + [ "$hz" -gt 0 ] && started_epoch=$((current_boot + ticks / hz)) ;; + esac + ;; + *) AGMSGD_REASON="platform liveness check unavailable"; return 0 ;; + esac + if [ -z "$current_boot" ] || [ "$current_boot" != "$boot" ] || [ -z "$started_epoch" ]; then + AGMSGD_REASON="executor missing or boot/start evidence does not match ($AGMSGD_REASON)" + return 0 + fi + # The owner records its claim time. A process born after that claim is a + # reused PID, never evidence that the recorded executor is still running. + local claim_epoch + case "$recorded_start" in + ????-??-??T??:??:??*) + case "$(uname -s)" in + Darwin) claim_epoch="$(TZ=UTC date -j -f '%Y-%m-%dT%H:%M:%S' "${recorded_start:0:19}" +%s 2>/dev/null)" || claim_epoch="" ;; + Linux) claim_epoch="$(date -u -d "$recorded_start" +%s 2>/dev/null)" || claim_epoch="" ;; + esac ;; + *) claim_epoch="" ;; + esac + if [ -z "$claim_epoch" ] || [ "$started_epoch" -gt "$claim_epoch" ]; then + AGMSGD_REASON="executor start evidence does not match ($AGMSGD_REASON)" + return 0 + fi + local process_state + process_state="$(ps -p "$pid" -o stat= 2>/dev/null)" || process_state="" + case "$process_state" in ''|*Z*|*T*) AGMSGD_REASON="executor exited or suspended ($AGMSGD_REASON)"; return 0 ;; esac + AGMSGD_HEALTH=ready + AGMSGD_REASON="" +} + +agmsg_daemon_recovery_text() { + printf 'agmsgd (Codex notices) is stopped while enabled (reason: %s). Run agmsg daemon start (recommended), or agmsg daemon disable and restart your Codex sessions. Unread messages are preserved.' "$AGMSGD_REASON" + case "$AGMSGD_REASON" in *Node*|*node*) + printf ' Install Node >= 22.13.0 from https://nodejs.org/en/download, then run agmsg daemon enable.' ;; + esac + printf ' If agmsg is not found, use "%s/scripts/agmsg" daemon start (replace start with disable or enable as needed).\n' "$_AGMSG_DAEMON_ROOT" +} + +agmsg_daemon_warn_if_stopped() { + # Keep health state local without forking a shell that inherits caller FDs. + local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON + local now last=0 marker slot + marker="$_AGMSG_DAEMON_ROOT/run/agmsgd-warning-at" + if [ "${1:-}" != always ]; then + now="$(date +%s)" || return 0 + [ ! -f "$marker" ] || IFS= read -r last < "$marker" || last=0 + case "$last" in ''|*[!0-9]*) last=0 ;; esac + # Already warned: skip SQLite and process liveness checks entirely. + [ "$((now - last))" -ge 600 ] || return 0 + fi + agmsg_daemon_read_state + [ "$AGMSGD_DESIRED" = on ] && [ "$AGMSGD_HEALTH" != ready ] || return 0 + if [ "${1:-}" = always ]; then + agmsg_daemon_recovery_text >&2 + return 0 + fi + # Atomic claim per time window; reread the rolling timestamp after claiming. + # Concurrent claims across a boundary can still emit twice (beta limitation). + # A killed claimant can suppress at most this window, never all future ones. + slot="$_AGMSG_DAEMON_ROOT/run/agmsgd-warning-slot.$((now / 600))" + mkdir "$slot" 2>/dev/null || return 0 + [ ! -f "$marker" ] || IFS= read -r last < "$marker" || last=0 + case "$last" in ''|*[!0-9]*) last=0 ;; esac + if [ "$((now - last))" -ge 600 ]; then + printf '%s\n' "$now" > "$slot/at" || return 0 + mv "$slot/at" "$marker" || return 0 + agmsg_daemon_recovery_text >&2 + fi + # The claimed window remains as an empty directory. Only past windows are + # removed; none of them can grant a fresh claim in the current window. + local old old_window + for old in "$_AGMSG_DAEMON_ROOT/run/"agmsgd-warning-slot.*; do + [ -d "$old" ] || continue + old_window="${old##*.}" + case "$old_window" in ''|*[!0-9]*) continue ;; esac + [ "$old_window" -lt "$((now / 600))" ] && rmdir "$old" 2>/dev/null || true + done + return 0 +} diff --git a/scripts/lib/storage.sh b/scripts/lib/storage.sh index 1f5c215d1..ba675579b 100644 --- a/scripts/lib/storage.sh +++ b/scripts/lib/storage.sh @@ -30,6 +30,22 @@ [ -n "${_AGMSG_STORAGE_SH:-}" ] && return 0 _AGMSG_STORAGE_SH=1 +# Ordinary operations on a non-daemon install pay only a file-existence check. +# Watchers suppress this entry check in themselves and all child operations. +_agmsg_entry_dir="${0%/*}" +[ "$_agmsg_entry_dir" != "$0" ] || _agmsg_entry_dir=. +if [ "${AGMSG_DAEMON_NOTICE_SKIP:-0}" != 1 ] && [ -f "$_agmsg_entry_dir/../run/install.db" ]; then + case "${0##*/}" in daemon.sh|doctor.sh|delivery.sh|watch.sh|session-start.sh|session-end.sh|check-inbox.sh) ;; *) + if [ -f "$_agmsg_entry_dir/lib/daemon-state.sh" ]; then + # shellcheck disable=SC1091 + source "$_agmsg_entry_dir/lib/daemon-state.sh" + agmsg_daemon_warn_if_stopped || true + fi + ;; + esac +fi +unset _agmsg_entry_dir + # agmsg_db_path turns the team selector into a path segment, so it cannot do its # job without the shared name validator. Sourced here rather than left to each # caller: watch.sh already reached the store without validate.sh in scope, and a diff --git a/scripts/session-end.sh b/scripts/session-end.sh index 44220b524..81d6d2624 100755 --- a/scripts/session-end.sh +++ b/scripts/session-end.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +# Hooks and their child operations must not run user recovery notices. +export AGMSG_DAEMON_NOTICE_SKIP=1 # shellcheck disable=SC1091 source "$(cd "$(dirname "$0")" && pwd)/lib/compat.sh" diff --git a/scripts/session-start.sh b/scripts/session-start.sh index 07e53896f..9a8ac894f 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +# Hooks and their child operations must not run user recovery notices. +export AGMSG_DAEMON_NOTICE_SKIP=1 # shellcheck disable=SC1091 source "$(cd "$(dirname "$0")" && pwd)/lib/compat.sh" diff --git a/scripts/watch.sh b/scripts/watch.sh index e9426ed6b..3fea047c5 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -49,6 +49,8 @@ ACTIVE_NAME="${4:-}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +# Polling and its child operations must never run the user-operation notice. +export AGMSG_DAEMON_NOTICE_SKIP=1 source "$SCRIPT_DIR/lib/storage.sh" agmsg_storage_load # Warm agmsg_storage_dir's own process-lifetime cache as a PLAIN STATEMENT, diff --git a/tests/agmsgd_status.test.mjs b/tests/agmsgd_status.test.mjs index 9c5edd195..8e40a03ce 100644 --- a/tests/agmsgd_status.test.mjs +++ b/tests/agmsgd_status.test.mjs @@ -4,6 +4,17 @@ import { classify } from "../scripts/daemon/status.mjs"; const baseOwner = { gen: 1, version: "1.6.0", socket: "/tmp/x.sock" }; +test("enabled intent never reports a never-started or dead executor as healthy", () => { + for (const owner of [{ ...baseOwner, gen: 0, state: "none" }, { ...baseOwner, state: "ready" }]) { + const result = classify({ owner, intent: { desired: "on" }, alive: false, reachable: true, + lastAttempt: { reason: "restart limit reached" } }); + assert.equal(result.exitCode, 1); + assert.match(result.text, /restart limit reached/); + assert.match(result.text, /agmsg daemon start/); + assert.match(result.text, /agmsg daemon disable/); + } +}); + test("ready + reachable -> running, exit 0", () => { const r = classify({ owner: { ...baseOwner, state: "ready" }, intent: { desired: "on" }, alive: true, reachable: true }); assert.equal(r.exitCode, 0); diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats index 55d4ef381..d000c534e 100644 --- a/tests/test_agmsgd_entrypoint.bats +++ b/tests/test_agmsgd_entrypoint.bats @@ -80,7 +80,8 @@ NODE local socket="" waited=0 while [ -z "$socket" ]; do - socket="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null)" + # Startup updates owner state concurrently; wait for its short DB write. + socket="$(sqlite3 -cmd '.timeout 5000' "$TEST_SKILL_DIR/run/install.db" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null)" [ -n "$socket" ] && break waited=$((waited + 1)) if [ "$waited" -ge 100 ]; then diff --git a/tests/test_agmsgd_switch.bats b/tests/test_agmsgd_switch.bats new file mode 100644 index 000000000..79eb9d3fb --- /dev/null +++ b/tests/test_agmsgd_switch.bats @@ -0,0 +1,262 @@ +#!/usr/bin/env bats +# Isolated scripts and data, without replacing HOME or touching real services. +assert_contains() { + case "$output" in *"$1"*) return 0 ;; *) printf 'Missing: %s\nOutput: %s\n' "$1" "$output" >&2; return 1 ;; esac +} +assert_lacks() { + case "$output" in *"$1"*) printf 'Unexpected: %s\n' "$1" >&2; return 1 ;; *) return 0 ;; esac +} +refute() { + if "$@"; then return 1; fi + return 0 +} +setup() { + export TEST_SKILL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/agmsgd-switch.XXXXXX")" + TEST_SKILL_DIR="$(cd "$TEST_SKILL_DIR" && pwd -P)" + cp -R "$BATS_TEST_DIRNAME/../scripts" "$TEST_SKILL_DIR/scripts" + export SCRIPTS="$TEST_SKILL_DIR/scripts" + mkdir -p "$TEST_SKILL_DIR/run" "$TEST_SKILL_DIR/teams/demo" "$TEST_SKILL_DIR/db" "$TEST_SKILL_DIR/codex-profile" + export AGMSG_CONFIG="$TEST_SKILL_DIR/config.json" + export AGMSG_STORAGE_PATH="$TEST_SKILL_DIR/db" + export CODEX_HOME="$TEST_SKILL_DIR/codex-profile" + export AGMSG_SELF_NAME=off + unset TMUX TMUX_PANE HERDR_ENV HERDR_PANE_ID HERDR_SOCKET_PATH + unset AGMSG_CODEX_SEAT_KEY AGMSG_CODEX_BRIDGE_APP_SERVER AGMSG_CODEX_SHIM_DISABLE AGMSG_CODEX_BRIDGE + unset AGMSG_DAEMON_NOTICE_SKIP + sqlite3 "$TEST_SKILL_DIR/run/install.db" < "$SCRIPTS/daemon/schema.sql" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET install_id='switch-test'; UPDATE daemon_intent SET desired='on';" + printf '{"install_id":"switch-test","gen":1}\n' > "$TEST_SKILL_DIR/run/install-manifest.json" + printf '{"name":"demo","agents":{"alice":{"registrations":[{"type":"codex","project":"%s"}]},"bob":{"registrations":[{"type":"codex","project":"%s"}]}}}\n' "$TEST_SKILL_DIR" "$TEST_SKILL_DIR" > "$TEST_SKILL_DIR/teams/demo/config.json" + export CALL_LOG="$TEST_SKILL_DIR/calls" + export AGMSG_REAL_CODEX="$TEST_SKILL_DIR/real-codex" + export AGMSG_CODEX_MONITOR_CMD="$TEST_SKILL_DIR/monitor" + printf '#!/usr/bin/env bash\nprintf "plain %%s\\n" "$*" >> "$CALL_LOG"\n' > "$AGMSG_REAL_CODEX" + printf '#!/usr/bin/env bash\nprintf "monitor %%s\\n" "$*" >> "$CALL_LOG"\n' > "$AGMSG_CODEX_MONITOR_CMD" + chmod +x "$AGMSG_REAL_CODEX" "$AGMSG_CODEX_MONITOR_CMD" + # Monitor mode lets missing/unreadable DB retain the existing launch route. + mkdir -p "$TEST_SKILL_DIR/project/.codex" + printf '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"%s/session-start.sh"}]}]}}\n' "$SCRIPTS" > "$TEST_SKILL_DIR/project/.codex/hooks.json" +} + +@test "enabled shim passes arguments unchanged and launches no monitor even when daemon is stopped" { + run bash "$SCRIPTS/drivers/types/codex/codex-shim.sh" -C "$TEST_SKILL_DIR/project" resume --last + [ "$status" -eq 0 ] + assert_contains 'without a bridge' + assert_contains 'agmsg daemon start' + assert_contains "$SCRIPTS/agmsg" + grep -Fq "plain -C $TEST_SKILL_DIR/project resume --last" "$CALL_LOG" + refute grep -q '^monitor' "$CALL_LOG" +} + +@test "off, missing and unreadable install records retain the existing monitor route" { + for condition in off missing unreadable; do + case "$condition" in + off) sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='off';" ;; + missing) mv "$TEST_SKILL_DIR/run/install.db" "$TEST_SKILL_DIR/run/saved.db" ;; + unreadable) printf 'invalid sqlite data\n' > "$TEST_SKILL_DIR/run/install.db" ;; + esac + : > "$CALL_LOG" + run bash "$SCRIPTS/drivers/types/codex/codex-shim.sh" -C "$TEST_SKILL_DIR/project" resume --last + [ "$status" -eq 0 ] + grep -q '^monitor' "$CALL_LOG" + assert_lacks 'agmsgd handles' + done +} + +@test "direct monitor wrapper also bypasses app-server and dispatcher while enabled" { + run bash "$SCRIPTS/drivers/types/codex/codex-monitor.sh" --project "$TEST_SKILL_DIR" --codex-command resume -- --last + [ "$status" -eq 0 ] + grep -Fq 'plain resume --last' "$CALL_LOG" + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT desired FROM daemon_intent;')" = on ] + local files + files="$(printf '%s\n' "$TEST_SKILL_DIR/run/"*)" + [ "${files#*codex-seat}" = "$files" ] + [ "${files#*codex-bridge-request}" = "$files" ] +} + +@test "ordinary operations warn once per install without Node and keep stdout clean" { + mkdir -p "$TEST_SKILL_DIR/no-node" + printf '#!/usr/bin/env bash\necho unexpected-node >> "$CALL_LOG"\nexit 1\n' > "$TEST_SKILL_DIR/no-node/node" + chmod +x "$TEST_SKILL_DIR/no-node/node" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "INSERT INTO daemon_start_attempts VALUES ('2099-01-01','no usable Node recorded',0);" + PATH="$TEST_SKILL_DIR/no-node:$PATH" run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + [ "$status" -eq 0 ] + assert_contains 'Node >= 22.13.0' + assert_contains 'agmsg daemon enable' + assert_contains alice + PATH="$TEST_SKILL_DIR/no-node:$PATH" run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + [ "$status" -eq 0 ] + assert_lacks 'stopped while enabled' + [ -f "$TEST_SKILL_DIR/run/agmsgd-warning-at" ] + [ ! -f "$CALL_LOG" ] +} + +@test "warning interval is rolling across clock windows and expires after ten minutes" { + mkdir -p "$TEST_SKILL_DIR/clock" + printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$TEST_NOW"\n' > "$TEST_SKILL_DIR/clock/date" + chmod +x "$TEST_SKILL_DIR/clock/date" + PATH="$TEST_SKILL_DIR/clock:$PATH" TEST_NOW=1199 run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + assert_contains 'stopped while enabled' + PATH="$TEST_SKILL_DIR/clock:$PATH" TEST_NOW=1201 run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + assert_lacks 'stopped while enabled' + PATH="$TEST_SKILL_DIR/clock:$PATH" TEST_NOW=1799 run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + assert_contains 'stopped while enabled' +} + +@test "storage skips daemon helper loading without an install record" { + mv "$TEST_SKILL_DIR/run/install.db" "$TEST_SKILL_DIR/run/saved.db" + printf '\nprintf "helper loaded\\n" >> "$CALL_LOG"\n' >> "$SCRIPTS/lib/daemon-state.sh" + run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + [ "$status" -eq 0 ] + assert_contains alice + [ ! -f "$CALL_LOG" ] +} + +@test "watch suppresses daemon helper loading in itself and child operations" { + printf '\nprintf "helper loaded\\n" >> "$CALL_LOG"\n' >> "$SCRIPTS/lib/daemon-state.sh" + mv "$SCRIPTS/identities.sh" "$SCRIPTS/identities-real.sh" + cat > "$SCRIPTS/identities.sh" <<'WRAPPER' +#!/usr/bin/env bash +printf 'skip=%s\n' "${AGMSG_DAEMON_NOTICE_SKIP:-0}" >> "$CALL_LOG" +exec bash "$SCRIPTS/identities-real.sh" "$@" +WRAPPER + chmod +x "$SCRIPTS/identities.sh" + AGMSG_WATCH_INTERVAL=0.1 run bash "$SCRIPTS/watch.sh" notice-test "$TEST_SKILL_DIR/unjoined" claude-code + [ "$status" -eq 0 ] + assert_lacks 'stopped while enabled' + [ "$(cat "$CALL_LOG")" = skip=1 ] + [ ! -f "$TEST_SKILL_DIR/run/agmsgd-warning-at" ] +} + +@test "hook and one-shot polling children inherit daemon notice suppression" { + printf '\nprintf "helper loaded\\n" >> "$CALL_LOG"\n' >> "$SCRIPTS/lib/daemon-state.sh" + mv "$SCRIPTS/identities.sh" "$SCRIPTS/identities-real.sh" + cat > "$SCRIPTS/identities.sh" <<'WRAPPER' +#!/usr/bin/env bash +printf 'skip=%s\n' "${AGMSG_DAEMON_NOTICE_SKIP:-0}" >> "$CALL_LOG" +exec bash "$SCRIPTS/identities-real.sh" "$@" +WRAPPER + chmod +x "$SCRIPTS/identities.sh" + local entry expected + for entry in check-inbox.sh session-start.sh drivers/types/codex/watch-once.sh; do + : > "$CALL_LOG" + expected=0 + case "$entry" in + *watch-once.sh) + expected=2 + run bash "$SCRIPTS/$entry" "$TEST_SKILL_DIR" codex --timeout 0 ;; + session-start.sh) + # No registration: exercise identity resolution, then stop before hooks. + run bash "$SCRIPTS/$entry" codex "$TEST_SKILL_DIR/unjoined" > "$CALL_LOG"; }\n' >> "$SCRIPTS/lib/daemon-state.sh" + run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + [ "$status" -eq 0 ] + assert_lacks 'stopped while enabled' + [ ! -f "$CALL_LOG" ] +} + +@test "warning cleanup removes only past slots and preserves current and newer claims" { + mkdir -p "$TEST_SKILL_DIR/clock" "$TEST_SKILL_DIR/run/agmsgd-warning-slot.0" "$TEST_SKILL_DIR/run/agmsgd-warning-slot.2" "$TEST_SKILL_DIR/run/agmsgd-warning-slot.unknown" + printf '#!/usr/bin/env bash\nprintf "1199\\n"\n' > "$TEST_SKILL_DIR/clock/date" + chmod +x "$TEST_SKILL_DIR/clock/date" + PATH="$TEST_SKILL_DIR/clock:$PATH" run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + [ "$status" -eq 0 ] + assert_contains 'stopped while enabled' + [ ! -d "$TEST_SKILL_DIR/run/agmsgd-warning-slot.0" ] + [ -d "$TEST_SKILL_DIR/run/agmsgd-warning-slot.1" ] + [ -d "$TEST_SKILL_DIR/run/agmsgd-warning-slot.2" ] + [ -d "$TEST_SKILL_DIR/run/agmsgd-warning-slot.unknown" ] +} + +@test "status and doctor report enabled failure even without usable Node" { + mkdir -p "$TEST_SKILL_DIR/no-node" + printf '#!/usr/bin/env bash\nexit 1\n' > "$TEST_SKILL_DIR/no-node/node" + chmod +x "$TEST_SKILL_DIR/no-node/node" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "INSERT INTO daemon_start_attempts VALUES ('2099-01-01','no usable Node recorded',0);" + PATH="$TEST_SKILL_DIR/no-node:$PATH" run bash "$SCRIPTS/daemon.sh" status + [ "$status" -eq 1 ] + assert_contains 'no usable Node recorded' + assert_contains 'agmsg daemon enable' + PATH="$TEST_SKILL_DIR/no-node:$PATH" run bash "$SCRIPTS/doctor.sh" --team demo --type codex --redacted + [ "$status" -eq 1 ] + assert_contains 'Codex notices' + assert_contains 'agmsg daemon start' + assert_lacks demo/alice +} + +@test "concurrent ordinary operations claim only one warning" { + bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex > "$TEST_SKILL_DIR/one.out" 2> "$TEST_SKILL_DIR/one.err" & + local one=$! + bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex > "$TEST_SKILL_DIR/two.out" 2> "$TEST_SKILL_DIR/two.err" & + local two=$! + wait "$one" + wait "$two" + [ "$(cat "$TEST_SKILL_DIR/one.err" "$TEST_SKILL_DIR/two.err" | grep -c 'stopped while enabled')" -eq 1 ] + refute grep -q agmsgd "$TEST_SKILL_DIR/one.out" + refute grep -q agmsgd "$TEST_SKILL_DIR/two.out" +} + +@test "status on a never-started enabled install fails with recovery and missing destinations" { + run bash "$SCRIPTS/daemon.sh" status + [ "$status" -eq 1 ] + assert_contains 'agmsg daemon start' + assert_contains 'no destination record' + assert_contains 'demo/alice' +} + +@test "executor evidence recognizes a live ready owner and rejects a changed boot" { + local boot + case "$(uname -s)" in + Darwin) boot="$(sysctl -n kern.boottime | sed -n 's/.*sec = \([0-9]*\),.*/\1/p')" ;; + Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; + *) skip 'POSIX beta executor evidence' ;; + esac + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='$boot', executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" + run bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$status" -eq 0 ] + [ "$output" = ready ] + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET executor_boot_id='previous-boot';" + run bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$output" = stopped ] +} + +@test "all enabled failure causes produce recovery notices without losing messages" { + for reason in 'restart limit reached' 'no usable Node recorded' 'repeated startup failures' crash; do + sqlite3 "$TEST_SKILL_DIR/run/install.db" "DELETE FROM daemon_start_attempts; INSERT INTO daemon_start_attempts VALUES ('2099-01-01','$reason',0);" + run bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_warn_if_stopped always' + [ "$status" -eq 0 ] + assert_contains "$reason" + assert_contains 'agmsg daemon start' + assert_contains 'Unread messages are preserved' + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT desired FROM daemon_intent;')" = on ] + done +} + +@test "disable inventory distinguishes bridge sessions and warns about JSONL" { + printf '{"storage":"jsonl"}\n' > "$AGMSG_CONFIG" + # Match delivery.sh's current pid/metadata liveness contract. + printf '%s\n' "$$" > "$TEST_SKILL_DIR/run/codex-bridge.demo.alice.pid" + printf 'pid=%s\ntype=codex\nproject=%s\n' "$$" "$TEST_SKILL_DIR" > "$TEST_SKILL_DIR/run/codex-bridge.demo.alice.meta" + mkdir -p "$TEST_SKILL_DIR/fake-bin" + printf '#!/usr/bin/env bash\necho Unsupported\n' > "$TEST_SKILL_DIR/fake-bin/uname" + chmod +x "$TEST_SKILL_DIR/fake-bin/uname" + PATH="$TEST_SKILL_DIR/fake-bin:$PATH" run bash "$SCRIPTS/daemon.sh" disable + [ "$status" -eq 0 ] + assert_contains 'demo/alice: already using a bridge; no restart needed' + assert_contains 'demo/bob: no bridge attached; restart Codex' + assert_contains 'JSONL' + assert_contains 'unread messages are preserved' + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT desired FROM daemon_intent;')" = off ] +} From 815eb2ab452ab251e472e6c66beec750a3484ffe Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 23:57:09 -0700 Subject: [PATCH 10/97] fix: handle database readers and immediate stops during daemon startup (#1530) * fix: wait for install database readers during daemon startup * fix: allow daemon stop before polling timer initialization --- scripts/daemon/agmsgd | 7 +++-- scripts/daemon/main.mjs | 3 +- tests/test_agmsgd_daemon_sh.bats | 5 ++- tests/test_agmsgd_entrypoint.bats | 51 +++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/scripts/daemon/agmsgd b/scripts/daemon/agmsgd index 9e3df7837..22149f912 100755 --- a/scripts/daemon/agmsgd +++ b/scripts/daemon/agmsgd @@ -74,6 +74,7 @@ function verifyInstallGeneration(installRoot, { incompleteOperationPath, manifes } const installDb = new DatabaseSync(dbPath, { readOnly: true }); + installDb.exec("PRAGMA busy_timeout = 5000;"); let recordedInstallId; try { recordedInstallId = installDb.prepare("SELECT install_id FROM meta").get().install_id; @@ -127,6 +128,7 @@ async function main() { lockDb.exec("BEGIN EXCLUSIVE;"); let verified; let mainModule; + let openInstallDb; try { verified = verifyInstallGeneration(installRoot, { incompleteOperationPath, manifestPath, dbPath, includeSchema: true }); @@ -135,6 +137,7 @@ async function main() { // log/status) is loaded here, still under the lock; release it only // after the component is fully loaded. mainModule = await import("./main.mjs"); + ({ openInstallDb } = await import("./db.mjs")); } finally { lockDb.exec("COMMIT;"); lockDb.close(); @@ -155,8 +158,8 @@ async function main() { if (current.manifestText !== verified.manifestText) { fail(75, "install generation changed before daemon ownership claim"); } - const db = new DatabaseSync(dbPath); - db.exec(verified.schemaText); + // Use the shared bounded wait for CLI readers racing owner updates. + const db = openInstallDb(dbPath); const release = () => { try { claimLock.exec("COMMIT;"); diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs index 4f1d4b76c..0024c8eed 100644 --- a/scripts/daemon/main.mjs +++ b/scripts/daemon/main.mjs @@ -123,6 +123,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe let controlHandle; let gen; let stopping = false; + let timer; async function doStop(reason) { if (stopping) return; @@ -172,7 +173,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe const watchState = await captureWatchState(installRoot, manifest, manifestText); let polling = false; - const timer = setInterval(async () => { + timer = setInterval(async () => { if (stopping || polling) return; polling = true; try { diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index 2c4e2b67e..1bfcd87aa 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -186,11 +186,10 @@ _assert_start_result() { _seed_install_db _write_completion_record _run_with_deadline 15 bash "$DAEMON" start - [ "$status" -eq 0 ] + _assert_start_result 'running' _run_with_deadline 10 bash "$DAEMON" start - [ "$status" -eq 0 ] - printf '%s\n' "$output" | grep -Fq 'already running' + _assert_start_result 'already running' bash "$DAEMON" stop >/dev/null 2>&1 || true } diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats index d000c534e..0f1686e39 100644 --- a/tests/test_agmsgd_entrypoint.bats +++ b/tests/test_agmsgd_entrypoint.bats @@ -138,6 +138,57 @@ NODE [[ "$output" == *"operation is incomplete"* ]] } +@test "agmsgd entrypoint waits for a CLI read lock before marking ready" { + # Instrument the copied module before hashing it. Synchronize a separate + # reader after ownership is committed, exactly at the ready-write boundary. + node - "$SCRIPTS/daemon/main.mjs" <<'NODE' +const { readFileSync, writeFileSync } = require('node:fs'); +const path = process.argv[2]; +const source = readFileSync(path, 'utf8'); +const needle = ' markReady(db, owned.gen);'; +if (source.split(needle).length !== 2) throw new Error('ready insertion point must occur exactly once'); +const insert = ` + const { spawn } = await import("node:child_process"); + const reader = spawn(process.execPath, ["--input-type=module", "-e", \` + import { DatabaseSync } from "node:sqlite"; + const db = new DatabaseSync(process.argv[1], { readOnly: true }); + db.exec("BEGIN; SELECT state FROM daemon_owner;"); + console.log("locked"); + setTimeout(() => { db.exec("ROLLBACK"); db.close(); }, 300); + \`, installRoot + "/run/install.db"], { stdio: ["ignore", "pipe", "inherit"] }); + await new Promise((resolve, reject) => { + reader.stdout.once("data", resolve); + reader.once("error", reject); + reader.once("exit", (code) => reject(new Error("reader exited before lock: " + code))); + }); +`; +writeFileSync(path, source.replace(needle, insert + needle)); +NODE + _write_completion_record + local daemon_log="$TEST_SKILL_DIR/run/contention.log" + node "$SCRIPTS/daemon/agmsgd" "$TEST_SKILL_DIR" on 0 > "$daemon_log" 2>&1 & + local daemon_pid=$! state="" waited=0 + while [ "$waited" -lt 100 ]; do + state="$(sqlite3 -cmd '.timeout 5000' "$TEST_SKILL_DIR/run/install.db" 'SELECT state FROM daemon_owner;' 2>/dev/null || true)" + [ "$state" = ready ] && break + kill -0 "$daemon_pid" 2>/dev/null || break + waited=$((waited + 1)) + sleep 0.05 + done + if [ "$state" != ready ]; then + cat "$daemon_log" >&2 + kill "$daemon_pid" 2>/dev/null || true + wait "$daemon_pid" 2>/dev/null || true + false + fi + run node "$SCRIPTS/daemon/status.mjs" "$TEST_SKILL_DIR" + [ "$status" -eq 0 ] + [[ "$output" == *"running"* ]] + bash "$SCRIPTS/daemon.sh" stop + wait "$daemon_pid" + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT state FROM daemon_owner;')" = none ] +} + @test "agmsgd does not claim ownership when an install starts after bootstrap unlock" { _inject_incomplete_marker_after_initial_unlock _write_completion_record From 2a1aa62fae175ac991f34ba228f4ec56188b7757 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 1 Oct 2026 14:45:30 -0700 Subject: [PATCH 11/97] test: wait for lock holder acknowledgement in launcher fixture (#1547) The held-lock launcher test could fail before it exercised the launcher: its independent `BEGIN EXCLUSIVE` readiness probe could acquire the lock first, making the holder's own nonblocking `BEGIN` fail with `database is locked`. The holder then waited on its FIFO without owning the lock, while the polling loop eventually reached its unconditional cutoff. The launcher correctly classified the free lock and `.prev` manifest as an incomplete previous update. Wait for an acquisition acknowledgement from the holder itself instead. Enable SQLite bail-on-error so a failed `BEGIN` cannot produce a false acknowledgement, and bound the acknowledgement wait to five seconds. Release the holder before checking the launcher result, and assert exit 75, the update-in-progress diagnostic, and no failed-update attempt record. Production launcher behavior is unchanged. Validation: all eight launcher bats tests pass on macOS, and the held-lock case passes five additional runs. A forced probe-first reproduction produced `Runtime error near line 1: database is locked (5)` while the holder continued without owning the lock. Local verification used a scratch helper copy without HOME reassignment or scratch directory deletion. `git diff --check` passes. --- tests/test_agmsgd_launch.bats | 39 +++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/test_agmsgd_launch.bats b/tests/test_agmsgd_launch.bats index 1cb52918b..26170ca3d 100644 --- a/tests/test_agmsgd_launch.bats +++ b/tests/test_agmsgd_launch.bats @@ -59,27 +59,34 @@ setup_install_db() { # the BEGIN EXCLUSIVE, is what actually holds it across commands, the # way a real in-progress install.sh would. local fifo="$TEST_SKILL_DIR/run/holder.fifo" - mkfifo "$fifo" - sqlite3 "$TEST_SKILL_DIR/run/install-op.lock.db" < "$fifo" & + local ack_fifo="$TEST_SKILL_DIR/run/holder-ack.fifo" + mkfifo "$fifo" "$ack_fifo" + sqlite3 -batch "$TEST_SKILL_DIR/run/install-op.lock.db" < "$fifo" > "$ack_fifo" & local holder_pid=$! exec 8> "$fifo" - echo "BEGIN EXCLUSIVE;" >&8 - # No fixed sleep budget: poll until the lock is actually held (a second, - # independent probe blocks), so this test does not race the holder's - # own startup time. - local waited=0 - while sqlite3 -cmd "PRAGMA busy_timeout=0;" "$TEST_SKILL_DIR/run/install-op.lock.db" "BEGIN EXCLUSIVE; ROLLBACK;" >/dev/null 2>&1; do - waited=$((waited + 1)) - [ "$waited" -lt 100 ] || break - sleep 0.05 - done + exec 9< "$ack_fifo" + # A second BEGIN EXCLUSIVE probe can win the race and make the holder's + # own BEGIN fail. Ask the holder itself to acknowledge acquisition, and + # bail on SQL errors so a failed BEGIN cannot print a false success. + printf '%s\n' '.bail on' 'BEGIN EXCLUSIVE;' '.print LOCKED' >&8 + local acquired="" + if ! IFS= read -r -t 5 acquired <&9 || [ "$acquired" != LOCKED ]; then + exec 8>&- + exec 9<&- + kill "$holder_pid" 2>/dev/null || true + wait "$holder_pid" 2>/dev/null || true + echo 'lock holder did not acknowledge acquisition' >&2 + false + fi run bash "$LAUNCH" - [ "$status" -eq 75 ] - - echo "ROLLBACK;" >&8 + printf '%s\n' 'ROLLBACK;' '.quit' >&8 exec 8>&- - wait "$holder_pid" 2>/dev/null || true + exec 9<&- + wait "$holder_pid" + [ "$status" -eq 75 ] + printf '%s\n' "$output" | grep -Fq 'an install/uninstall is in progress' + [ "$(sqlite3 "$SKILLDIR_INSTALL_DB" 'SELECT count(*) FROM daemon_start_attempts;')" -eq 0 ] } @test "agmsgd-launch: a crashed update (.prev present, lock free) -> exits 0, records a start attempt" { From d01c32971ff7b4e1c76bbee52d8eb211968f0d2e Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 1 Oct 2026 16:34:37 -0700 Subject: [PATCH 12/97] feat: allow the measured Windows Codex queue release (#1548) ## Summary Permit new Windows queue operations only when a native codex.exe resolves and that executable reports codex-cli 0.157.0. This release has measured evidence that native queue creates no descendants and that its notice reaches a logged-in live conversation. Missing executables, unreadable version output, and every unlisted version remain blocked with a reason. The version probe has a two-second timeout and bounded output. Queue launches use the resolved native path directly, including the measured npm global installation layout, rather than its JavaScript or shell shim. Apply the same gate while reconciling pending Windows operations, preserving confirmation and expiry observations even when a changed or unavailable executable blocks new delivery. Add an anonymized, measured Windows response_item fixture from 2026-10-01, preserving field types and positions, including metadata.client_authored=false and metadata.user_input_order. One regression verifies that the fixture's user input nonce is observed as present. ## Validation - `node --test tests/agmsgd_codex_queue.test.mjs`: 9/9 PASS. - `bats tests/test_agmsgd_codex_queue.bats`: 1/1 PASS (runs the Node support suite). - `git diff --check`: PASS. - Tests cover executable resolution, the measured npm layout, unlisted and unreadable versions, native executable forwarding, pending-to-confirmed Windows delivery, and preventing duplicate nudges for a confirmed snapshot. Validation ran on macOS with disposable profiles and mocked native version/queue processes. It did not launch a live Windows service or modify a user queue. The measured rollout fixture and native path layout were supplied from the existing Windows live-delivery measurement. Targets integration/agmsgd-beta. Merge is handled by the integration coordinator. --- scripts/daemon/channels/codex-queue.mjs | 30 ++++--- .../daemon/channels/windows-codex-queue.mjs | 55 ++++++++++++ tests/agmsgd_codex_queue.test.mjs | 83 ++++++++++++++++++- ...codex-windows-0.157.0-queue-response.jsonl | 1 + 4 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 scripts/daemon/channels/windows-codex-queue.mjs create mode 100644 tests/fixtures/codex-windows-0.157.0-queue-response.jsonl diff --git a/scripts/daemon/channels/codex-queue.mjs b/scripts/daemon/channels/codex-queue.mjs index 569fb7c7a..2e47700ea 100644 --- a/scripts/daemon/channels/codex-queue.mjs +++ b/scripts/daemon/channels/codex-queue.mjs @@ -26,6 +26,7 @@ import { } from "./codex-queue-io.mjs"; import { captureProcessGroup, observeProcessGroup } from "./process-group.mjs"; import { processStartWitness } from "./process-group.mjs"; +import { checkWindowsCodexQueueGate } from "./windows-codex-queue.mjs"; const QUEUE_TIMEOUT_MS = 10_000; @@ -278,6 +279,7 @@ export function createCodexQueueChannel({ captureGroup = captureProcessGroup, observeGroup = observeProcessGroup, hostPlatform = process.platform, + windowsGate = checkWindowsCodexQueueGate, }) { let stopped = false; let activeController = null; @@ -299,13 +301,16 @@ export function createCodexQueueChannel({ return; } if (hostPlatform === "win32") { - if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); - else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); - const reason = observation.state === "pending" - ? `windows_live_delivery_unverified;pending:${observation.reason}` - : "windows_live_delivery_unverified"; - saveSeat(db, { seat, thread: pending.thread, codexHome: pending.codex_home, state: "blocked", reason }, now); - return; + const gate = windowsGate({ executable, env }); + if (gate.state !== "ok") { + if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); + else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); + const reason = observation.state === "pending" + ? `${gate.reason};pending:${observation.reason}` + : gate.reason; + saveSeat(db, { seat, thread: pending.thread, codexHome: pending.codex_home, state: "blocked", reason }, now); + return; + } } if (observation.state === "confirmed") { setQueueState(db, pending.id, "confirmed"); @@ -349,9 +354,14 @@ export function createCodexQueueChannel({ saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "role_session_type_mismatch" }, now); return; } + let queueExecutable = executable; if (hostPlatform === "win32") { - saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "windows_live_delivery_unverified" }, now); - return; + const gate = windowsGate({ executable, env }); + if (gate.state !== "ok") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: gate.reason }, now); + return; + } + queueExecutable = gate.executable; } let driver; try { @@ -426,7 +436,7 @@ export function createCodexQueueChannel({ let result; try { result = await queue({ - executable, + executable: queueExecutable, codexHome: record.codex_home, thread: record.thread, message: inboxNudge(nonce), diff --git a/scripts/daemon/channels/windows-codex-queue.mjs b/scripts/daemon/channels/windows-codex-queue.mjs new file mode 100644 index 000000000..1e264ba51 --- /dev/null +++ b/scripts/daemon/channels/windows-codex-queue.mjs @@ -0,0 +1,55 @@ +// Each listed release was measured on Windows: native queue spawns no +// children, and its nonce reaches a logged-in live conversation's rollout. +import { execFileSync } from "node:child_process"; +import { statSync } from "node:fs"; +import { basename, isAbsolute, join, resolve } from "node:path"; + +// Native-child measurement: 2026-09-28; live delivery/rollout: 2026-10-01. +const VERIFIED_WINDOWS_QUEUE_VERSIONS = new Set(["0.157.0"]); + +export function resolveWindowsCodexExe(executable, env = process.env, arch = process.arch) { + if (typeof executable !== "string" || !executable) return null; + const pathEntry = Object.entries(env).find(([key]) => key.toLowerCase() === "path")?.[1] ?? ""; + const candidates = []; + if (isAbsolute(executable)) { + candidates.push(executable); + } else if (executable === "codex" || executable.toLowerCase() === "codex.exe") { + for (const dir of pathEntry.split(";").filter(Boolean)) { + candidates.push(join(dir, "codex.exe")); + const target = { x64: "x86_64-pc-windows-msvc", arm64: "aarch64-pc-windows-msvc" }[arch]; + if (target) { + // npm's PATH entry is a JS/shell shim. Resolve its native package + // directly; never queue through that shim and its extra processes. + const packageDir = join(dir, "node_modules", "@openai", "codex", "node_modules", "@openai", `codex-win32-${arch}`); + candidates.push(join(packageDir, "vendor", target, "bin", "codex.exe")); + } + } + } + for (const candidate of candidates) { + if (basename(candidate).toLowerCase() !== "codex.exe") continue; + try { + if (statSync(candidate).isFile()) return resolve(candidate); + } catch { /* A missing candidate is not a resolved native executable. */ } + } + return null; +} + +export function checkWindowsCodexQueueGate({ executable, env = process.env, resolveExe = resolveWindowsCodexExe, probe = execFileSync }) { + const nativeExe = resolveExe(executable, env); + if (!nativeExe) return { state: "blocked", reason: "windows_codex_executable_unresolved" }; + let output; + try { + output = probe(nativeExe, ["--version"], { + env, encoding: "utf8", windowsHide: true, timeout: 2000, maxBuffer: 16 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + return { state: "blocked", reason: "windows_codex_version_unreadable" }; + } + const version = /^codex-cli (\d+\.\d+\.\d+)\s*$/.exec(output)?.[1]; + if (!version) return { state: "blocked", reason: "windows_codex_version_unreadable" }; + if (!VERIFIED_WINDOWS_QUEUE_VERSIONS.has(version)) { + return { state: "blocked", reason: `windows_codex_version_unverified:${version}` }; + } + return { state: "ok", executable: nativeExe, version }; +} diff --git a/tests/agmsgd_codex_queue.test.mjs b/tests/agmsgd_codex_queue.test.mjs index 99ceafbec..1add2df27 100644 --- a/tests/agmsgd_codex_queue.test.mjs +++ b/tests/agmsgd_codex_queue.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; -import { mkdtempSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; @@ -31,6 +31,7 @@ import { createCodexQueueChannel } from '../scripts/daemon/channels/codex-queue. import { openInstallDb } from '../scripts/daemon/db.mjs'; import { readOwnerAndIntentReadOnly } from '../scripts/daemon/status.mjs'; import { processStartWitness } from '../scripts/daemon/channels/process-group.mjs'; +import { checkWindowsCodexQueueGate, resolveWindowsCodexExe } from '../scripts/daemon/channels/windows-codex-queue.mjs'; const thread = '01a0ea97-c011-73f3-8470-fd59db15adba'; const itemId = '01a0ea98-2235-7f02-b477-7c130e602fcd'; @@ -144,6 +145,16 @@ test('queue DB and rollout observations distinguish positive, absent, and unread assert.equal((await readRolloutNonce(home, thread, nonce)).state, 'unreadable'); }); +test('measured Windows 0.157.0 queued user response proves the rollout nonce is present', async (t) => { + const home = temporaryDirectory(t); + const sessions = path.join(home, 'sessions'); + mkdirSync(sessions); + // Anonymized measured row; field positions and types are preserved. + const row = readFileSync(new URL('./fixtures/codex-windows-0.157.0-queue-response.jsonl', import.meta.url), 'utf8'); + writeFileSync(path.join(sessions, `rollout-fixture-${thread}.jsonl`), JSON.stringify({ type: 'session_meta', payload: { id: thread } }) + '\n' + row); + assert.deepEqual(await readRolloutNonce(home, thread, 'NONCE'), { state: 'present' }); +}); + test('pending delivery confirms on either positive witness, expires only on two readable absences, and otherwise stays pending', () => { assert.deepEqual(resolvePendingQueue({ queueObservation: { state: 'present' }, @@ -207,6 +218,37 @@ test('seat classification never treats an uncertain destination or bridge as add assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped' }), { state: 'blocked', reason: 'thread_observation_failed' }); }); +test('Windows queue requires a native executable and an exactly measured release', (t) => { + const root = temporaryDirectory(t); + const nativeExe = path.join(root, 'codex.exe'); + writeFileSync(nativeExe, 'fixture, never executed'); + const env = { PATH: root }; + assert.equal(resolveWindowsCodexExe('codex', env), nativeExe); + assert.equal(resolveWindowsCodexExe(nativeExe, env), nativeExe); + assert.equal(resolveWindowsCodexExe(path.join(root, 'codex.cmd'), env), null); + assert.equal(resolveWindowsCodexExe('codex', { PATH: path.join(root, 'missing') }), null); + const npmRoot = path.join(root, 'npm'); + const vendor = path.join(npmRoot, 'node_modules', '@openai', 'codex', 'node_modules', '@openai', 'codex-win32-x64', 'vendor', 'x86_64-pc-windows-msvc', 'bin'); + mkdirSync(vendor, { recursive: true }); + writeFileSync(path.join(npmRoot, 'codex.cmd'), 'fixture shim, never executed'); + writeFileSync(path.join(vendor, 'codex.exe'), 'fixture native binary, never executed'); + assert.equal(resolveWindowsCodexExe('codex', { Path: npmRoot }, 'x64'), path.join(vendor, 'codex.exe')); + const probe = (file, args, options) => { + assert.equal(file, nativeExe); + assert.deepEqual(args, ['--version']); + assert.equal(options.timeout, 2000); + return 'codex-cli 0.157.0\n'; + }; + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe }), { state: 'ok', executable: nativeExe, version: '0.157.0' }); + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env: {} }), { state: 'blocked', reason: 'windows_codex_executable_unresolved' }); + for (const version of ['0.156.0', '0.158.0']) { + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe: () => `codex-cli ${version}\n` }), { state: 'blocked', reason: `windows_codex_version_unverified:${version}` }); + } + for (const probe of [() => 'codex-cli 0.157.0-beta\n', () => { throw new Error('timeout'); }]) { + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe }), { state: 'blocked', reason: 'windows_codex_version_unreadable' }); + } +}); + test('store discovery is fail-closed and unread snapshots advance both event and legacy cursors', async (t) => { const root = temporaryDirectory(t); const teamsDir = path.join(root, 'teams'); @@ -344,8 +386,11 @@ test('Codex channel queues one unread snapshot and confirms it before advancing expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir }, hostPlatform: 'win32', + windowsGate: () => ({ state: 'blocked', reason: 'windows_codex_version_unverified:0.158.0' }), queue: async () => { throw new Error('Windows queue must stay closed until live delivery is verified'); }, }); + await windowsChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).reason, 'windows_codex_version_unverified:0.158.0'); installDb.prepare(` INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) @@ -353,7 +398,7 @@ test('Codex channel queues one unread snapshot and confirms it before advancing await windowsChannel.pollOnce(); const windowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); assert.equal(windowsSeat.state, 'blocked'); - assert.equal(windowsSeat.reason, 'windows_live_delivery_unverified;pending:awaiting_confirmation'); + assert.equal(windowsSeat.reason, 'windows_codex_version_unverified:0.158.0;pending:awaiting_confirmation'); assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); const windowsQueueDb = new DatabaseSync(path.join(codexHome, 'queue_1.sqlite')); windowsQueueDb.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run(notYetVisibleItemId, thread, '{}'); @@ -361,10 +406,42 @@ test('Codex channel queues one unread snapshot and confirms it before advancing await windowsChannel.pollOnce(); const confirmedWindowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); assert.equal(confirmedWindowsSeat.state, 'blocked'); - assert.equal(confirmedWindowsSeat.reason, 'windows_live_delivery_unverified'); + assert.equal(confirmedWindowsSeat.reason, 'windows_codex_version_unverified:0.158.0'); assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); await windowsChannel.stop(); + const nextMessageDb = new DatabaseSync(path.join(storageDir, 'messages.db')); + nextMessageDb.prepare('INSERT INTO events VALUES (2, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run('message_sent', 'event-2', 'alpha', 'sender', 'alice', 'next unread message', null, null, '2026-10-01T00:00:00Z', null); + nextMessageDb.close(); + const nativeExe = path.join(root, 'codex.exe'); + writeFileSync(nativeExe, 'native fixture, never executed'); + let windowsQueues = 0; + let windowsChildState = 'present'; + const verifiedWindowsChannel = createCodexQueueChannel({ + db: installDb, installRoot: root, expectedOpGen: 4, hostPlatform: 'win32', + env: { AGMSG_STORAGE_PATH: storageDir, PATH: root }, + windowsGate: (options) => checkWindowsCodexQueueGate({ ...options, probe: () => 'codex-cli 0.157.0\n' }), + captureGroup: (pid) => ({ state: 'complete', kind: 'windows-native-process', pgid: pid, boot_id: 'test-boot', witness: 'test-start' }), + observeGroup: () => ({ state: windowsChildState }), + queue: async (request) => { + windowsQueues += 1; + assert.equal(request.executable, nativeExe, 'queue bypasses the npm shim'); + request.onChildStart(4242); + return { kind: 'queued', queueItemId: itemId }; + }, + }); + await verifiedWindowsChannel.pollOnce(); + assert.equal(windowsQueues, 1); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'addressable'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); + windowsChildState = 'absent'; + await verifiedWindowsChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); + await verifiedWindowsChannel.pollOnce(); + assert.equal(windowsQueues, 1, 'a confirmed Windows snapshot is not requeued'); + await verifiedWindowsChannel.stop(); + writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ agents: {} })); const missingSeatChannel = createCodexQueueChannel({ db: installDb, installRoot: root, expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir } }); await missingSeatChannel.pollOnce(); diff --git a/tests/fixtures/codex-windows-0.157.0-queue-response.jsonl b/tests/fixtures/codex-windows-0.157.0-queue-response.jsonl new file mode 100644 index 000000000..7e0a146e0 --- /dev/null +++ b/tests/fixtures/codex-windows-0.157.0-queue-response.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-10-01T22:04:30.973Z","ordinal":21,"type":"response_item","payload":{"type":"message","id":"msg_00000000-0000-0000-0000-000000000001","role":"user","content":[{"type":"input_text","text":"[agmsg:NONCE] New messages are waiting. Check your agmsg inbox."}],"internal_chat_message_metadata_passthrough":{"turn_id":"00000000-0000-0000-0000-000000000002","create_time":1790892270.9732182,"content_item_kinds":["user.text"]}},"metadata":{"client_authored":false,"user_input_order":2,"mcp_attribution":{"status":"none"}}} From 6581135db79def6b69073a86133701a3e56a54e5 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 5 Oct 2026 00:19:22 -0700 Subject: [PATCH 13/97] Allow Windows Codex queue from the measured minimum release (#1573) --- README.md | 2 +- .../daemon/channels/windows-codex-queue.mjs | 16 ++++++----- tests/agmsgd_codex_queue.test.mjs | 27 ++++++++++++++----- ...codex-windows-0.160.0-queue-response.jsonl | 1 + 4 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/codex-windows-0.160.0-queue-response.jsonl diff --git a/README.md b/README.md index 062dead97..19fde30c8 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Git Bash PATH). There is no PowerShell reimplementation. ### agmsgd beta (1.6.0) -The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, replace other agents' monitors, or take over remote sync. macOS and Linux are supported. Windows delivery is unsupported until it has been measured on a real Windows session. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. +The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, replace other agents' monitors, or take over remote sync. macOS, Linux, and Windows are supported. Windows requires native Codex 0.157.0 or later; earlier versions are blocked with a reason. Native queue execution and delivery to a logged-in live Windows session were measured with Codex 0.157.0 and 0.160.0. Unreadable version or delivery evidence keeps the existing conservative handling. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. Requires Node >= 22.13.0 with `node:sqlite` (the experimental SQLite warning is expected). Install Node from [nodejs.org](https://nodejs.org/en/download). Enable and inspect the beta with: diff --git a/scripts/daemon/channels/windows-codex-queue.mjs b/scripts/daemon/channels/windows-codex-queue.mjs index 1e264ba51..c29543c66 100644 --- a/scripts/daemon/channels/windows-codex-queue.mjs +++ b/scripts/daemon/channels/windows-codex-queue.mjs @@ -1,11 +1,12 @@ -// Each listed release was measured on Windows: native queue spawns no -// children, and its nonce reaches a logged-in live conversation's rollout. +// Windows native queue compatibility has been measured at the minimum +// supported release and at 0.160.0; later stable releases use the same policy. import { execFileSync } from "node:child_process"; import { statSync } from "node:fs"; import { basename, isAbsolute, join, resolve } from "node:path"; -// Native-child measurement: 2026-09-28; live delivery/rollout: 2026-10-01. -const VERIFIED_WINDOWS_QUEUE_VERSIONS = new Set(["0.157.0"]); +// 0.157.0: native-child measurement 2026-09-28, live rollout 2026-10-01. +// 0.160.0: native-child observations and live rollout 2026-10-05. +const MINIMUM_WINDOWS_QUEUE_VERSION = [0, 157, 0]; export function resolveWindowsCodexExe(executable, env = process.env, arch = process.arch) { if (typeof executable !== "string" || !executable) return null; @@ -48,8 +49,11 @@ export function checkWindowsCodexQueueGate({ executable, env = process.env, reso } const version = /^codex-cli (\d+\.\d+\.\d+)\s*$/.exec(output)?.[1]; if (!version) return { state: "blocked", reason: "windows_codex_version_unreadable" }; - if (!VERIFIED_WINDOWS_QUEUE_VERSIONS.has(version)) { - return { state: "blocked", reason: `windows_codex_version_unverified:${version}` }; + const parts = version.split(".").map(BigInt); + const minimum = MINIMUM_WINDOWS_QUEUE_VERSION.map(BigInt); + const firstDifference = parts.findIndex((part, index) => part !== minimum[index]); + if (firstDifference !== -1 && parts[firstDifference] < minimum[firstDifference]) { + return { state: "blocked", reason: `windows_codex_version_below_minimum:${version}` }; } return { state: "ok", executable: nativeExe, version }; } diff --git a/tests/agmsgd_codex_queue.test.mjs b/tests/agmsgd_codex_queue.test.mjs index 1add2df27..6327b7892 100644 --- a/tests/agmsgd_codex_queue.test.mjs +++ b/tests/agmsgd_codex_queue.test.mjs @@ -155,6 +155,16 @@ test('measured Windows 0.157.0 queued user response proves the rollout nonce is assert.deepEqual(await readRolloutNonce(home, thread, 'NONCE'), { state: 'present' }); }); +test('measured Windows 0.160.0 queued user response proves the rollout nonce is present', async (t) => { + const home = temporaryDirectory(t); + const sessions = path.join(home, 'sessions'); + mkdirSync(sessions); + // Anonymized measured row; field positions and types are preserved. + const row = readFileSync(new URL('./fixtures/codex-windows-0.160.0-queue-response.jsonl', import.meta.url), 'utf8'); + writeFileSync(path.join(sessions, `rollout-fixture-${thread}.jsonl`), JSON.stringify({ type: 'session_meta', payload: { id: thread } }) + '\n' + row); + assert.deepEqual(await readRolloutNonce(home, thread, 'NONCE'), { state: 'present' }); +}); + test('pending delivery confirms on either positive witness, expires only on two readable absences, and otherwise stays pending', () => { assert.deepEqual(resolvePendingQueue({ queueObservation: { state: 'present' }, @@ -218,7 +228,7 @@ test('seat classification never treats an uncertain destination or bridge as add assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped' }), { state: 'blocked', reason: 'thread_observation_failed' }); }); -test('Windows queue requires a native executable and an exactly measured release', (t) => { +test('Windows queue requires a native executable and a stable release at or above the measured minimum', (t) => { const root = temporaryDirectory(t); const nativeExe = path.join(root, 'codex.exe'); writeFileSync(nativeExe, 'fixture, never executed'); @@ -241,8 +251,11 @@ test('Windows queue requires a native executable and an exactly measured release }; assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe }), { state: 'ok', executable: nativeExe, version: '0.157.0' }); assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env: {} }), { state: 'blocked', reason: 'windows_codex_executable_unresolved' }); - for (const version of ['0.156.0', '0.158.0']) { - assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe: () => `codex-cli ${version}\n` }), { state: 'blocked', reason: `windows_codex_version_unverified:${version}` }); + for (const version of ['0.0.999', '0.156.999']) { + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe: () => `codex-cli ${version}\n` }), { state: 'blocked', reason: `windows_codex_version_below_minimum:${version}` }); + } + for (const version of ['0.157.0', '0.158.0', '0.160.0', '0.1000.0', '1.0.0']) { + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe: () => `codex-cli ${version}\n` }), { state: 'ok', executable: nativeExe, version }); } for (const probe of [() => 'codex-cli 0.157.0-beta\n', () => { throw new Error('timeout'); }]) { assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe }), { state: 'blocked', reason: 'windows_codex_version_unreadable' }); @@ -386,11 +399,11 @@ test('Codex channel queues one unread snapshot and confirms it before advancing expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir }, hostPlatform: 'win32', - windowsGate: () => ({ state: 'blocked', reason: 'windows_codex_version_unverified:0.158.0' }), + windowsGate: () => ({ state: 'blocked', reason: 'windows_codex_version_below_minimum:0.156.0' }), queue: async () => { throw new Error('Windows queue must stay closed until live delivery is verified'); }, }); await windowsChannel.pollOnce(); - assert.equal(installDb.prepare("SELECT reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).reason, 'windows_codex_version_unverified:0.158.0'); + assert.equal(installDb.prepare("SELECT reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).reason, 'windows_codex_version_below_minimum:0.156.0'); installDb.prepare(` INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) @@ -398,7 +411,7 @@ test('Codex channel queues one unread snapshot and confirms it before advancing await windowsChannel.pollOnce(); const windowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); assert.equal(windowsSeat.state, 'blocked'); - assert.equal(windowsSeat.reason, 'windows_codex_version_unverified:0.158.0;pending:awaiting_confirmation'); + assert.equal(windowsSeat.reason, 'windows_codex_version_below_minimum:0.156.0;pending:awaiting_confirmation'); assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); const windowsQueueDb = new DatabaseSync(path.join(codexHome, 'queue_1.sqlite')); windowsQueueDb.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run(notYetVisibleItemId, thread, '{}'); @@ -406,7 +419,7 @@ test('Codex channel queues one unread snapshot and confirms it before advancing await windowsChannel.pollOnce(); const confirmedWindowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); assert.equal(confirmedWindowsSeat.state, 'blocked'); - assert.equal(confirmedWindowsSeat.reason, 'windows_codex_version_unverified:0.158.0'); + assert.equal(confirmedWindowsSeat.reason, 'windows_codex_version_below_minimum:0.156.0'); assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); await windowsChannel.stop(); diff --git a/tests/fixtures/codex-windows-0.160.0-queue-response.jsonl b/tests/fixtures/codex-windows-0.160.0-queue-response.jsonl new file mode 100644 index 000000000..74353b89b --- /dev/null +++ b/tests/fixtures/codex-windows-0.160.0-queue-response.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-10-05T06:43:19.582Z","ordinal":29,"type":"response_item","payload":{"type":"message","id":"msg_00000000-0000-0000-0000-000000000001","role":"user","content":[{"type":"input_text","text":"[agmsg:NONCE] New messages are waiting. Check your agmsg inbox."}],"internal_chat_message_metadata_passthrough":{"turn_id":"00000000-0000-0000-0000-000000000002","create_time":1791182599.5823665,"content_item_kinds":["user.text"]}},"metadata":{"retained_source":{"id":{"message_id":"msg_00000000-0000-0000-0000-000000000001","turn_id":"00000000-0000-0000-0000-000000000002","role":"user"},"revision":"retained_00000000-0000-0000-0000-000000000003","complete":true},"client_authored":false,"user_input_order":4,"mcp_attribution":{"status":"none"}}} From 81433b8f589108c9b2a4e0e780eea473e4108ac3 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 5 Oct 2026 03:48:53 -0700 Subject: [PATCH 14/97] fix: skip shared launcher for custom command installs (#1578) --- install.sh | 4 ++-- scripts/lib/agmsg-launcher.sh | 11 +++++++++-- tests/test_agmsg_command.bats | 10 ++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index f31b3cd2d..e6e986098 100755 --- a/install.sh +++ b/install.sh @@ -1179,7 +1179,7 @@ $_agmsg_running_team" fi install_windows_helpers || exit 1 agmsg_install_op_phase_end || exit 1 - agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" || exit 1 + agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" "$CMD_NAME" || exit 1 agmsg_install_op_run_phase agmsg_install_write_version || exit 1 echo " + updated scripts, templates, and SKILL.md (version $INSTALLED_VERSION)" echo " ~ DB and team configs preserved" @@ -1315,7 +1315,7 @@ install_windows_helpers || exit 1 agmsg_install_op_phase_end || exit 1 # The `agmsg` command launcher (never touches shell rc files or PATH). -agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" || exit 1 +agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" "$CMD_NAME" || exit 1 # Marker file for uninstall detection and source provenance version are one # protected phase so neither write can occur after a lost-lock boundary. diff --git a/scripts/lib/agmsg-launcher.sh b/scripts/lib/agmsg-launcher.sh index d5d81a817..180690409 100644 --- a/scripts/lib/agmsg-launcher.sh +++ b/scripts/lib/agmsg-launcher.sh @@ -60,13 +60,20 @@ agmsg_launcher_classify() { fi } -# Places the launcher for the install at $1 and prints a three-part report: +# Places the launcher for the install at $1 and command name $2 (default +# agmsg for existing callers). Custom skill names do not claim the shared +# shell command. Prints a three-part report: # what was placed, whether this process can see it, and what to check on a # real terminal. Always returns 0: not placing a launcher is never an install # failure. agmsg_launcher_install() { - local skill_dir="$1" target tmp resolved old + local skill_dir="$1" cmd_name="${2:-agmsg}" target tmp resolved old AGMSG_LAUNCHER_REASON="" + if [ "$cmd_name" != agmsg ]; then + echo " ~ agmsg command: not placed (custom command name: $cmd_name; shared launcher is only for --cmd agmsg)" + printf ' use this installation directly: bash %q daemon status\n' "$skill_dir/scripts/agmsg" + return 0 + fi if ! agmsg_launcher_pick_dir; then echo " ~ agmsg command: not placed ($AGMSG_LAUNCHER_REASON)" return 0 diff --git a/tests/test_agmsg_command.bats b/tests/test_agmsg_command.bats index f7ff1144d..b372e8b37 100644 --- a/tests/test_agmsg_command.bats +++ b/tests/test_agmsg_command.bats @@ -64,6 +64,16 @@ teardown() { [ -d "$BIN_DIR" ] } +@test "launcher: a custom command does not claim an empty shared bin directory" { + run bash -c 'source "$1"; agmsg_launcher_install "$2" agmsg-e2e' _ "$LIB" "$TEST_SKILL_DIR" + [ "$status" -eq 0 ] + grep -Fq -- 'custom command name: agmsg-e2e' <<<"$output" + grep -Fq -- "$TEST_SKILL_DIR/scripts/agmsg" <<<"$output" + [ ! -e "$BIN_DIR/agmsg" ] + [ ! -e "$BIN_DIR/agmsg-e2e" ] + [ ! -e "$TEST_SKILL_DIR/run/agmsg-launcher.path" ] +} + # The four kinds of thing already sitting at the target: nothing is broken, # nothing is followed, and the message says why. @test "launcher: a valid symlink, a broken symlink, a directory and a foreign file are all left alone" { From 5cd0f6de48c9fbeb12baab28d1f5a71420dc4ac1 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 5 Oct 2026 03:51:14 -0700 Subject: [PATCH 15/97] feat(agmsgd): deliver inbox notices to Claude Code seats through the session socket (#1577) agmsgd can now deliver the inbox notice to Claude Code seats, through the session's own cross-session messaging socket (CLAUDE_CODE_MESSAGING_SOCKET), instead of the Monitor watch. macOS and Linux only; Windows seats keep the Monitor watch. - SessionStart records messaging_socket, claude_config_dir and session in the role-session record, only for the pair this session resumes or acts as, and only after the instance id (sid.pid) matches the actas lock owner exactly. The record is rewritten on every start, since --resume binds a new socket and /clear starts a new session id. - SessionStart skips the Monitor directive only when the daemon is ready, the platform is not Windows, and the record was written and read back. In every other case it emits the Monitor directive as before. A resume re-claims the lock through actas_lock_claim, which never takes a live owner's lock. - The daemon checks the socket's owner uid and type before sending one `[agmsg:]` notice line, then confirms delivery only from the session transcript's user line carrying the nonce. A held banner is recorded as held. A missing transcript expires after 60 seconds with its reason; a transient read failure stays pending. - `agmsg daemon status` lists claude_code_seats. --- .../daemon/channels/claude-code-queue-io.mjs | 198 +++++++++++ .../channels/claude-code-queue-store.mjs | 122 +++++++ scripts/daemon/channels/claude-code-queue.mjs | 317 ++++++++++++++++++ scripts/daemon/channels/codex-queue.mjs | 4 +- scripts/daemon/main.mjs | 4 + scripts/daemon/status.mjs | 18 +- .../types/claude-code/_session-start.sh | 120 +++++++ scripts/lib/role-session.sh | 49 +++ tests/agmsgd_claude_code_queue.test.mjs | 81 +++++ tests/test_role_session.bats | 29 ++ ...sion_start_claude_code_native_channel.bats | 195 +++++++++++ 11 files changed, 1131 insertions(+), 6 deletions(-) create mode 100644 scripts/daemon/channels/claude-code-queue-io.mjs create mode 100644 scripts/daemon/channels/claude-code-queue-store.mjs create mode 100644 scripts/daemon/channels/claude-code-queue.mjs create mode 100644 scripts/drivers/types/claude-code/_session-start.sh create mode 100644 tests/agmsgd_claude_code_queue.test.mjs create mode 100644 tests/test_session_start_claude_code_native_channel.bats diff --git a/scripts/daemon/channels/claude-code-queue-io.mjs b/scripts/daemon/channels/claude-code-queue-io.mjs new file mode 100644 index 000000000..d7c446b3d --- /dev/null +++ b/scripts/daemon/channels/claude-code-queue-io.mjs @@ -0,0 +1,198 @@ +// Claude Code native-channel I/O: writes one line to a seat's own +// cross-session-messaging socket (https://code.claude.com/docs/en/cross-session-messaging) +// and verifies delivery by reading the seat's own transcript file. Measured +// 2026-10-05, memory/design/2026-10-05-cross-session-messaging-socket-measurement.md +// (and its addendum) and memory/design/2026-09-22-agmsgd-arch-8-delivery-driver.md +// §12/§12.1 — this file's shape follows directly from those measurements, not +// from the (partly inaccurate, per the same memo) public docs alone. +// +// This file knows only the Claude Code peer-socket wire format and transcript +// layout; the daemon loop and its install.db schema remain owned by the caller +// (claude-code-queue.mjs), matching codex-queue-io.mjs's split. + +import { randomUUID } from "node:crypto"; +import { createConnection } from "node:net"; +import { closeSync, lstatSync, openSync, readFileSync, readSync, statSync } from "node:fs"; +import process from "node:process"; +import { isAbsolute } from "node:path"; + +export { newNonce, inboxNudge, QUEUE_CONFIRMATION_TTL_MS } from "./codex-queue-io.mjs"; + +const SEND_TIMEOUT_MS = 5_000; +// Bound the transcript read to its tail: a long-lived seat's jsonl grows +// without limit, and the delivery record this channel looks for is always +// recent (written within the TTL window of a message this same daemon just +// sent). 512 KiB comfortably covers many turns' worth of lines. +const TRANSCRIPT_TAIL_BYTES = 512 * 1024; + +// Measured 2026-10-05: Claude Code encodes a project's cwd into its +// transcript directory name by replacing every "/" with "-" +// (e.g. /home/user/projects/example/sub -> -home-user-projects-example-sub). +// Only this simple case was measured; a cwd containing a literal "-" was not +// tested separately and is not known to collide in practice, so this is not +// claimed exhaustive. +export function encodeClaudeProjectDir(cwd) { + if (typeof cwd !== "string" || !cwd) return ""; + return cwd.replace(/\//g, "-"); +} + +export function resolveTranscriptPath(claudeConfigDir, project, sessionId) { + if (typeof claudeConfigDir !== "string" || !isAbsolute(claudeConfigDir)) return ""; + if (typeof project !== "string" || !isAbsolute(project)) return ""; + if (typeof sessionId !== "string" || !sessionId) return ""; + const encoded = encodeClaudeProjectDir(project); + if (!encoded) return ""; + return `${claudeConfigDir}/projects/${encoded}/${sessionId}.jsonl`; +} + +// Refuses to write unless the path is a Unix domain socket owned by this +// process's own OS user: a socket path read from an untrusted or stale +// record must never be written to on trust alone. A symlinked path is +// refused the same way every other agmsg state-file reader on this codebase +// refuses one. +function checkSocketOwnership(socketPath) { + let stat; + try { + stat = lstatSync(socketPath); + } catch (error) { + if (error?.code === "ENOENT") return { state: "unreadable", reason: "messaging_socket_missing" }; + return { state: "unreadable", reason: "messaging_socket_stat_failed" }; + } + if (stat.isSymbolicLink()) return { state: "unreadable", reason: "messaging_socket_symlink" }; + if (!stat.isSocket()) return { state: "unreadable", reason: "messaging_socket_not_a_socket" }; + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) { + return { state: "unreadable", reason: "messaging_socket_not_owned_by_self" }; + } + return { state: "ok" }; +} + +// Sends the one-line JSON cross-session-message the socket accepts (measured +// shape). `from` is a fixed, non-socket string on purpose: it must not look +// like a reply address, since this is a one-way nudge, not a peer +// conversation. +export async function sendClaudeCodeMessage({ + socketPath, + nonce, + body, + timeoutMs = SEND_TIMEOUT_MS, + checkOwnership = checkSocketOwnership, + connect = createConnection, +}) { + const ownership = checkOwnership(socketPath); + if (ownership.state !== "ok") return { state: "failed", reason: ownership.reason }; + + const payload = { + msgV: 1, + msg_id: randomUUID(), + type: "user", + message: { + role: "user", + content: `\n${body}\n`, + }, + priority: "next", + from: "agmsgd", + }; + const line = `${JSON.stringify(payload)}\n`; + + return new Promise((resolve) => { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { socket.destroy(); } catch { /* best effort */ } + resolve(result); + }; + const timer = setTimeout(() => finish({ state: "failed", reason: "send_timeout" }), timeoutMs); + let socket; + try { + socket = connect(socketPath); + } catch { + clearTimeout(timer); + resolve({ state: "failed", reason: "connect_failed" }); + return; + } + socket.on("error", (error) => finish({ state: "failed", reason: `socket_error:${error.code ?? error.message}` })); + socket.on("connect", () => { + socket.end(line, () => finish({ state: "sent", nonce, msgId: payload.msg_id })); + }); + }); +} + +function readTail(path, maxBytes) { + const stat = statSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("not_regular_file"); + if (stat.size <= maxBytes) return readFileSync(path, "utf8"); + const buf = Buffer.alloc(maxBytes); + const fd = openSync(path, "r"); + try { + readSync(fd, buf, 0, maxBytes, stat.size - maxBytes); + } finally { + closeSync(fd); + } + return buf.toString("utf8"); +} + +// Reads the delivery outcome straight from the receiving session's own +// transcript. Measured line shapes (2026-10-05 addendum): +// - delivered: a `type:"user"` line with `origin.body` containing the +// nonce verbatim — the ONLY line this treats as proof of delivery. +// - held/declined: a `type:"system"`, `subtype:"informational"` line whose +// `content` (a possibly-truncated preview) contains the nonce. +// - a `type:"queue-operation"`, `operation:"enqueue"` line fires on mere +// receipt, held or not — deliberately never read as evidence here (G4: +// never mark a seat notified on evidence that could be a hold). +export function observeTranscript(transcriptPath, nonce) { + if (typeof nonce !== "string" || !nonce) return { state: "unreadable", reason: "nonce_missing" }; + let text; + try { + text = readTail(transcriptPath, TRANSCRIPT_TAIL_BYTES); + } catch (error) { + if (error?.code === "ENOENT") return { state: "unreadable", reason: "transcript_missing" }; + return { state: "unreadable", reason: "transcript_read_failed" }; + } + const marker = `[agmsg:${nonce}]`; + let heldSeen = false; + for (const line of text.split("\n")) { + if (!line || !line.includes(marker)) continue; + let row; + try { + row = JSON.parse(line); + } catch { + continue; + } + if (row?.type === "user" && typeof row?.origin?.body === "string" && row.origin.body.includes(marker)) { + return { state: "delivered" }; + } + if (row?.type === "system" && row?.subtype === "informational" && typeof row?.content === "string" && row.content.includes(marker)) { + heldSeen = true; + } + } + if (heldSeen) return { state: "held" }; + return { state: "absent" }; +} + +// Single-observation analogue of codex-queue-io.mjs's resolvePendingQueue: +// this channel has only the transcript to read, not a separate queue-item +// check, so "present" collapses to one case (delivered) instead of two. +export function resolvePendingDelivery({ observation, ageMs, ttlMs }) { + if (observation?.state === "delivered") return { state: "confirmed", reason: "nonce_observed" }; + const ageKnown = Number.isSafeInteger(ageMs) && ageMs >= 0; + if (observation?.state === "unreadable") { + // A transcript that is genuinely absent past the TTL is never coming + // back on its own (the session that would create it is gone, or this + // record's project/session no longer matches a live one) -- expire it + // with a reason that says so, rather than polling it forever. Any OTHER + // unreadable cause (a transient read failure, a malformed line) stays + // pending indefinitely: that one really could resolve on the next read, + // and timing it out would report a seat undeliverable that might not be. + if (observation.reason === "transcript_missing" && ageKnown && ageMs >= ttlMs) { + return { state: "expired", reason: "transcript_missing" }; + } + return { state: "pending", reason: observation.reason ?? "transcript_unreadable" }; + } + if (!ageKnown) return { state: "pending", reason: "pending_age_unreadable" }; + const reason = observation?.state === "held" ? "peer_held" : "awaiting_confirmation"; + if (ageMs >= ttlMs) return { state: "expired", reason: observation?.state === "held" ? "confirmation_timeout:peer_held" : "confirmation_timeout" }; + return { state: "pending", reason }; +} diff --git a/scripts/daemon/channels/claude-code-queue-store.mjs b/scripts/daemon/channels/claude-code-queue-store.mjs new file mode 100644 index 000000000..1e304ae32 --- /dev/null +++ b/scripts/daemon/channels/claude-code-queue-store.mjs @@ -0,0 +1,122 @@ +// Read-only view of agmsg's existing message stores plus the daemon's small +// Claude Code native-channel tables. The daemon never initializes or repairs +// a team store. Mirrors codex-queue-store.mjs's shape; the generic store +// helpers (message store path/open/snapshot, storage driver) are reused +// directly from there rather than duplicated. + +import { lstatSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { roleSessionPath } from "./codex-queue-store.mjs"; + +export const CLAUDE_CODE_CHANNEL_SCHEMA = ` +CREATE TABLE IF NOT EXISTS beta_claude_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + seat TEXT NOT NULL, + messaging_socket TEXT NOT NULL, + transcript_path TEXT NOT NULL, + up_to TEXT NOT NULL, + nonce TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'confirmed', 'expired')), + created_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS beta_claude_queue_one_pending + ON beta_claude_queue(seat) WHERE state = 'pending'; +CREATE INDEX IF NOT EXISTS beta_claude_queue_latest + ON beta_claude_queue(seat, id DESC); +CREATE TABLE IF NOT EXISTS beta_claude_seat ( + seat TEXT PRIMARY KEY, + messaging_socket TEXT, + state TEXT NOT NULL CHECK (state IN ('addressable', 'unaddressable', 'blocked')), + reason TEXT NOT NULL, + checked_at TEXT NOT NULL +); +`; + +export function ensureClaudeCodeChannelSchema(db) { + db.exec("BEGIN IMMEDIATE;"); + try { + db.exec(CLAUDE_CODE_CHANNEL_SCHEMA); + db.exec("COMMIT;"); + } catch (error) { + try { db.exec("ROLLBACK;"); } catch { /* preserve the schema error */ } + throw error; + } +} + +// Same record file role-session.sh's agmsg_role_session_set_messaging writes +// messaging_socket/claude_config_dir into (#339 record, shared with Codex's +// codex_home field in the same file). A record missing either new field is +// reported present with an empty value — the caller treats that exactly like +// Codex's role_session_missing: no destination, not an error. +export function readClaudeRoleSession(runDir, team, agent) { + const path = roleSessionPath(runDir, team, agent); + let stat; + try { + stat = lstatSync(path); + } catch (error) { + if (error?.code === "ENOENT") return { state: "absent" }; + return { state: "unreadable", reason: "role_session_read_failed" }; + } + if (!stat.isFile() || stat.isSymbolicLink()) return { state: "unreadable", reason: "role_session_not_regular_file" }; + try { + const fields = Object.create(null); + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { + const at = line.indexOf("="); + if (at < 1) continue; + const key = line.slice(0, at); + if (!(key in fields)) fields[key] = line.slice(at + 1); + } + return { + state: "present", + record: { + team: fields.team ?? "", + agent: fields.agent ?? "", + type: fields.type ?? "", + project: fields.project ?? "", + session: fields.session ?? "", + messaging_socket: fields.messaging_socket ?? "", + claude_config_dir: fields.claude_config_dir ?? "", + }, + }; + } catch { + return { state: "unreadable", reason: "role_session_read_failed" }; + } +} + +// Enumerates only roles whose current registration explicitly names +// claude-code. Mirrors listCodexRegistrations in codex-queue-store.mjs +// exactly, filtered on a different registration type — small enough that +// sharing it would cost more indirection than it saves. +export function listClaudeCodeRegistrations(teamsDir) { + let entries; + try { + entries = readdirSync(teamsDir, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return { state: "ok", seats: [] }; + return { state: "unreadable", reason: "team_roster_unreadable" }; + } + const seats = []; + for (const entry of entries) { + if (entry.isSymbolicLink()) return { state: "unreadable", reason: "team_roster_symlink" }; + if (!entry.isDirectory()) continue; + let config; + try { + const stat = lstatSync(join(teamsDir, entry.name, "config.json")); + if (!stat.isFile() || stat.isSymbolicLink()) return { state: "unreadable", reason: "team_roster_symlink" }; + config = JSON.parse(readFileSync(join(teamsDir, entry.name, "config.json"), "utf8")); + } catch (error) { + if (error?.code === "ENOENT") continue; + return { state: "unreadable", reason: "team_roster_malformed" }; + } + if (!config || typeof config !== "object" || !config.agents || typeof config.agents !== "object" || Array.isArray(config.agents)) { + return { state: "unreadable", reason: "team_roster_malformed" }; + } + for (const [agent, value] of Object.entries(config.agents)) { + const regs = value && Array.isArray(value.registrations) ? value.registrations : []; + if (regs.some((registration) => registration?.type === "claude-code")) { + seats.push({ team: entry.name, agent, teamConfig: config }); + } + } + } + return { state: "ok", seats }; +} diff --git a/scripts/daemon/channels/claude-code-queue.mjs b/scripts/daemon/channels/claude-code-queue.mjs new file mode 100644 index 000000000..0dc2f6640 --- /dev/null +++ b/scripts/daemon/channels/claude-code-queue.mjs @@ -0,0 +1,317 @@ +// Polls the existing message stores and nudges registered Claude Code seats +// through their own cross-session-messaging socket. It never writes message +// data or read cursors. Mirrors codex-queue.mjs's shape; see +// memory/design/2026-09-22-agmsgd-arch-8-delivery-driver.md §12/§12.1 and +// memory/design/2026-10-05-cross-session-messaging-socket-measurement.md for +// the measurements this channel's gates come from. +// +// Deliberately simpler than the Codex channel: there is no CLI child process +// to spawn and observe, no bridge, and (per plan, 2026-10-05) no Windows +// support yet — a named-pipe + mandatory-auth-line variant is separate work. + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { logLine } from "../log.mjs"; +import { withImmediateTransaction } from "../db.mjs"; +import { + messageStorePath, + openMessageStore, + readStorageDriver, + readUnreadSnapshot, +} from "./codex-queue-store.mjs"; +import { sameProject, seatKey } from "./codex-queue.mjs"; +import { + listClaudeCodeRegistrations, + readClaudeRoleSession, +} from "./claude-code-queue-store.mjs"; +import { + QUEUE_CONFIRMATION_TTL_MS, + inboxNudge, + newNonce, + observeTranscript, + resolveTranscriptPath, + resolvePendingDelivery, + sendClaudeCodeMessage, +} from "./claude-code-queue-io.mjs"; + +const SEND_TIMEOUT_MS = 5_000; + +function registrationProjects(teamConfig, agent) { + const registrations = teamConfig?.agents?.[agent]?.registrations; + return Array.isArray(registrations) + ? registrations.filter((item) => item?.type === "claude-code").map((item) => item.project).filter((value) => typeof value === "string" && value) + : []; +} + +function readPending(db, seat) { + return db.prepare(` + SELECT id, messaging_socket, transcript_path, up_to, nonce, state, created_at + FROM beta_claude_queue WHERE seat = ? AND state = 'pending' ORDER BY id DESC LIMIT 1 + `).get(seat); +} + +function pendingSeats(db) { + const rows = db.prepare("SELECT DISTINCT seat FROM beta_claude_queue WHERE state = 'pending'").all(); + const seats = []; + for (const row of rows) { + try { + const pair = JSON.parse(row.seat); + if (!Array.isArray(pair) || pair.length !== 2) continue; + const [team, agent] = pair; + const validSegment = (value) => typeof value === "string" && value.length > 0 && value !== "." && value !== ".." && + !value.startsWith("-") && !/[\\/\u0000-\u001f\u007f]/.test(value); + if (validSegment(team) && validSegment(agent)) seats.push({ team, agent }); + } catch { /* malformed historical key cannot safely identify a seat */ } + } + return seats; +} + +function latestConfirmedCursor(db, seat) { + const row = db.prepare(` + SELECT up_to FROM beta_claude_queue WHERE seat = ? AND state = 'confirmed' + ORDER BY id DESC LIMIT 1 + `).get(seat); + return row?.up_to ?? ""; +} + +function saveSeat(db, { seat, messagingSocket = null, state, reason = "" }, now) { + withImmediateTransaction(db, () => { + db.prepare(` + INSERT INTO beta_claude_seat (seat, messaging_socket, state, reason, checked_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(seat) DO UPDATE SET messaging_socket=COALESCE(excluded.messaging_socket, beta_claude_seat.messaging_socket), + state=excluded.state, reason=excluded.reason, checked_at=excluded.checked_at + `).run(seat, messagingSocket, state, reason, new Date(now()).toISOString()); + }); +} + +function setQueueState(db, id, state) { + withImmediateTransaction(db, () => { + db.prepare("UPDATE beta_claude_queue SET state = ? WHERE id = ? AND state = 'pending'").run(state, id); + }); +} + +function createPending(db, { seat, messagingSocket, transcriptPath, upTo, nonce, expectedOpGen }, now) { + let rowId; + withImmediateTransaction(db, () => { + const intent = db.prepare("SELECT desired, op_gen FROM daemon_intent").get(); + if (intent?.desired !== "on" || intent.op_gen !== expectedOpGen) { + throw new Error("daemon_intent_changed"); + } + const current = db.prepare("SELECT id FROM beta_claude_queue WHERE seat = ? AND state = 'pending'").get(seat); + if (current) throw new Error("seat_already_pending"); + const result = db.prepare(` + INSERT INTO beta_claude_queue (seat, messaging_socket, transcript_path, up_to, nonce, state, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?) + `).run(seat, messagingSocket, transcriptPath, upTo, nonce, new Date(now()).toISOString()); + rowId = Number(result.lastInsertRowid); + }); + return rowId; +} + +function storagePaths(installRoot, env) { + return { + storageDir: env.AGMSG_STORAGE_PATH || join(installRoot, "db"), + configPath: env.AGMSG_CONFIG || join(homedir(), ".agents", "agmsg", "config.json"), + }; +} + +async function pendingObservation(pending, now, observe) { + const createdAt = Date.parse(pending.created_at); + const ageMs = Number.isFinite(createdAt) ? now() - createdAt : NaN; + const observation = observe(pending.transcript_path, pending.nonce); + return resolvePendingDelivery({ observation, ageMs, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); +} + +export function createClaudeCodeQueueChannel({ + db, + installRoot, + expectedOpGen, + env = process.env, + timeoutMs = SEND_TIMEOUT_MS, + now = Date.now, + log = (message) => logLine(installRoot, message), + listSeats = listClaudeCodeRegistrations, + readSession = readClaudeRoleSession, + readDriver = readStorageDriver, + openStore = openMessageStore, + readSnapshot = readUnreadSnapshot, + send = sendClaudeCodeMessage, + observe = observeTranscript, + hostPlatform = process.platform, +}) { + let stopped = false; + let activePoll = null; + + async function pollSeat(registration, paths) { + const { team, agent, teamConfig, registered = true } = registration; + const seat = seatKey(team, agent); + const pending = readPending(db, seat); + if (pending) { + const observation = await pendingObservation(pending, now, observe); + if (!registered) { + if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); + else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "unaddressable", reason: "seat_registration_missing" }, now); + return; + } + if (observation.state === "confirmed") { + setQueueState(db, pending.id, "confirmed"); + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "addressable" }, now); + return; + } + if (observation.state === "expired") { + setQueueState(db, pending.id, "expired"); + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "blocked", reason: observation.reason }, now); + return; + } + // Still pending — includes the "held" case: Held is worth surfacing as + // a status, but it is not a delivery confirmation, so the seat must + // never be marked notified on it alone. + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "addressable", reason: observation.reason }, now); + return; + } + if (!registered) return; + if (hostPlatform === "win32") { + saveSeat(db, { seat, state: "blocked", reason: "windows_not_supported_yet" }, now); + return; + } + const session = readSession(join(installRoot, "run"), team, agent); + if (session.state !== "present") { + saveSeat(db, { seat, state: "unaddressable", reason: session.reason ?? "role_session_missing" }, now); + return; + } + const record = session.record; + if (record.team !== team || record.agent !== agent) { + saveSeat(db, { seat, state: "blocked", reason: "role_session_identity_mismatch" }, now); + return; + } + if (record.type !== "claude-code") { + saveSeat(db, { seat, state: "blocked", reason: "role_session_type_mismatch" }, now); + return; + } + if (!record.messaging_socket || !record.claude_config_dir || !record.session) { + saveSeat(db, { seat, state: "unaddressable", reason: "messaging_destination_missing" }, now); + return; + } + const registeredProjects = registrationProjects(teamConfig, agent); + if (registeredProjects.length === 0 || !registeredProjects.some((project) => sameProject(project, record.project))) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: "role_session_project_mismatch" }, now); + return; + } + let driver; + try { + driver = readDriver(paths.configPath); + } catch (error) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `storage_config_unreadable:${error.message}` }, now); + return; + } + if (driver.state !== "ok") { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: driver.reason }, now); + return; + } + if (driver.driver !== "sqlite") { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: "storage_driver_unsupported" }, now); + return; + } + let storePath; + try { + storePath = messageStorePath({ storageDir: paths.storageDir, team, teamConfig }); + } catch (error) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `message_store_path_unreadable:${error.message}` }, now); + return; + } + let store; + try { + store = openStore(storePath); + } catch (error) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `message_store_unreadable:${error.message}` }, now); + return; + } + let snapshot; + try { + snapshot = readSnapshot(store, team, agent, latestConfirmedCursor(db, seat)); + } catch (error) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `message_store_unreadable:${error.message}` }, now); + return; + } finally { + try { store.close(); } catch { /* keep the read result */ } + } + if (!snapshot.unread || stopped) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "addressable" }, now); + return; + } + + const transcriptPath = resolveTranscriptPath(record.claude_config_dir, record.project, record.session); + if (!transcriptPath) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: "transcript_path_unresolvable" }, now); + return; + } + const nonce = newNonce(); + let id; + try { + id = createPending(db, { seat, messagingSocket: record.messaging_socket, transcriptPath, upTo: snapshot.upTo, nonce, expectedOpGen }, now); + } catch (error) { + if (error.message !== "daemon_intent_changed" && error.message !== "seat_already_pending") throw error; + return; + } + + const result = await send({ socketPath: record.messaging_socket, nonce, body: inboxNudge(nonce), timeoutMs }); + if (result.state !== "sent") { + setQueueState(db, id, "expired"); + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `send_failed:${result.reason}` }, now); + return; + } + const firstCheck = await pendingObservation(readPending(db, seat), now, observe); + if (firstCheck.state === "confirmed") { + setQueueState(db, id, "confirmed"); + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "addressable" }, now); + return; + } + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "addressable", reason: firstCheck.reason }, now); + } + + async function pollOnce() { + if (stopped) return; + if (activePoll) return activePoll; + activePoll = (async () => { + const roster = listSeats(join(installRoot, "teams")); + if (roster.state !== "ok") { + log(`channel: Claude Code roster unavailable (${roster.reason})`); + return; + } + const paths = storagePaths(installRoot, env); + const seats = [...roster.seats]; + const registeredKeys = new Set(seats.map(({ team, agent }) => seatKey(team, agent))); + const unsettledSeats = pendingSeats(db); + const pendingKeys = new Set(unsettledSeats.map(({ team, agent }) => seatKey(team, agent))); + for (const { team, agent } of unsettledSeats) { + const key = seatKey(team, agent); + if (!registeredKeys.has(key)) seats.push({ team, agent, teamConfig: null, registered: false }); + } + const knownKeys = new Set([...registeredKeys, ...pendingKeys]); + for (const row of db.prepare("SELECT seat FROM beta_claude_seat").all()) { + if (!knownKeys.has(row.seat)) { + saveSeat(db, { seat: row.seat, state: "unaddressable", reason: "seat_registration_missing" }, now); + } + } + for (const seat of seats) { + if (stopped) break; + try { + await pollSeat(seat, paths); + } catch (error) { + log(`channel: Claude Code seat ${seat.team}/${seat.agent} could not be checked (${error.message})`); + } + } + })().finally(() => { activePoll = null; }); + return activePoll; + } + + return { + pollOnce, + async stop() { + stopped = true; + await activePoll; + }, + }; +} diff --git a/scripts/daemon/channels/codex-queue.mjs b/scripts/daemon/channels/codex-queue.mjs index 2e47700ea..47b264d59 100644 --- a/scripts/daemon/channels/codex-queue.mjs +++ b/scripts/daemon/channels/codex-queue.mjs @@ -30,11 +30,11 @@ import { checkWindowsCodexQueueGate } from "./windows-codex-queue.mjs"; const QUEUE_TIMEOUT_MS = 10_000; -function seatKey(team, agent) { +export function seatKey(team, agent) { return JSON.stringify([team, agent]); } -function sameProject(left, right) { +export function sameProject(left, right) { if (typeof left !== "string" || !left || typeof right !== "string" || !right) return false; if (!isAbsolute(left) || !isAbsolute(right)) return false; const normalize = (value) => { diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs index 0024c8eed..75f8c64e6 100644 --- a/scripts/daemon/main.mjs +++ b/scripts/daemon/main.mjs @@ -18,12 +18,15 @@ import { logLine } from "./log.mjs"; import { classify } from "./status.mjs"; import { createCodexQueueChannel } from "./channels/codex-queue.mjs"; import { ensureCodexChannelSchema } from "./channels/codex-queue-store.mjs"; +import { createClaudeCodeQueueChannel } from "./channels/claude-code-queue.mjs"; +import { ensureClaudeCodeChannelSchema } from "./channels/claude-code-queue-store.mjs"; export const POLL_INTERVAL_MS = 5000; export async function prepareClaimWithCodexSchema(prepareClaim) { const prepared = await prepareClaim(); ensureCodexChannelSchema(prepared.db); + ensureClaudeCodeChannelSchema(prepared.db); return prepared; } @@ -168,6 +171,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe gen = started.gen; controlHandle = started.controlHandle; channelHooks.push(createCodexQueueChannel({ db, installRoot, expectedOpGen })); + channelHooks.push(createClaudeCodeQueueChannel({ db, installRoot, expectedOpGen })); process.on("SIGTERM", () => doStop("normal")); diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs index 9562bdcdc..ab8b13a3b 100644 --- a/scripts/daemon/status.mjs +++ b/scripts/daemon/status.mjs @@ -123,11 +123,21 @@ export function readOwnerAndIntentReadOnly(installRoot) { if (!String(error?.message ?? "").includes("no such table")) throw error; codexSeats = { state: "unavailable", reason: "channel_not_initialized", seats: [] }; } + let claudeCodeSeats; + try { + claudeCodeSeats = { + state: "ok", + seats: db.prepare("SELECT seat, messaging_socket, state, reason, checked_at FROM beta_claude_seat ORDER BY seat").all(), + }; + } catch (error) { + if (!String(error?.message ?? "").includes("no such table")) throw error; + claudeCodeSeats = { state: "unavailable", reason: "channel_not_initialized", seats: [] }; + } const alive = owner.state === "none" || owner.executor_pid == null ? null : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); - return { owner, intent, alive, codexSeats, lastAttempt }; + return { owner, intent, alive, codexSeats, claudeCodeSeats, lastAttempt }; } finally { db.close(); } @@ -168,9 +178,9 @@ async function main() { process.stderr.write("usage: status.mjs \n"); process.exit(2); } - let owner, intent, alive, codexSeats, lastAttempt; + let owner, intent, alive, codexSeats, claudeCodeSeats, lastAttempt; try { - ({ owner, intent, alive, codexSeats, lastAttempt } = readOwnerAndIntentReadOnly(installRoot)); + ({ owner, intent, alive, codexSeats, claudeCodeSeats, lastAttempt } = readOwnerAndIntentReadOnly(installRoot)); } catch (error) { // An input that can be detected as an error is reported at // that entry point, with a nonzero exit -- not a raw stack trace, and @@ -195,7 +205,7 @@ async function main() { // unbuffered) looked completely fine. Setting exitCode and letting the // event loop drain naturally waits for the flush first. process.stdout.write( - `${JSON.stringify({ ...result, codex_seats: codexSeats, node_sqlite_experimental: true, node_version: process.version })}\n`, + `${JSON.stringify({ ...result, codex_seats: codexSeats, claude_code_seats: claudeCodeSeats, node_sqlite_experimental: true, node_version: process.version })}\n`, ); process.exitCode = result.exitCode; } diff --git a/scripts/drivers/types/claude-code/_session-start.sh b/scripts/drivers/types/claude-code/_session-start.sh new file mode 100644 index 000000000..6a8bce213 --- /dev/null +++ b/scripts/drivers/types/claude-code/_session-start.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# claude-code SessionStart plug — record this session's own cross-session- +# messaging destination, and (when agmsgd's native channel is up) skip the +# generic Monitor directive the same way codex's own plug skips it. +# +# Sourced by session-start.sh in its global context (so it sees TYPE, PROJECT, +# RUN_DIR, SKILL_DIR, PAIRS and SESSION_ID). Defines agmsg_session_start, +# overriding session-start.sh's default no-op. +# +# Measured 2026-10-05 (memory/design/2026-10-05-cross-session-messaging-socket- +# measurement.md and its addendum) and ruled on the same day +# (memory/design/2026-09-22-agmsgd-arch-8-delivery-driver.md §12/§12.1): +# - CLAUDE_CODE_MESSAGING_SOCKET changes on every `--resume` (new process, +# new pid-named socket) but not on /clear or /compact, so this must run +# and overwrite on every SessionStart, never cache or trust a stale value. +# - CLAUDE_CONFIG_DIR (falling back to ~/.claude) is the base agmsgd needs +# to find this session's own transcript later; it must NEVER be hardcoded +# to ~/.claude, since this machine's own accounts live elsewhere. + +agmsg_session_start() { + if ! declare -F agmsg_role_session_set_messaging >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "${SKILL_DIR:-}/scripts/lib/role-session.sh" + fi + + # Same guard shape codex-record-session.sh applies to CODEX_HOME: anything + # that is not an absolute path, or that carries a control character, is + # never published as a delivery destination (a malformed value is worse + # than none -- it would look addressable and silently never deliver). + local raw_socket="${CLAUDE_CODE_MESSAGING_SOCKET:-}" socket="" + case "$raw_socket" in + uds:/*) socket="${raw_socket#uds:}" ;; + /*) socket="$raw_socket" ;; + esac + case "$socket" in *[[:cntrl:]]*) socket="" ;; esac + + local config_dir="${CLAUDE_CONFIG_DIR:-${HOME:+$HOME/.claude}}" + case "$config_dir" in + *[[:cntrl:]]*) config_dir="" ;; + /*) ;; + *) config_dir="" ;; + esac + + # Check every OTHER precondition FIRST, before ever touching a lock + # (#1577 re-review): a session that cannot possibly use the native path + # (daemon not ready, Windows, no usable socket) must claim NOTHING. The + # earlier version claimed first and checked these after, so a session with + # agmsgd disabled or no socket could still silently seize an actas lock it + # was never going to use -- only Monitor's own watch.sh was ever supposed + # to touch that lock in that case. + if [ -z "$socket" ]; then return 0; fi + if ! command -v _agmsg_detect_platform >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "${SKILL_DIR:-}/scripts/lib/compat.sh" + fi + _agmsg_detect_platform + [ "$_agmsg_platform" != msys ] || return 0 + if ! declare -F agmsg_daemon_read_state >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "${SKILL_DIR:-}/scripts/lib/daemon-state.sh" + fi + agmsg_daemon_read_state + [ "${AGMSGD_HEALTH:-}" = ready ] || return 0 + + # Claim ONLY the single role this session is actually resuming as -- never + # loop over every pair whose record happens to share this bare sid + # (#1577 re-review: the same conversation can carry session= + # into MORE THAN ONE role's record over its life, e.g. actas alice then + # later actas bob; a resumed session must take back only whichever one + # role it is now, not seize every stale name it ever wore). Delegates to + # agmsg_role_session_match_unique -- the SAME ambiguity rule session- + # start.sh's own narrowing uses elsewhere in this file: a second + # qualifying record makes the whole lookup refuse rather than guess. + if ! command -v agmsg_role_session_match_unique >/dev/null 2>&1 \ + || ! command -v actas_lock_claim >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "${SKILL_DIR:-}/scripts/lib/actas-lock.sh" + fi + local bare_sid my_instance project_phys match pair_team pair_agent + bare_sid="$(agmsg_instance_bare_sid "${SESSION_ID:-}" 2>/dev/null || true)" + my_instance="$(agmsg_normalize_instance_id "${SESSION_ID:-}" "${TYPE:-}" 2>/dev/null || true)" + [ -n "$bare_sid" ] || return 0 + agmsg_instance_is_composite "$my_instance" 2>/dev/null || return 0 + project_phys="$(agmsg_canonical_path "${PROJECT:-}" 2>/dev/null || printf '%s' "${PROJECT:-}")" + match="$(agmsg_role_session_match_unique "${TYPE:-}" "$project_phys" "$bare_sid" 2>/dev/null)" || return 0 + pair_team="${match%%$'\t'*}" + pair_agent="${match#*$'\t'}" + [ -n "$pair_team" ] && [ -n "$pair_agent" ] || return 0 + + # actas_lock_claim is the write gate -- the SAME primitive watch.sh uses to + # reclaim a role's lock across --resume (session-start.sh's own 4th-arg + # "Role-aware resume" relies on exactly this, via watch.sh, when Monitor + # fires; this plug needs its own call since it may skip Monitor entirely). + # Its "mine" verdict requires an EXACT token match; a mismatched owner + # that is still genuinely alive fails the claim outright, and only a + # mismatched DEAD owner reclaims -- so two live processes that happen to + # share a bare sid (same hazard class as #1568) can never both end up + # "owning" the same pair, while a real --resume (new pid, old pid now + # dead) correctly reclaims. + local claim_result + claim_result="$(actas_lock_claim "$pair_team" "$pair_agent" "$my_instance" 2>/dev/null)" || claim_result="unknown:claim_failed" + [ "$claim_result" = ok ] || return 0 + + # Write, then read back: this is the SAME check the daemon itself applies + # (messaging_socket/claude_config_dir both present and non-empty) before + # it will ever address this seat, so "the record actually took" must be + # verified here too, not assumed from the write call returning. + agmsg_role_session_set_messaging "$pair_team" "$pair_agent" "$socket" "$config_dir" "$bare_sid" + local readback_socket readback_config_dir readback_session + readback_socket="$(agmsg_role_session_get "$pair_team" "$pair_agent" messaging_socket 2>/dev/null || true)" + readback_config_dir="$(agmsg_role_session_get "$pair_team" "$pair_agent" claude_config_dir 2>/dev/null || true)" + readback_session="$(agmsg_role_session_uuid "$pair_team" "$pair_agent" 2>/dev/null || true)" + if [ "$readback_socket" = "$socket" ] && [ -n "$config_dir" ] && \ + [ "$readback_config_dir" = "$config_dir" ] && [ "$readback_session" = "$bare_sid" ]; then + cat < is the this-session's uds: peer address with the prefix +# stripped (empty if invalid/absent -- see _session-start.sh's own guard, +# which mirrors codex_home's). is CLAUDE_CONFIG_DIR, or +# ~/.claude when unset -- the base this session's own Claude Code build +# actually uses, never assumed by this function. +# , when given, replaces the record's own session= line (#1577 +# review): a record's session= is only ever written at actas-claim time, so +# after /clear -- which keeps the same socket/process but hands this session +# a brand-new Claude Code session id -- it would otherwise go stale right as +# this function refreshes the fields that matter for finding its transcript +# (the daemon derives the transcript path from BOTH claude_config_dir and +# this session id together). Empty means "leave session= as it is" (every +# other existing caller passes none). +agmsg_role_session_set_messaging() { # [] + local team="$1" agent="$2" socket="$3" config_dir="$4" bare_sid="${5:-}" + [ -n "$team" ] && [ -n "$agent" ] || return 0 + local path dir tmp line + _agmsg_role_session_path_into "$team" "$agent" + path="$_AGMSG_ROLE_SESSION_PATH" + [ -f "$path" ] || return 0 + dir="$(_actas_lock_dir)" + tmp="$(mktemp "$dir/.role-session.XXXXXX" 2>/dev/null)" || return 0 + { + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + messaging_socket=*|claude_config_dir=*) ;; + session=*) [ -z "$bare_sid" ] && printf '%s\n' "$line" ;; + *) printf '%s\n' "$line" ;; + esac + done < "$path" + [ -z "$bare_sid" ] || printf 'session=%s\n' "$bare_sid" + [ -z "$socket" ] || printf 'messaging_socket=%s\n' "$socket" + [ -z "$config_dir" ] || printf 'claude_config_dir=%s\n' "$config_dir" + } > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 0; } + mv -f "$tmp" "$path" 2>/dev/null || rm -f "$tmp" 2>/dev/null + return 0 +} + agmsg_role_session_mark_named() { local team="$1" agent="$2" ref="$3" epoch="${4:-}" project="${5:-}" type="${6:-}" [ -n "$team" ] && [ -n "$agent" ] && [ -n "$ref" ] || return 0 diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs new file mode 100644 index 000000000..630ee55b2 --- /dev/null +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + observeTranscript, + resolvePendingDelivery, + QUEUE_CONFIRMATION_TTL_MS, +} from '../scripts/daemon/channels/claude-code-queue-io.mjs'; + +function temporaryDirectory(t) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'agmsg-cc-queue-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +// Load-bearing property: a `queue-operation`/`enqueue` line fires on mere +// receipt, Held or not, so it must NEVER by itself cause a seat to be marked +// delivered -- never notify on evidence that could just as well be a hold. +// Only the `type:"user"` line whose `origin.body` carries the nonce counts. +// This reproduces both line shapes measured against a live Claude Code +// transcript in one file and asserts the queue-operation line alone is not +// enough, while the user/origin.body line is. +test('observeTranscript: a queue-operation enqueue line is not delivery evidence; only the user/origin.body line is', (t) => { + const dir = temporaryDirectory(t); + const transcriptPath = path.join(dir, 'session.jsonl'); + const nonce = 'abc123-test-nonce'; + const marker = `[agmsg:${nonce}] New messages are waiting. Check your agmsg inbox.`; + + // Only the enqueue line present (held, or not yet acted on) — must read as + // absent, not delivered. + writeFileSync(transcriptPath, `${JSON.stringify({ + type: 'queue-operation', + operation: 'enqueue', + content: `${marker}`, + })}\n`); + let observation = observeTranscript(transcriptPath, nonce); + assert.equal(observation.state, 'absent'); + let resolved = resolvePendingDelivery({ observation, ageMs: 1000, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + assert.equal(resolved.state, 'pending'); + + // The real delivered record appended afterward (as the genuine sequence + // measured: enqueue first, then the delivered user line) — now it must + // read as delivered/confirmed. + writeFileSync(transcriptPath, `${JSON.stringify({ + type: 'queue-operation', + operation: 'enqueue', + content: `${marker}`, + })}\n${JSON.stringify({ + type: 'user', + isMeta: true, + message: { role: 'user', content: 'Another Claude session sent a message...' }, + origin: { kind: 'peer', from: 'agmsgd', verifiedPeerPid: 12345, name: 'agmsgd', fromMode: 'bypass', body: marker }, + })}\n`); + observation = observeTranscript(transcriptPath, nonce); + assert.equal(observation.state, 'delivered'); + resolved = resolvePendingDelivery({ observation, ageMs: 1000, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + assert.equal(resolved.state, 'confirmed'); +}); + +// #1577 review: a transcript that is genuinely missing (its session/project +// no longer has a live file to write to) must eventually expire with a +// reason, not poll forever -- but a transcript that merely failed to read +// (a transient fs error) must stay pending indefinitely, since that one +// really could resolve on the next read. Distinguishing these two is the +// property this test pins. +test('resolvePendingDelivery: a missing transcript expires at the TTL; a read failure never does', () => { + const missing = { state: 'unreadable', reason: 'transcript_missing' }; + const readFailed = { state: 'unreadable', reason: 'transcript_read_failed' }; + + const missingBeforeTtl = resolvePendingDelivery({ observation: missing, ageMs: 1000, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + assert.equal(missingBeforeTtl.state, 'pending'); + const missingAfterTtl = resolvePendingDelivery({ observation: missing, ageMs: QUEUE_CONFIRMATION_TTL_MS + 1, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + assert.equal(missingAfterTtl.state, 'expired'); + assert.equal(missingAfterTtl.reason, 'transcript_missing'); + + const readFailedAfterTtl = resolvePendingDelivery({ observation: readFailed, ageMs: QUEUE_CONFIRMATION_TTL_MS + 1, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + assert.equal(readFailedAfterTtl.state, 'pending'); +}); diff --git a/tests/test_role_session.bats b/tests/test_role_session.bats index 8904b3ab7..5ec8cdfb2 100644 --- a/tests/test_role_session.bats +++ b/tests/test_role_session.bats @@ -274,3 +274,32 @@ fake_session() { [ "$status" -eq 0 ] [[ "$output" == *"OK u=sid-9 p=/p/q r"* ]] } + +# Claude Code native-channel delivery (2026-10-05, arch-8 §12.1): a --resume +# starts a brand-new process bound to a brand-new CLAUDE_CODE_MESSAGING_SOCKET +# (measured in memory/design/2026-10-05-cross-session-messaging-socket- +# measurement.md's addendum), so agmsgd must never cache the socket path -- +# session-start.sh re-derives and overwrites it on every start/resume via +# agmsg_role_session_set_messaging. This is the load-bearing property: the +# SECOND call's socket value wins, and unrelated fields (session, project) +# untouched by that function survive unchanged. +@test "role-session: set_messaging overwrites the socket on each call, as a --resume would require" { + agmsg_role_session_record T alice sid-1 /p/q claude-code + agmsg_role_session_set_messaging T alice /tmp/cc-socks/111.sock /home/x/.claude + [ "$(agmsg_role_session_get T alice messaging_socket)" = /tmp/cc-socks/111.sock ] + [ "$(agmsg_role_session_get T alice claude_config_dir)" = /home/x/.claude ] + + # A --resume: same role, brand-new pid-named socket. + agmsg_role_session_set_messaging T alice /tmp/cc-socks/222.sock /home/x/.claude + [ "$(agmsg_role_session_get T alice messaging_socket)" = /tmp/cc-socks/222.sock ] + # Fields set_messaging does not touch must survive untouched. + [ "$(agmsg_role_session_uuid T alice)" = sid-1 ] + [ "$(agmsg_role_session_get T alice project)" = /p/q ] + + # A /clear: same socket, but Claude Code hands this session a brand-new + # session id -- the 5th argument must replace session= too (#1577 review), + # since the daemon derives the transcript path from it. + agmsg_role_session_set_messaging T alice /tmp/cc-socks/222.sock /home/x/.claude sid-2 + [ "$(agmsg_role_session_uuid T alice)" = sid-2 ] + [ "$(agmsg_role_session_get T alice project)" = /p/q ] +} diff --git a/tests/test_session_start_claude_code_native_channel.bats b/tests/test_session_start_claude_code_native_channel.bats new file mode 100644 index 000000000..333f9cfee --- /dev/null +++ b/tests/test_session_start_claude_code_native_channel.bats @@ -0,0 +1,195 @@ +#!/usr/bin/env bats + +# Claude Code native-channel delivery (2026-10-05, arch-8 §12.1): once +# agmsgd's own executor is verifiably ready, a claude-code seat must stop +# arming the generic Monitor watcher — the daemon delivers to its messaging +# socket directly instead, and a second, redundant receive path serves no +# purpose. But "ready" alone is not enough to skip Monitor (#1577 review): +# the daemon only ever addresses a seat whose role-session record actually +# carries a readable messaging_socket/claude_config_dir, so skipping Monitor +# for a seat that never got that record (never actas-claimed) would leave it +# with no delivery path at all. This file pins both halves of that gate. + +load test_helper + +setup() { + setup_test_env + export AGMSG_PLUGIN_DIRS="" + export SKILL_DIR="$TEST_SKILL_DIR" + export RUN_DIR="$SKILL_DIR/run" + mkdir -p "$RUN_DIR" + export PROJ="/tmp/agmsg-session-start-native-channel-proj" + bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null +} + +teardown() { teardown_test_env; } + +_run_session_start() { + env AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/$1.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< "{\"session_id\":\"$1\"}" +} + +_mark_daemon_ready() { + sqlite3 "$RUN_DIR/install.db" < "$SCRIPTS/daemon/schema.sql" + local boot + case "$(uname -s)" in + Darwin) boot="$(sysctl -n kern.boottime | sed -n 's/.*sec = \([0-9]*\),.*/\1/p')" ;; + Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; + *) skip 'POSIX beta executor evidence' ;; + esac + sqlite3 "$RUN_DIR/install.db" "UPDATE daemon_intent SET desired='on'; + UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='$boot', executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" +} + +# #1577 re-review repro: a conversation that actas'd alice, then +# LATER actas'd bob, leaves BOTH role-session records carrying the same +# session=shared (actas-claim.sh always writes the CURRENT session's bare +# sid into whichever record it claims). A later caller with that same bare +# sid is genuinely resuming only ONE of those roles -- claiming the other's +# lock too would silently seize an actas exclusivity lock nobody asked for, +# with no watcher ever subscribed to use it. Neither role is ever claimed +# here on purpose: the daemon isn't ready (the precondition-first ordering +# this test also pins -- #1577 re-review point 2), so the plug must return +# before ever touching a lock at all, for either pair. +@test "session-start: two roles sharing a stale bare sid are never both claimed (#1577 re-review repro)" { + bash "$SCRIPTS/join.sh" team bob claude-code "$PROJ" >/dev/null + source "$SCRIPTS/lib/role-session.sh" + agmsg_role_session_record team alice shared "$PROJ" claude-code + agmsg_role_session_record team bob shared "$PROJ" claude-code + + run _run_session_start "shared" + [ "$status" -eq 0 ] + grep -qF "AGMSG monitor mode" <<<"$output" + + if ! command -v actas_lock_path >/dev/null 2>&1; then source "$SCRIPTS/lib/actas-lock.sh"; fi + bob_lock="$(actas_lock_path team bob)" + [ ! -e "$bob_lock" ] + alice_lock="$(actas_lock_path team alice)" + [ ! -e "$alice_lock" ] +} + +@test "session-start: agmsgd ready AND an actas-claimed role-session record -> no Monitor directive" { + _mark_daemon_ready + # The SAME live pid on both calls, explicit -- the plug's ownership check + # (#1577 re-review) is an exact match on the full composite ".", + # so this positive case must prove the lock and the record really are + # composite-identified, not rely on whatever agmsg_agent_pid's real + # ancestry walk happens to resolve (or degrade to bare) in this + # environment, which left this case passing or failing by accident. + env AGMSG_AGENT_PID=$$ bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "sid-daemon-ready" >/dev/null + run env AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-daemon-ready.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-daemon-ready"}' + [ "$status" -eq 0 ] + refute grep -q "AGMSG monitor mode" <<<"$output" + refute grep -q "invoke the Monitor tool" <<<"$output" + grep -qF "agmsgd is running and will deliver" <<<"$output" +} + +# actas_lock_claim is the write gate (not a raw read+compare): its own +# liveness check is what lets a genuine --resume (new pid, the OLD pid now +# dead) correctly reclaim the lock and keep delivering natively, while still +# refusing a same-bare-sid collision from a pid that is still alive (the +# test above). Claim first under a pid nothing on this machine will ever be, +# simulating the pre-resume process having already exited; the resumed +# session's own live pid must still be able to take over. +@test "session-start: a genuine --resume (dead old pid, same bare sid) reclaims the lock and still skips Monitor" { + _mark_daemon_ready + env AGMSG_AGENT_PID=999999999 bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "sid-resumed" >/dev/null + run env AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-resumed.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-resumed"}' + [ "$status" -eq 0 ] + refute grep -q "AGMSG monitor mode" <<<"$output" + grep -qF "agmsgd is running and will deliver" <<<"$output" +} + +@test "session-start: agmsgd not running -> the usual Monitor directive still fires" { + run _run_session_start "sid-no-daemon" + [ "$status" -eq 0 ] + grep -qF "AGMSG monitor mode" <<<"$output" +} + +# The counterexample #1577 review required: daemon ready is NOT enough by +# itself. A seat that was only joined (never actas-claimed, so it has no +# role-session record at all, let alone a messaging_socket field) must keep +# its only delivery path -- Monitor -- rather than be silenced on the +# assumption the daemon can reach it. +@test "session-start: agmsgd ready but NO role-session record -> Monitor directive still fires" { + _mark_daemon_ready + run _run_session_start "sid-ready-no-record" + [ "$status" -eq 0 ] + grep -qF "AGMSG monitor mode" <<<"$output" + refute grep -q "agmsgd is running and will deliver" <<<"$output" +} + +# #1577 re-review: comparing only the BARE session id let one process +# overwrite another's record whenever the two happened to share the same +# underlying sid under different pids (two live --resume/--continue +# processes of the same conversation -- same hazard class as #1568). bob +# holds the actas lock as the composite "shared."; the caller's +# own SESSION_ID is the SAME bare "shared" but a DIFFERENT live pid, so the +# caller's own composite id differs from bob's lock owner even though their +# bare sids are identical. Only an exact composite match may write. +@test "session-start: a different live pid with the SAME bare session id never overwrites that seat's record" { + _mark_daemon_ready + bash "$SCRIPTS/join.sh" team bob claude-code "$PROJ" >/dev/null + env AGMSG_AGENT_PID=$$ bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code bob "shared" >/dev/null + source "$SCRIPTS/lib/role-session.sh" + agmsg_role_session_set_messaging team bob /tmp/cc-socks/bob-original.sock /home/bob/.claude shared + + # A second, DIFFERENT live pid (this bats test's own subshell, genuinely + # alive and distinct from $$) claims the SAME bare sid for a different + # agent -- the scenario this test exists to catch. + ( env AGMSG_AGENT_PID=$BASHPID bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + env AGMSG_AGENT_PID=$BASHPID bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "shared" >/dev/null + env AGMSG_AGENT_PID=$BASHPID AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/shared.$BASHPID.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"shared"}' ) >/dev/null + + [ "$(agmsg_role_session_get team bob messaging_socket)" = /tmp/cc-socks/bob-original.sock ] + [ "$(agmsg_role_session_get team bob claude_config_dir)" = /home/bob/.claude ] + [ "$(agmsg_role_session_uuid team bob)" = shared ] +} + +# #1577 review: agmsgd's own Claude Code channel does not send to a Windows +# seat at all yet (named pipe + mandatory auth line is separate work), so the +# plug must never skip Monitor there even with a daemon that is otherwise +# ready and a role-session record that otherwise round-trips cleanly. Faking +# `uname -s` for the whole session-start.sh run (as the full integration tests +# above do for ready/not-ready) is unusable here: compat.sh's OWN platform +# branches change unrelated behavior under a faked Windows uname too, which +# would make this pass or fail for a confounded reason rather than the +# platform check this test exists to pin. Isolate it instead: stub every +# collaborator agmsg_session_start calls so the only real logic under test is +# its own final gate, and set _agmsg_platform directly (compat.sh's own memo +# variable, read, never recomputed, once non-empty). +@test "session-start plug: the Monitor-skip gate itself never fires on Windows, in isolation" { + run bash -c ' + set -uo pipefail + SKILL_DIR="'"$TEST_SKILL_DIR"'" + PROJECT="/tmp/p1" + TYPE="claude-code" + SESSION_ID="sid-1" + PAIRS="T alice" + CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/1.sock" + CLAUDE_CONFIG_DIR="/home/x/.claude" + # Stubs: every collaborator reports "this pair is fully addressable" -- + # the one thing NOT stubbed is _agmsg_platform/_agmsg_detect_platform and + # agmsg_daemon_read_state, which together are what this test is pinning. + agmsg_role_session_set_messaging() { :; } + agmsg_role_session_get() { printf "%s" "$3" | grep -q socket && echo "/tmp/cc-socks/1.sock" || echo "/home/x/.claude"; } + agmsg_role_session_uuid() { echo "sid-1"; } + actas_lock_claim() { echo "ok"; } + agmsg_instance_bare_sid() { printf "%s" "$1"; } + agmsg_normalize_instance_id() { echo "sid-1.4242"; } + agmsg_instance_is_composite() { case "$1" in *.*) return 0 ;; *) return 1 ;; esac; } + agmsg_daemon_read_state() { AGMSGD_HEALTH=ready; } + _agmsg_platform=msys + _agmsg_detect_platform() { :; } # already set -- compat.sh itself would also no-op here + + SKILL_DIR="$SKILL_DIR" . "'"$TEST_SKILL_DIR"'/scripts/drivers/types/claude-code/_session-start.sh" + agmsg_session_start + echo "FELL THROUGH (correct: Windows must not skip Monitor)" + ' + [ "$status" -eq 0 ] + grep -qF "FELL THROUGH" <<<"$output" + refute grep -q "agmsgd is running and will deliver" <<<"$output" +} From 8d11674e4d11077bc4347d7e93485ecb3d1c7559 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 13 Oct 2026 16:22:52 -0700 Subject: [PATCH 16/97] README: document the Claude Code native delivery path (#1577) (#1588) * README: document the Claude Code native delivery path (#1577) The agmsgd beta section still described the Monitor-replacement claim as false for every agent, which is no longer accurate for Claude Code: a seat agmsgd can confirm it delivers to never needs its Monitor watch re-armed, on macOS/Linux. Windows Claude Code seats are unaffected and keep using Monitor. Also documents the claude_code_seats status field and that disabling the daemon falls back to Monitor on the next SessionStart. * README: say an open Claude Code session needs a resume to get Monitor back after disable --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ba55d83c6..b107bc349 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Git Bash PATH). There is no PowerShell reimplementation. ### agmsgd beta (1.6.0) -The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, replace other agents' monitors, or take over remote sync. macOS, Linux, and Windows are supported. Windows requires native Codex 0.157.0 or later; earlier versions are blocked with a reason. Native queue execution and delivery to a logged-in live Windows session were measured with Codex 0.157.0 and 0.160.0. Unreadable version or delivery evidence keeps the existing conservative handling. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. +The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue, and sends Claude Code sessions a notice directly through that session's own cross-session messaging socket. A Claude Code seat delivered this way never needs its Monitor watch re-armed. The Claude Code path is macOS/Linux only; a Windows Claude Code seat keeps using Monitor as before. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, take over remote sync, or change how any agent other than Claude Code and Codex receives messages. macOS, Linux, and Windows are supported for Codex. Windows requires native Codex 0.157.0 or later; earlier versions are blocked with a reason. Native queue execution and delivery to a logged-in live Windows session were measured with Codex 0.157.0 and 0.160.0. Unreadable version or delivery evidence keeps the existing conservative handling. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. Requires Node >= 22.13.0 with `node:sqlite` (the experimental SQLite warning is expected). Install Node from [nodejs.org](https://nodejs.org/en/download). Enable and inspect the beta with: @@ -165,11 +165,11 @@ agmsg daemon status If `agmsg` is not found, use `/scripts/agmsg daemon enable` (and the same path for `status`, `start`, `stop`, or `disable`); `` is the installation directory printed by the installer. Follow its PATH instructions to make `agmsg` available in your shell. -While enabled, new Codex launches through the shim use plain Codex: no bridge app-server or dispatcher is started. Sessions that already have a bridge keep using it until restarted; agmsgd skips those sessions. `enable` and `start` list the remaining bridge sessions and missing destination records. Restart Codex and run `$agmsg actas ` to record a missing destination. Existing remote sync processes remain separate, so this beta does not yet reduce all background work to one process. +While enabled, new Codex launches through the shim use plain Codex: no bridge app-server or dispatcher is started. Sessions that already have a bridge keep using it until restarted; agmsgd skips those sessions. `enable` and `start` list the remaining bridge sessions and missing destination records. Restart Codex and run `$agmsg actas ` to record a missing destination. Existing remote sync processes remain separate, so this beta does not yet reduce all background work to one process. A Claude Code seat only skips Monitor once agmsgd confirms it can actually be delivered to; every other seat keeps using Monitor as before. `daemon status` reports each such seat's state under `claude_code_seats`. If agmsgd stops while enabled, the next ordinary agmsg script operation warns at most once every ten minutes per install. Codex startup also warns, and `daemon status` and `scripts/doctor.sh` report the failure with exit code 1. Run `agmsg daemon start` to recover. For a missing or outdated Node executable, install Node and run `agmsg daemon enable` again. Unread messages are preserved. -To return to bridge notices, run `agmsg daemon disable`, then restart the Codex sessions listed as having no bridge (for example, `codex resume`, followed by `$agmsg actas `). They receive no new notices until restarted. Existing bridge sessions need no restart, and subsequent Codex launches get a bridge. Notices already accepted by the Codex queue are not cancelled and may appear once more. `stop` stops the daemon without removing its resident service; `disable` also unregisters that service. +To return to bridge notices, run `agmsg daemon disable`, then restart the Codex sessions listed as having no bridge (for example, `codex resume`, followed by `$agmsg actas `). They receive no new notices until restarted. Existing bridge sessions need no restart, and subsequent Codex launches get a bridge. Notices already accepted by the Codex queue are not cancelled and may appear once more. Claude Code seats go back to Monitor at their next SessionStart; an open Claude Code session gets no notices until then. To restore notices in an existing session, resume it so SessionStart arms Monitor again. `stop` stops the daemon without removing its resident service; `disable` also unregisters that service. ## First run From 54d6e24e6c0d559eada7557867caaecb0935ca9e Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 14 Oct 2026 09:17:33 +0900 Subject: [PATCH 17/97] release: 1.6.0-rc.1 (#1583) * release: 1.6.0-rc.1 * release: map the agmsgd beta squash subjects into the 1.6.0-rc.1 notes Four beta commits were merged with subjects that carry no conventional prefix, so git-cliff dropped them from the generated release notes: the daemon skeleton, Codex queue delivery, switching and recovery, and the Windows Codex minimum release. --- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- cliff.toml | 5 +++++ package.json | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 34532c74e..3a4b00452 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agmsg", "description": "Cross-agent messaging via SQLite. Send messages between CLI AI agents. No daemon, no network.", - "version": "1.5.3", + "version": "1.6.0-rc.1", "author": { "name": "fujibee" }, diff --git a/VERSION b/VERSION index 8af85beb5..2b940c405 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.3 +1.6.0-rc.1 diff --git a/cliff.toml b/cliff.toml index dbcad2ca6..6c2ef5227 100644 --- a/cliff.toml +++ b/cliff.toml @@ -96,6 +96,11 @@ commit_parsers = [ { message = "^sync-cipher: tolerate unknown message fields on receive", group = "Fixed" }, { message = "^codex: one app-server per seat, never shared", group = "Fixed" }, { message = "^plain driver: an agent-native substitute for peek/poke with no pane", group = "Added" }, +# 1.6.0 agmsgd beta: squash subjects without a conventional prefix. +{ message = "^agmsgd beta: skeleton", group = "Added" }, +{ message = "^Add Codex queue delivery and seat status", group = "Added" }, +{ message = "^Add daemon beta switching and recovery guidance", group = "Added" }, +{ message = "^Allow Windows Codex queue from the measured minimum release", group = "Added" }, # 1.4.0's jev work was squashed with a "jev:" subject rather than a # Conventional prefix, so the catch-all below would drop it — including the # second provider, the most user-visible jev change in the release. Same diff --git a/package.json b/package.json index e81a77505..3aa5547f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.5.3", + "version": "1.6.0-rc.1", "description": "Cross-agent messaging via SQLite for CLI AI agents (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Antigravity, OpenCode). The npm package is a thin bootstrapper that fetches and runs the canonical bash installer at https://github.com/fujibee/agmsg.", "bin": { "agmsg": "bin/agmsg.js" From 15892e4df95eb3150ff57de2c23bc94911365534 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 14 Oct 2026 12:27:02 +0900 Subject: [PATCH 18/97] fix: persist Codex executable and expire failed queue starts (#1595) --- scripts/daemon.sh | 52 ++++++++++++++- scripts/daemon/channels/codex-queue-io.mjs | 15 +++-- scripts/daemon/channels/codex-queue.mjs | 36 +++++++--- scripts/daemon/db.mjs | 6 ++ scripts/daemon/schema.sql | 5 +- tests/agmsgd_codex_queue.test.mjs | 78 +++++++++++++++++++++- tests/agmsgd_db.test.mjs | 4 ++ 7 files changed, 177 insertions(+), 19 deletions(-) diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 180a76280..b86b055ab 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -121,6 +121,22 @@ _with_op_lock() { return 1 fi if [ -n "$statements" ]; then + # Upgrade rc.1 inside this connection's operation lock and transaction. + local has_codex_path + printf '%s\n' "SELECT count(*) FROM pragma_table_info('meta', 'installdb') WHERE name='codex_path';" >&8 + IFS= read -r -t 10 has_codex_path <&9 || has_codex_path="" + if [ "$has_codex_path" = 0 ]; then + printf '%s\n' 'ALTER TABLE installdb.meta ADD COLUMN codex_path TEXT;' >&8 + elif [ "$has_codex_path" != 1 ]; then + printf 'ROLLBACK;\n.quit\n' >&8 2>/dev/null || true + exec 8>&- + exec 9<&- + wait "$lock_pid" 2>/dev/null || true + rm -f "$lock_dir/in" "$lock_dir/out" + rmdir "$lock_dir" + echo 'agmsg daemon: could not inspect the Codex executable column' >&2 + return 1 + fi printf '%s\n.print APPLIED\n' "$statements" >&8 if ! IFS= read -r -t 10 applied <&9 || [ "$applied" != "APPLIED" ]; then printf 'ROLLBACK;\n.quit\n' >&8 2>/dev/null || true @@ -379,6 +395,35 @@ _resolve_node() { printf '%s' "$resolved" } +_resolve_codex() { + local candidate dir rest="${PATH:-}:" override="${AGMSG_REAL_CODEX:-}" + while :; do + if [ -n "$override" ]; then + candidate="$override" + override="" + elif [ -n "$rest" ]; then + dir="${rest%%:*}" + rest="${rest#*:}" + [ -n "$dir" ] || dir=. + candidate="$dir/codex" + else + return 1 + fi + case "$candidate" in *[[:cntrl:]]*) continue ;; esac + [ -f "$candidate" ] && [ -x "$candidate" ] || continue + if head -c 4096 "$candidate" 2>/dev/null | grep -q 'Optional Codex entrypoint shim for agmsg monitor mode'; then + continue + fi + dir="$(cd "$(dirname "$candidate")" && pwd -P)" || continue + candidate="$dir/$(basename "$candidate")" + if command -v cygpath >/dev/null 2>&1; then + candidate="$(cygpath -m "$candidate")" || continue + fi + printf '%s\n' "$candidate" + return 0 + done +} + _wait_for_ready() { local target_op_gen="$1" waited=0 while [ "$waited" -lt 100 ]; do @@ -519,8 +564,13 @@ cmd_enable() { } local node_version node_version="$("$node_path" -e 'console.log(process.versions.node)')" + local codex_path + codex_path="$(_resolve_codex 2>/dev/null || true)" + if [ -z "$codex_path" ]; then + echo 'agmsg daemon enable: no usable Codex executable found; Codex seats stay blocked until enabled again with Codex available.' >&2 + fi _with_op_lock " - UPDATE meta SET node_path = '$(printf '%s' "$node_path" | sed "s/'/''/g")', node_version = '$node_version'; + UPDATE meta SET node_path = '$(printf '%s' "$node_path" | sed "s/'/''/g")', node_version = '$node_version', codex_path = '$(printf '%s' "$codex_path" | sed "s/'/''/g")'; UPDATE daemon_intent SET desired = 'on'; " _register "$node_path" || { if ! _with_op_lock "" _unregister; then diff --git a/scripts/daemon/channels/codex-queue-io.mjs b/scripts/daemon/channels/codex-queue-io.mjs index 1a6a231bb..4a4c0315e 100644 --- a/scripts/daemon/channels/codex-queue-io.mjs +++ b/scripts/daemon/channels/codex-queue-io.mjs @@ -55,6 +55,7 @@ export async function runCodexQueue({ timeoutMs, signal, onChildStart, + env = process.env, spawnProcess = spawn, }) { if (typeof executable !== "string" || executable.length === 0) throw new TypeError("missing Codex executable"); @@ -68,13 +69,13 @@ export async function runCodexQueue({ let child; try { child = spawnProcess(executable, args, { - env: { ...process.env, CODEX_HOME: codexHome }, + env: { ...env, CODEX_HOME: codexHome }, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", }); } catch (error) { - return { kind: "failed", reason: `spawn_failed: ${error.message}` }; + return { kind: "failed", started: false, reason: `spawn_failed:${error.code ?? "unknown"}: ${error.message}` }; } let stdout = ""; @@ -111,7 +112,10 @@ export async function runCodexQueue({ if (timedOut) signalProcessGroup(child, "SIGKILL"); }, timeoutMs + 1000); let childRecordFailed = false; - if (onChildStart) { + let started = false; + child.once("spawn", () => { + started = true; + if (!onChildStart) return; try { onChildStart(child.pid); } catch { @@ -119,7 +123,7 @@ export async function runCodexQueue({ signalProcessGroup(child, "SIGTERM"); killTimers.push(setTimeout(() => signalProcessGroup(child, "SIGKILL"), 1000)); } - } + }); const result = await closed; clearTimeout(timer); clearTimeout(hardKill); @@ -127,9 +131,10 @@ export async function runCodexQueue({ signal?.removeEventListener("abort", onAbort); if (childRecordFailed) return { kind: "failed", reason: "child_record_failed", stdout, stderr }; + if (result.error && !started) return { kind: "failed", started: false, reason: `spawn_failed:${result.error.code ?? "unknown"}: ${result.error.message}`, stdout, stderr }; if (aborted) return { kind: "cancelled", reason: "daemon_stopping", stdout, stderr }; if (timedOut) return { kind: "timeout", stdout, stderr }; - if (result.error) return { kind: "failed", reason: `spawn_failed: ${result.error.message}`, stdout, stderr }; + if (result.error) return { kind: "failed", started, reason: `spawn_failed:${result.error.code ?? "unknown"}: ${result.error.message}`, stdout, stderr }; const combined = `${stdout}\n${stderr}`; if (result.code !== 0) { if (combined.includes("code -32600")) return { kind: "archived", reason: "thread_archived" }; diff --git a/scripts/daemon/channels/codex-queue.mjs b/scripts/daemon/channels/codex-queue.mjs index 47b264d59..434016ab5 100644 --- a/scripts/daemon/channels/codex-queue.mjs +++ b/scripts/daemon/channels/codex-queue.mjs @@ -3,8 +3,8 @@ import { createHash } from "node:crypto"; import { homedir, hostname } from "node:os"; -import { isAbsolute, join, resolve, sep } from "node:path"; -import { lstatSync, readFileSync, readdirSync } from "node:fs"; +import { delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { accessSync, constants, lstatSync, readFileSync, readdirSync, statSync } from "node:fs"; import { logLine } from "../log.mjs"; import { withImmediateTransaction } from "../db.mjs"; import { @@ -134,13 +134,14 @@ function recordChildResult(db, id, result) { if (!row) return; let children; try { children = JSON.parse(row.children); } catch { children = { state: "unreadable", reason: "child_group_record_malformed" }; } - if (children.state === "incomplete" && result.kind === "failed" && result.reason?.startsWith("spawn_failed:")) { - children = { state: "absent", kind: result.kind }; + const failedBeforeStart = result.kind === "failed" && result.started === false; + if (failedBeforeStart) { + children = { state: "absent", result: result.kind, reason: result.reason }; } else { children.result = result.kind; } - db.prepare("UPDATE beta_codex_queue SET children = ? WHERE id = ? AND state = 'pending'") - .run(JSON.stringify(children), id); + db.prepare("UPDATE beta_codex_queue SET children = ?, state = ? WHERE id = ? AND state = 'pending'") + .run(JSON.stringify(children), failedBeforeStart ? "expired" : "pending", id); }); } @@ -266,7 +267,7 @@ export function createCodexQueueChannel({ installRoot, expectedOpGen, env = process.env, - executable = "codex", + executable, timeoutMs = QUEUE_TIMEOUT_MS, now = Date.now, log = (message) => logLine(installRoot, message), @@ -301,7 +302,7 @@ export function createCodexQueueChannel({ return; } if (hostPlatform === "win32") { - const gate = windowsGate({ executable, env }); + const gate = windowsGate({ executable: executable ?? db.prepare('SELECT codex_path FROM meta').get()?.codex_path, env }); if (gate.state !== "ok") { if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); @@ -354,15 +355,25 @@ export function createCodexQueueChannel({ saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "role_session_type_mismatch" }, now); return; } - let queueExecutable = executable; + const runtime = db.prepare('SELECT codex_path, node_path FROM meta').get(); + let queueExecutable = executable ?? runtime?.codex_path; + const queueEnv = { ...env, PATH: [runtime?.node_path ? dirname(runtime.node_path) : '', queueExecutable && isAbsolute(queueExecutable) ? dirname(queueExecutable) : '', env.PATH ?? ''].filter(Boolean).join(delimiter) }; if (hostPlatform === "win32") { - const gate = windowsGate({ executable, env }); + const gate = windowsGate({ executable: queueExecutable, env: queueEnv }); if (gate.state !== "ok") { saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: gate.reason }, now); return; } queueExecutable = gate.executable; } + try { + if (!isAbsolute(queueExecutable ?? '')) throw new Error('not_absolute'); + if (!statSync(queueExecutable).isFile()) throw new Error('not_file'); + accessSync(queueExecutable, constants.X_OK); + } catch { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: 'blocked', reason: 'codex_executable_unavailable:run_agmsg_daemon_enable' }, now); + return; + } let driver; try { driver = readDriver(paths.configPath); @@ -437,6 +448,7 @@ export function createCodexQueueChannel({ try { result = await queue({ executable: queueExecutable, + env: queueEnv, codexHome: record.codex_home, thread: record.thread, message: inboxNudge(nonce), @@ -448,6 +460,10 @@ export function createCodexQueueChannel({ activeController = null; } recordChildResult(db, id, result); + if (result.kind === "failed" && result.started === false) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "addressable", reason: result.reason }, now); + return; + } if (result.kind === "archived" || result.kind === "no_rollout") { setQueueState(db, id, "expired"); const reason = result.kind === "archived" ? "thread_archived" : "codex_home_mismatch"; diff --git a/scripts/daemon/db.mjs b/scripts/daemon/db.mjs index 1225f4675..bb75a0d02 100644 --- a/scripts/daemon/db.mjs +++ b/scripts/daemon/db.mjs @@ -19,6 +19,12 @@ export function openInstallDb(path, { readonly = false } = {}) { db.exec("PRAGMA busy_timeout = 5000;"); if (!readonly) { db.exec(readFileSync(SCHEMA_PATH, "utf8")); + // Additive upgrade from rc.1; existing enable settings remain intact. + withImmediateTransaction(db, () => { + if (!db.prepare("SELECT 1 FROM pragma_table_info('meta') WHERE name='codex_path'").get()) { + db.exec("ALTER TABLE meta ADD COLUMN codex_path TEXT"); + } + }); } return db; } diff --git a/scripts/daemon/schema.sql b/scripts/daemon/schema.sql index d93c837c4..9ad4e72ce 100644 --- a/scripts/daemon/schema.sql +++ b/scripts/daemon/schema.sql @@ -14,13 +14,14 @@ -- (written by install.sh, PR 2); run/install-manifest.json carries only a -- COPY of it, and the entrypoint's startup verification confirms the two -- still agree. schema_version is written by --- install/agmsgd; node_path and node_version are written ONLY by the +-- install/agmsgd; node_path, node_version and codex_path are written ONLY by the -- enable/disable CLI (never by agmsgd itself). CREATE TABLE IF NOT EXISTS meta ( schema_version INTEGER NOT NULL, install_id TEXT, node_path TEXT, - node_version TEXT + node_version TEXT, + codex_path TEXT ); INSERT INTO meta (schema_version, install_id, node_path, node_version) SELECT 1, NULL, NULL, NULL WHERE NOT EXISTS (SELECT 1 FROM meta); diff --git a/tests/agmsgd_codex_queue.test.mjs b/tests/agmsgd_codex_queue.test.mjs index 6327b7892..98426dd7d 100644 --- a/tests/agmsgd_codex_queue.test.mjs +++ b/tests/agmsgd_codex_queue.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; @@ -51,6 +51,7 @@ function fakeChild({ stdout, stderr = '', code = 0, autoClose = true }) { child.kill = () => true; if (autoClose) { setImmediate(() => { + child.emit('spawn'); child.stdout.end(stdout); child.stderr.end(stderr); child.emit('close', code, null); @@ -59,6 +60,78 @@ function fakeChild({ stdout, stderr = '', code = 0, autoClose = true }) { return child; } +function executableFixture(t) { + const root = temporaryDirectory(t); + mkdirSync(path.join(root, 'run')); + const home = path.join(root, 'profile'); + mkdirSync(path.join(home, 'sessions'), { recursive: true }); + writeFileSync(path.join(home, 'sessions', `rollout-test-${thread}.jsonl`), [ + JSON.stringify({ type: 'session_meta', payload: { id: thread } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'earlier prompt' } }), '', + ].join('\n')); + const db = openInstallDb(path.join(root, 'run', 'install.db')); + ensureCodexChannelSchema(db); + db.exec("UPDATE daemon_intent SET desired='on', op_gen=1"); + t.after(() => db.close()); + const options = { + db, installRoot: root, expectedOpGen: 1, + env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', AGMSG_STORAGE_PATH: root }, + listSeats: () => ({ state: 'ok', seats: [{ team: 'test', agent: 'alice', teamConfig: { agents: { alice: { registrations: [{ type: 'codex', project: root }] } } } }] }), + readSession: () => ({ state: 'present', record: { team: 'test', agent: 'alice', type: 'codex', project: root, thread, codex_home: home } }), + readDriver: () => ({ state: 'ok', driver: 'sqlite' }), + openStore: () => ({ close() {} }), + readSnapshot: () => ({ state: 'ok', unread: true, upTo: '{}' }), + captureGroup: () => ({ state: 'absent' }), + }; + return { root, db, options }; +} + +test('recorded absolute Codex runs with an env-node shebang under a minimal service PATH', async (t) => { + if (process.platform === 'win32') return t.skip('POSIX env-node launcher'); + const { root, db, options } = executableFixture(t); + const binary = path.join(root, 'custom codex'); + writeFileSync(binary, `#!/usr/bin/env node\nconsole.log('Queued message ${itemId} for thread ${thread}.');\n`); + chmodSync(binary, 0o755); + const channel = createCodexQueueChannel(options); + await channel.pollOnce(); + assert.equal(db.prepare('SELECT state FROM beta_codex_seat').get().state, 'blocked'); + assert.match(db.prepare('SELECT reason FROM beta_codex_seat').get().reason, /codex_executable_unavailable/); + db.prepare('UPDATE meta SET codex_path=?, node_path=?').run(binary, process.execPath); + await channel.pollOnce(); + const row = db.prepare('SELECT state, queue_item_id FROM beta_codex_queue').get(); + assert.equal(row.queue_item_id, itemId); + assert.equal(row.state, 'pending', 'a receipt alone is not delivery confirmation'); + await channel.stop(); +}); + +test('a pre-spawn failure expires its reservation and allows the next poll to retry', async (t) => { + const { db, options } = executableFixture(t); + db.prepare('UPDATE meta SET codex_path=?, node_path=?').run(process.execPath, process.execPath); + let attempts = 0; + let witnesses = 0; + const channel = createCodexQueueChannel({ ...options, captureGroup: () => { + witnesses += 1; + return { state: 'unreadable' }; + }, queue: (request) => { + attempts += 1; + // Model a recorded executable disappearing between validation and spawn. + return runCodexQueue({ ...request, executable: path.join(options.installRoot, 'missing-codex') }); + } }); + await channel.pollOnce(); + let rows = db.prepare('SELECT state, queue_item_id, children FROM beta_codex_queue').all(); + assert.equal(rows[0].state, 'expired'); + assert.equal(rows[0].queue_item_id, null); + const children = JSON.parse(rows[0].children); + assert.equal(children.state, 'absent'); + assert.match(children.reason, /spawn_failed:ENOENT/); + await channel.pollOnce(); + rows = db.prepare('SELECT state FROM beta_codex_queue').all(); + assert.equal(attempts, 2); + assert.equal(witnesses, 0, 'a failed spawn must never request a process witness'); + assert.deepEqual(rows.map(row => row.state), ['expired', 'expired']); + await channel.stop(); +}); + test('queue invocation is shell-free, pins CODEX_HOME, and parses only a successful matching receipt', async () => { const calls = []; const nonce = newNonce(); @@ -349,6 +422,7 @@ test('Codex channel queues one unread snapshot and confirms it before advancing queueDb.close(); const installDb = openInstallDb(path.join(runDir, 'install.db')); + installDb.prepare('UPDATE meta SET codex_path=?, node_path=?').run(process.execPath, process.execPath); ensureCodexChannelSchema(installDb); installDb.exec("UPDATE daemon_intent SET desired='on', op_gen=4"); const queued = []; @@ -429,6 +503,8 @@ test('Codex channel queues one unread snapshot and confirms it before advancing nextMessageDb.close(); const nativeExe = path.join(root, 'codex.exe'); writeFileSync(nativeExe, 'native fixture, never executed'); + chmodSync(nativeExe, 0o755); + installDb.prepare('UPDATE meta SET codex_path=?').run(nativeExe); let windowsQueues = 0; let windowsChildState = 'present'; const verifiedWindowsChannel = createCodexQueueChannel({ diff --git a/tests/agmsgd_db.test.mjs b/tests/agmsgd_db.test.mjs index 3bee749ae..c61afe75a 100644 --- a/tests/agmsgd_db.test.mjs +++ b/tests/agmsgd_db.test.mjs @@ -19,12 +19,16 @@ test("openInstallDb applies the schema idempotently; readOnly never migrates or 0, "daemon_start_attempts is append-only history with no seed row", ); + // Simulate rc.1 without the additive Codex executable column. + db.exec("ALTER TABLE meta DROP COLUMN codex_path; UPDATE meta SET node_path='/saved/node'"); db.close(); // Re-opening (a second component, or a restart) must not add rows or // fail on the already-existing schema. const db2 = openInstallDb(path); assert.equal(db2.prepare("SELECT count(*) AS n FROM daemon_owner").get().n, 1); + assert.equal(db2.prepare("SELECT node_path, codex_path FROM meta").get().node_path, '/saved/node'); + assert.equal(db2.prepare("SELECT codex_path FROM meta").get().codex_path, null); db2.close(); // A readOnly open of a DB that was never created must not create it From 8c86a4c99c636f15f63bddce30bd037af856e44e Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 09:24:44 +0900 Subject: [PATCH 19/97] fix: present readable daemon status without duplicate seat rows (#1600) * fix: make daemon status readable and prioritize current seats * fix: suppress managed experimental notices and temporary npx conflicts * fix: expose pending confirmations for unregistered seats * fix: align status checks and storage labels with readable output --- install.sh | 17 ++-- scripts/agmsg | 2 + scripts/daemon.sh | 44 +++++++--- scripts/daemon/status.mjs | 58 +++++++++++-- scripts/lib/agmsg-launcher.sh | 6 +- scripts/lib/daemon-seats.sh | 78 +++++++++++++++-- tests/agmsgd_status_display.test.mjs | 120 +++++++++++++++++++++++++++ tests/test_agmsgd_status.bats | 2 +- tests/test_agmsgd_switch.bats | 2 +- 9 files changed, 294 insertions(+), 35 deletions(-) create mode 100644 tests/agmsgd_status_display.test.mjs diff --git a/install.sh b/install.sh index 898ff372e..9c94475ea 100755 --- a/install.sh +++ b/install.sh @@ -140,6 +140,15 @@ agmsg_source_version() { fi } +agmsg_install_restart_guidance() { + echo ' Next: finish your current work, then close and reopen each running agent.' + echo ' Resume the same conversation and run your messaging skill with actas to refresh its destination.' + printf ' Check notice delivery from that session with: bash %q status \n' "$SKILL_DIR/scripts/delivery.sh" + printf ' If you use agmsgd, check it with: bash %q daemon status\n' "$SKILL_DIR/scripts/agmsg" + echo ' If status says the enabled daemon is stopped, run agmsg daemon start.' + echo ' This installation does not enable agmsgd or switch existing sessions automatically.' +} + # --- Defaults --- CMD_NAME="" UPDATE_ONLY=false @@ -1197,9 +1206,7 @@ $_agmsg_running_team" fi agmsg_install_op_run_phase configure_codex_sandbox || exit 1 echo "" - echo " ! Restart any running agent sessions to pick up the updated scripts." - echo " In-flight watch.sh processes detect this and stand down on their own;" - echo " reopening the session brings delivery back." + agmsg_install_restart_guidance echo "" # #963: a sync engine has no equivalent self-detection, so this update # restarts one itself for every team the snapshot above found running. @@ -1430,8 +1437,8 @@ echo "" echo " ✓ Installed to ~/.agents/skills/$CMD_NAME/ (version $INSTALLED_VERSION)" echo "" echo " Next steps:" -echo " 1. Restart your agent (Claude Code / Codex / Gemini CLI / Antigravity / OpenCode) to pick up the new skill" -echo " 2. Run the command to join a team:" +agmsg_install_restart_guidance +echo " Run the command to join a team:" echo " Claude Code: /$CMD_NAME" echo " Codex: \$$CMD_NAME" echo " Gemini CLI: \$$CMD_NAME" diff --git a/scripts/agmsg b/scripts/agmsg index 8af17367c..f5095818a 100755 --- a/scripts/agmsg +++ b/scripts/agmsg @@ -13,6 +13,8 @@ usage: agmsg [args] commands: daemon start|stop|status|enable|disable manage the agmsgd beta daemon + status --all include older destination records + status --json machine-readable status --version print the installed version help show this message diff --git a/scripts/daemon.sh b/scripts/daemon.sh index b86b055ab..e19e01839 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -39,6 +39,7 @@ AGMSGD_SCHTASKS="${AGMSGD_SCHTASKS:-schtasks}" _usage() { cat >&2 <<'EOF' usage: agmsg daemon start|stop|status|enable|disable +status options: --json (machine-readable), --all (include older destinations) EOF } @@ -387,7 +388,7 @@ _unregister() { _resolve_node() { local candidate="${1:-node}" resolved resolved="$(command -v "$candidate" 2>/dev/null)" || return 1 - "$resolved" -e ' + "$resolved" --disable-warning=ExperimentalWarning -e ' const [maj, min] = process.versions.node.split(".").map(Number); if (maj < 22 || (maj === 22 && min < 13)) process.exit(1); require("node:sqlite"); @@ -524,23 +525,42 @@ cmd_stop() { cmd_status() { _require_install_db + local json=false AGMSG_DAEMON_ALL=0 option + for option in "$@"; do + case "$option" in + --json) json=true ;; + --all) AGMSG_DAEMON_ALL=1 ;; + *) _usage; return 2 ;; + esac + done + local node_path + node_path="$(_resolve_node node 2>/dev/null || true)" + if [ -n "$node_path" ]; then + local status_rc=0 + if [ "$json" = true ]; then + "$node_path" --disable-warning=ExperimentalWarning "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" --json || status_rc=$? + else + if [ "$AGMSG_DAEMON_ALL" = 1 ]; then + "$node_path" --disable-warning=ExperimentalWarning "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" --all || status_rc=$? + else + "$node_path" --disable-warning=ExperimentalWarning "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" || status_rc=$? + fi + agmsg_daemon_seat_report status + fi + return "$status_rc" + fi agmsg_daemon_read_state local health_rc=0 if [ "${AGMSGD_DESIRED:-unknown}" = on ] && [ "${AGMSGD_HEALTH:-unknown}" != ready ]; then - agmsg_daemon_recovery_text + [ "$json" = true ] || agmsg_daemon_recovery_text health_rc=1 elif [ "${AGMSGD_HEALTH:-unknown}" = unknown ]; then - echo 'agmsg daemon status: install record could not be read; health is unknown' + [ "$json" = true ] || echo 'agmsg daemon status: install record could not be read; health is unknown' health_rc=1 fi - local node_path - node_path="$(_resolve_node node 2>/dev/null || true)" - if [ -n "$node_path" ]; then - local status_rc=0 - "$node_path" "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" || status_rc=$? - agmsg_daemon_seat_report status - [ "$health_rc" -eq 0 ] || return 1 - return "$status_rc" + if [ "$json" = true ]; then + sqlite3 "$INSTALL_DB" "SELECT json_object('state',state,'intent',(SELECT desired FROM daemon_intent),'health','${AGMSGD_HEALTH:-unknown}','detail','no usable Node') FROM daemon_owner;" + return "$health_rc" fi # K14 fallback: no usable Node at all -- a minimal, honest read straight # from install.db rather than the fuller status.mjs decision text. @@ -608,7 +628,7 @@ cmd_disable() { case "${1:-}" in start) cmd_start ;; stop) cmd_stop ;; - status) cmd_status ;; + status) shift; cmd_status "$@" ;; enable) cmd_enable ;; disable) cmd_disable ;; *) _usage; exit 2 ;; diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs index ab8b13a3b..52f6f819c 100644 --- a/scripts/daemon/status.mjs +++ b/scripts/daemon/status.mjs @@ -15,7 +15,7 @@ // that says ready but a socket that refuses the connection is the // "ready, but does not connect" case, which only an // external prober (not the daemon answering about itself) can ever -// observe. Prints one JSON line to stdout; exit code follows the +// observe. Prints human-readable text, or JSON with --json; exit code follows the // table's own exit column. import { existsSync, realpathSync } from "node:fs"; @@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url"; import { isAlive } from "./executor.mjs"; import { openInstallDb } from "./db.mjs"; import { PROTOCOL_VERSION } from "./control.mjs"; +import { listCodexRegistrations } from "./channels/codex-queue-store.mjs"; const STARTING_STOPPING_GRACE_MS = 30_000; @@ -133,11 +134,17 @@ export function readOwnerAndIntentReadOnly(installRoot) { if (!String(error?.message ?? "").includes("no such table")) throw error; claudeCodeSeats = { state: "unavailable", reason: "channel_not_initialized", seats: [] }; } + let codexPending = []; + try { + codexPending = db.prepare("SELECT seat, created_at FROM beta_codex_queue WHERE state='pending' ORDER BY created_at, id").all(); + } catch (error) { + if (!String(error?.message ?? "").includes("no such table")) throw error; + } const alive = owner.state === "none" || owner.executor_pid == null ? null : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); - return { owner, intent, alive, codexSeats, claudeCodeSeats, lastAttempt }; + return { owner, intent, alive, codexSeats, claudeCodeSeats, codexPending, lastAttempt }; } finally { db.close(); } @@ -174,13 +181,15 @@ function probeSocket(socketPath, timeoutMs = 2000) { async function main() { const installRoot = process.argv[2]; + const json = process.argv.slice(3).includes("--json"); + const all = process.argv.slice(3).includes("--all"); if (!installRoot) { process.stderr.write("usage: status.mjs \n"); process.exit(2); } - let owner, intent, alive, codexSeats, claudeCodeSeats, lastAttempt; + let owner, intent, alive, codexSeats, claudeCodeSeats, codexPending, lastAttempt; try { - ({ owner, intent, alive, codexSeats, claudeCodeSeats, lastAttempt } = readOwnerAndIntentReadOnly(installRoot)); + ({ owner, intent, alive, codexSeats, claudeCodeSeats, codexPending, lastAttempt } = readOwnerAndIntentReadOnly(installRoot)); } catch (error) { // An input that can be detected as an error is reported at // that entry point, with a nonzero exit -- not a raw stack trace, and @@ -191,8 +200,8 @@ async function main() { const reachable = owner.state === "ready" && owner.socket ? await probeSocket(owner.socket) : false; const result = classify({ owner, intent, alive, reachable, lastAttempt }); - // The node:sqlite experimental-feature warning is surfaced here, always - // -- not hidden, not treated as a failure. + // The CLI suppresses Node's ExperimentalWarning category for managed SQLite + // usage. Ordinary warnings and errors are still surfaced. // // `process.exitCode = ...` and returning, NOT `process.exit(...)`: when // stdout is a pipe rather than a TTY (exactly what capturing this @@ -205,11 +214,46 @@ async function main() { // unbuffered) looked completely fine. Setting exitCode and letting the // event loop drain naturally waits for the flush first. process.stdout.write( - `${JSON.stringify({ ...result, codex_seats: codexSeats, claude_code_seats: claudeCodeSeats, node_sqlite_experimental: true, node_version: process.version })}\n`, + json ? `${JSON.stringify({ ...result, codex_seats: codexSeats, claude_code_seats: claudeCodeSeats, codex_pending: codexPending, node_sqlite_experimental: true, node_version: process.version })}\n` : `${result.text}\n${formatClaudeSeats(claudeCodeSeats, all)}${formatUnregisteredPending(installRoot, codexPending, all)}`, ); process.exitCode = result.exitCode; } +function formatUnregisteredPending(installRoot, pending, all) { + if (!pending.length) return ""; + let roster; + try { roster = listCodexRegistrations(join(installRoot, "teams")); } catch { /* Do not guess that an unreadable roster is empty. */ } + if (roster?.state !== "ok") return "Codex registrations could not be read; inspect pending confirmations with agmsg daemon status --json.\n"; + const registered = new Set(roster.seats.map(({ team, agent }) => JSON.stringify([team, agent]))); + const orphaned = pending.filter(row => !registered.has(row.seat)); + if (!orphaned.length) return ""; + let text = `Pending confirmations for unregistered seats: ${orphaned.length}. View the list with: agmsg daemon status --all\n`; + if (all) { + for (const row of orphaned) { + let label; + try { label = JSON.parse(row.seat).join("/"); } catch { label = "unidentified seat"; } + text += `Codex ${label}: unregistered; past destination; confirmation pending since ${row.created_at}\n`; + } + } + return text; +} + +function formatClaudeSeats(inventory, all) { + let omitted = 0; + const lines = []; + for (const seat of inventory?.seats ?? []) { + if (!all && (seat.state === "unaddressable" || !seat.messaging_socket)) { + omitted += 1; + continue; + } + let label; + try { label = JSON.parse(seat.seat).join("/"); } catch { label = "unidentified seat"; } + lines.push(`Claude Code ${label}: last daemon observation: ${seat.state}${seat.reason ? ` (${seat.reason})` : ""}`); + } + if (omitted) lines.push(`Claude Code seats: ${omitted} unavailable destination records hidden. View them with: agmsg daemon status --all`); + return lines.length ? `${lines.join("\n")}\n` : ""; +} + // `fileURLToPath(import.meta.url)` is realpath'd by Node's own module // loader (symlinks resolved); `process.argv[1]` is whatever the caller // passed literally and is NOT realpath'd by Node. On any system where the diff --git a/scripts/lib/agmsg-launcher.sh b/scripts/lib/agmsg-launcher.sh index 180690409..2da1b0049 100644 --- a/scripts/lib/agmsg-launcher.sh +++ b/scripts/lib/agmsg-launcher.sh @@ -116,7 +116,10 @@ agmsg_launcher_install() { esac resolved="$(command -v agmsg 2>/dev/null || true)" if [ -n "$resolved" ] && [ "$resolved" != "$target" ]; then - echo " note: 'agmsg' currently resolves to $resolved, which comes first on PATH" + case "$resolved" in + */_npx/*|*\\_npx\\*) : ;; # Temporary installer entry, not a persistent PATH conflict. + *) echo " note: 'agmsg' currently resolves to $resolved, which comes first on PATH" ;; + esac fi # Every PATH entry ahead of the launcher's directory is checked, not just the # first hit: during `npx agmsg install` a temporary entry sits first and an @@ -158,6 +161,7 @@ agmsg_launcher_find_old_npm_entry() { rest="${rest#*:}" [ -n "$dir" ] || continue [ "$dir" = "$stop" ] && return 1 + case "$dir" in */_npx/*|*\\_npx\\*) continue ;; esac if agmsg_launcher_is_old_npm_entry "$dir/agmsg"; then printf '%s\n' "$dir/agmsg" return 0 diff --git a/scripts/lib/daemon-seats.sh b/scripts/lib/daemon-seats.sh index 2c5c39303..44492e0a1 100644 --- a/scripts/lib/daemon-seats.sh +++ b/scripts/lib/daemon-seats.sh @@ -11,6 +11,8 @@ source "$SKILL_DIR/scripts/lib/role-session.sh" agmsg_daemon_seat_report() { local action="${1:-status}" cfg cfg_sql roster team agent project pid meta_type meta_project meta_pid local bridge record_status cached key driver record_type record_home label + local seen='|' omitted=0 pending owner updated recent active display + local active_lines='' recent_lines='' older_lines='' driver="$(agmsg_storage_driver)" || driver=unknown for cfg in "$SKILL_DIR/teams/"*/config.json; do [ -f "$cfg" ] || continue @@ -27,6 +29,8 @@ agmsg_daemon_seat_report() { [ -z "${FILTER_PROJECT:-}" ] || [ "$FILTER_PROJECT" = "$project" ] || continue [ -z "${FILTER_TYPE:-}" ] || [ "$FILTER_TYPE" = codex ] || continue if ! agmsg_validate_team_name "$team" >/dev/null 2>&1 || ! agmsg_validate_agent_name "$agent" >/dev/null 2>&1; then continue; fi + case "$seen" in *"|$team/$agent|"*) continue ;; esac + seen="$seen$team/$agent|" bridge=stopped if [ -e "$SKILL_DIR/run/codex-bridge.$team.$agent.pid" ]; then pid="$(cat "$SKILL_DIR/run/codex-bridge.$team.$agent.pid" 2>/dev/null)" || pid="" @@ -38,7 +42,11 @@ agmsg_daemon_seat_report() { done < "$SKILL_DIR/run/codex-bridge.$team.$agent.meta" fi case "$pid" in ''|*[!0-9]*) bridge=unknown ;; *) - if [ "$meta_pid" != "$pid" ] || [ "$meta_type" != codex ] || [ "$meta_project" != "$project" ]; then + local registered_project=0 agent_match project_match + agent_match="$(printf '%s' "$agent" | sed "s/'/''/g")" + project_match="$(printf '%s' "$meta_project" | sed "s/'/''/g")" + registered_project="$(sqlite3 :memory: "SELECT count(*) FROM json_each(CAST(readfile('$cfg_sql') AS TEXT),'\$.agents') a, json_each(a.value,'\$.registrations') r WHERE a.key='$agent_match' AND json_extract(r.value,'\$.type')='codex' AND json_extract(r.value,'\$.project')='$project_match';" 2>/dev/null)" || registered_project=0 + if [ "$meta_pid" != "$pid" ] || [ "$meta_type" != codex ] || [ "$registered_project" = 0 ]; then bridge=unknown elif _agmsg_pid_alive "$pid"; then bridge=running @@ -58,23 +66,77 @@ agmsg_daemon_seat_report() { local thread_sql thread_sql="$(printf '%s' "${AGMSG_ROLE_SESSION_UUID:-}" | sed "s/'/''/g")" cached="$(sqlite3 -readonly -cmd '.timeout 100' "$SKILL_DIR/run/install.db" "SELECT reason FROM beta_codex_seat WHERE seat=json_array('$key','$agent_sql') AND thread='$thread_sql';" 2>/dev/null)" || cached="" + pending="$(sqlite3 -readonly -cmd '.timeout 100' "$SKILL_DIR/run/install.db" "SELECT count(*) FROM beta_codex_queue WHERE seat=json_array('$key','$agent_sql') AND state='pending';" 2>/dev/null)" || pending=0 + owner="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" owner)" + updated="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" updated_at)" + updated="$(printf '%s' "$updated" | sed "s/'/''/g")" + recent="$(sqlite3 :memory: "SELECT COALESCE(strftime('%s','$updated') >= strftime('%s','now','-1 day'),0);" 2>/dev/null)" || recent=0 + active=0 + [ "$bridge" != running ] || active=1 + [ "$pending" = 0 ] || active=1 + # Bare historical thread IDs have no process witness. Do not scan all + # session markers for each old role just to infer activity. + if agmsg_instance_is_composite "$owner" && agmsg_instance_alive "$owner"; then active=1; fi + if [ "$action" = status ] && [ "${AGMSG_DAEMON_ALL:-0}" != 1 ] && [ "$active" = 0 ] && [ "$recent" != 1 ]; then + omitted=$((omitted + 1)) + continue + fi case "$cached" in thread_archived*) record_status='conversation archived; resume an active conversation and run actas again' ;; codex_home_mismatch*) record_status='incorrect CODEX_HOME record; run actas from the correct Codex profile' ;; esac label="Codex $team/$agent" if [ "${REDACTED:-0}" = 1 ]; then label='Codex seat (redacted)'; cached=""; fi + case "$cached" in + awaiting_confirmation|*pending:*|child_*|queue_child_*) record_status='waiting for delivery confirmation; unread messages are preserved' ;; + codex_executable_unavailable*) record_status='Codex executable unavailable; run agmsg daemon enable with Codex on PATH' ;; + spawn_failed:*) record_status="notice could not start ($cached); delivery will retry" ;; + queue_failed:*|queue_timeout:*|queue_cancelled:*) record_status="notice attempt needs confirmation ($cached); unread messages are preserved" ;; + thread_observation_failed) record_status='conversation could not be verified; resume it and run actas again' ;; + bridge_state_unknown*) : ;; # Current bridge evidence is authoritative for this display. + '') : ;; + *) record_status="$record_status; $cached" ;; + esac + if [ "$pending" != 0 ]; then + case "$record_status" in + *confirmation*) : ;; + *) record_status="$record_status; waiting for delivery confirmation" ;; + esac + fi case "$action:$bridge" in - disable:running) echo "$label: already using a bridge; no restart needed" ;; - disable:stopped) echo "$label: no bridge attached; restart Codex to restore notices ($record_status)" ;; - disable:unknown) echo "$label: bridge state unknown; inspect delivery.sh status codex before restarting ($record_status)" ;; - *:running) echo "$label: still using a bridge; restart Codex to move to agmsgd" ;; - *:unknown) echo "$label: bridge state unknown ($record_status)" ;; - *) echo "$label: $record_status${cached:+; last channel observation: $cached}" ;; + disable:running) display='already using a bridge; no restart needed' ;; + disable:stopped) display="no bridge attached; restart Codex to restore notices ($record_status)" ;; + disable:unknown) display="bridge could not be verified; inspect delivery.sh status codex before restarting ($record_status)" ;; + *:running) display='bridge process running; reopen Codex to switch to agmsgd' ;; + *:unknown) display="bridge could not be verified; inspect delivery.sh status codex ($record_status)" ;; + *) display="$record_status" ;; esac if [ "$driver" != sqlite ]; then - echo "$label: agmsgd beta does not support $driver storage (including JSONL). Keep using the bridge; disable agmsgd before restarting this seat." + local storage_label="$driver" + [ "$driver" != jsonl ] || storage_label=JSONL + display="$display; $storage_label storage needs the bridge. Disable agmsgd before reopening this seat" + fi + if [ "$action" != status ]; then + echo "$label: $display" + elif [ "$active" = 1 ]; then + printf -v active_lines '%s%s: %s\n' "$active_lines" "$label" "$display" + elif [ "$recent" = 1 ]; then + printf -v recent_lines '%s%s: %s\n' "$recent_lines" "$label" "$display" + else + printf -v older_lines '%s%s: %s\n' "$older_lines" "$label" "$display" fi done <<< "$roster" done + printf '%s' "$active_lines" + if [ -n "$recent_lines" ]; then + echo 'Recently recorded destinations (last 24 hours; activity not verified):' + printf '%s' "$recent_lines" + fi + if [ -n "$older_lines" ]; then + echo 'Older destination records (activity not verified):' + printf '%s' "$older_lines" + fi + if [ "$omitted" -gt 0 ]; then + echo "Codex seats: $omitted older destination records hidden. View them with: agmsg daemon status --all" + fi } diff --git a/tests/agmsgd_status_display.test.mjs b/tests/agmsgd_status_display.test.mjs new file mode 100644 index 000000000..e20067865 --- /dev/null +++ b/tests/agmsgd_status_display.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { openInstallDb } from '../scripts/daemon/db.mjs'; +import { ensureCodexChannelSchema } from '../scripts/daemon/channels/codex-queue-store.mjs'; + +const repo = fileURLToPath(new URL('../', import.meta.url)); +const thread = '01a101a5-3599-7d62-a149-4c8182bcf007'; +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'daemon-status-display-')); + cpSync(join(repo, 'scripts'), join(root, 'scripts'), { recursive: true }); + mkdirSync(join(root, 'run')); + mkdirSync(join(root, 'teams/test'), { recursive: true }); + const db = openInstallDb(join(root, 'run/install.db')); + ensureCodexChannelSchema(db); + t.after(() => db.close()); + const config = { name: 'test', agents: { alice: { registrations: [ + { type: 'codex', project: '/first' }, { type: 'codex', project: '/second' }, + ] } } }; + writeFileSync(join(root, 'teams/test/config.json'), JSON.stringify(config)); + writeFileSync(join(root, 'run/role-session.test__alice'), `type=codex\nsession=${thread}\ncodex_home=${root}\nupdated_at=${new Date().toISOString()}\n`); + // Read-only liveness evidence points at this test process, never a real seat. + writeFileSync(join(root, 'run/codex-bridge.test.alice.pid'), `${process.pid}\n`); + writeFileSync(join(root, 'run/codex-bridge.test.alice.meta'), `pid=${process.pid}\ntype=codex\nproject=/second\n`); + const status = (...flags) => execFileSync('bash', [join(root, 'scripts/daemon.sh'), 'status', ...flags], { + encoding: 'utf8', env: { ...process.env, AGMSG_CONFIG: join(root, 'config.json') }, + }); + return { root, db, config, status }; +} + +test('daemon status defaults to readable text and emits only JSON with --json', t => { + const { status } = fixture(t); + const text = status(); + assert.match(text, /agmsgd has never been started/); + assert.doesNotMatch(text, /"exitCode"|"codex_seats"/); + const json = JSON.parse(status('--json')); + assert.equal(json.exitCode, 0); + assert.equal(json.codex_seats.state, 'ok'); +}); + +test('multiple project registrations produce one seat line', t => { + const { status } = fixture(t); + assert.equal(status().split('\n').filter(line => line.startsWith('Codex test/alice:')).length, 1); +}); + +test('bridge display uses every registered project and does not repeat a contradictory cached verdict', t => { + const { db, status } = fixture(t); + db.prepare('INSERT INTO beta_codex_seat VALUES (?, ?, ?, ?, ?, ?)').run(JSON.stringify(['test', 'alice']), thread, '/profile', 'blocked', 'bridge_state_unknown', new Date().toISOString()); + const text = status(); + assert.match(text, /bridge process running/); + assert.doesNotMatch(text, /bridge.*unknown|bridge could not be verified/); +}); + +test('old destinations are counted by default and available with --all after current seats', t => { + const { root, db, config, status } = fixture(t); + config.agents.ancient = { registrations: [{ type: 'codex', project: '/old' }] }; + writeFileSync(join(root, 'teams/test/config.json'), JSON.stringify(config)); + writeFileSync(join(root, 'run/role-session.test__ancient'), 'updated_at=2020-01-01T00:00:00Z\n'); + const text = status(); + assert.doesNotMatch(text, /Codex test\/ancient:/); + assert.match(text, /1 older destination records hidden.*--all/); + const all = status('--all'); + assert.match(all, /Codex test\/ancient:/); + assert.ok(all.indexOf('Codex test/alice:') < all.indexOf('Codex test/ancient:')); + db.prepare("INSERT INTO beta_codex_queue (seat,codex_home,thread,up_to,nonce,state,children,created_at) VALUES (?, ?, ?, '{}', 'old-pending', 'pending', 'incomplete', '2020-01-01T00:00:00Z')") + .run(JSON.stringify(['test', 'ancient']), root, thread); + assert.match(status(), /Codex test\/ancient:.*waiting for delivery confirmation/); +}); + +test('installer restart guidance tells users to resume, refresh their destination, and check status', () => { + const source = readFileSync(join(repo, 'install.sh'), 'utf8'); + const fn = source.match(/agmsg_install_restart_guidance\(\) \{[\s\S]*?\n\}/)[0]; + const output = execFileSync('bash', ['-c', `${fn}\nSKILL_DIR=/tmp/isolated-install\nagmsg_install_restart_guidance`], { encoding: 'utf8' }); + assert.match(output, /close and reopen/); + assert.match(output, /Resume the same conversation.*actas/); + assert.match(output, /\/tmp\/isolated-install\/scripts\/agmsg daemon status/); + assert.match(output, /does not enable agmsgd/); + assert.equal((source.match(/^ agmsg_install_restart_guidance$/gm) ?? []).length, 1); + assert.equal((source.match(/^agmsg_install_restart_guidance$/gm) ?? []).length, 1); +}); + +test('pending confirmations remain discoverable after a seat registration is removed', t => { + const { db, root, status } = fixture(t); + db.prepare("INSERT INTO beta_codex_queue (seat,codex_home,thread,up_to,nonce,state,children,created_at) VALUES (?, ?, ?, '{}', 'removed-pending', 'pending', 'incomplete', '2020-01-01T00:00:00Z')") + .run(JSON.stringify(['test', 'removed']), root, thread); + const summary = status(); + assert.match(summary, /Pending confirmations for unregistered seats: 1.*--all/); + assert.doesNotMatch(summary, /Codex test\/removed:/); + assert.match(status('--all'), /Codex test\/removed: unregistered; past destination; confirmation pending since 2020-01-01T00:00:00Z/); + assert.equal(JSON.parse(status('--json')).codex_pending.length, 1); + assert.equal(db.prepare('SELECT state FROM beta_codex_queue').get().state, 'pending'); +}); + +test('status suppresses managed SQLite experimental notices but retains ordinary warnings', t => { + const { root } = fixture(t); + const entry = join(root, 'scripts/daemon/status.mjs'); + writeFileSync(entry, readFileSync(entry, 'utf8') + '\nprocess.emitWarning("SQLite is an experimental feature", "ExperimentalWarning");\nprocess.emitWarning("fixture ordinary warning");\n'); + const result = spawnSync('bash', [join(root, 'scripts/daemon.sh'), 'status'], { encoding: 'utf8', env: { ...process.env, AGMSG_CONFIG: join(root, 'config.json') } }); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stderr, /ExperimentalWarning|SQLite is an experimental/); + assert.match(result.stderr, /fixture ordinary warning/); +}); + +test('launcher ignores temporary npx PATH entries and still gives the permanent command check', () => { + const root = mkdtempSync(join(tmpdir(), 'launcher-npx-status-')); + const transient = join(root, '_npx/temporary/node_modules/.bin'); + mkdirSync(transient, { recursive: true }); + const entry = join(transient, 'agmsg'); + writeFileSync(entry, '#!/bin/sh\n# agmsg npm bootstrapper\n'); + chmodSync(entry, 0o755); + const output = execFileSync('bash', ['-c', 'source "$1"; agmsg_launcher_install "$2"', '_', join(repo, 'scripts/lib/agmsg-launcher.sh'), join(root, 'install')], { + encoding: 'utf8', env: { ...process.env, AGMSG_BIN_DIR: join(root, 'bin'), PATH: `${transient}:/usr/bin:/bin:/usr/sbin:/sbin` }, + }); + assert.doesNotMatch(output, /currently resolves to|_npx|older npm/); + assert.match(output, /open a new terminal and run: command -v agmsg/); +}); diff --git a/tests/test_agmsgd_status.bats b/tests/test_agmsgd_status.bats index c7fb675cd..59086cc1d 100644 --- a/tests/test_agmsgd_status.bats +++ b/tests/test_agmsgd_status.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats @test "agmsgd status: decision table for daemon_owner/daemon_intent" { - run node --test "$BATS_TEST_DIRNAME/agmsgd_status.test.mjs" + run node --test "$BATS_TEST_DIRNAME/agmsgd_status.test.mjs" "$BATS_TEST_DIRNAME/agmsgd_status_display.test.mjs" [ "$status" -eq 0 ] } diff --git a/tests/test_agmsgd_switch.bats b/tests/test_agmsgd_switch.bats index 79eb9d3fb..53c2e5662 100644 --- a/tests/test_agmsgd_switch.bats +++ b/tests/test_agmsgd_switch.bats @@ -209,7 +209,7 @@ WRAPPER } @test "status on a never-started enabled install fails with recovery and missing destinations" { - run bash "$SCRIPTS/daemon.sh" status + run bash "$SCRIPTS/daemon.sh" status --all [ "$status" -eq 1 ] assert_contains 'agmsg daemon start' assert_contains 'no destination record' From 7ab801d5fa655eda7bb06b98f1ca50b14de3abdc Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 09:43:47 +0900 Subject: [PATCH 20/97] daemon: explain and stop when systemd user services are unavailable (#1599) On a machine with no systemd user manager (a container, a minimal image), `agmsg daemon enable` ran into `systemctl: command not found` three times, then printed only a cleanup failure, and `disable` failed the same way. Check `systemctl --user` before enable touches anything and, when it does not work, say why and what to run instead (`agmsg daemon start`), leaving install.db and the unit directory unchanged. When a unit file is left over and systemd cannot be reached, disable removes the file instead of failing. --- scripts/daemon.sh | 25 ++++++++++++++++++++----- tests/test_agmsgd_daemon_sh.bats | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/scripts/daemon.sh b/scripts/daemon.sh index e19e01839..33023e58a 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -243,6 +243,10 @@ _unregister_darwin() { fi } +# False on a machine with no systemd user manager (a container, WSL1, a +# minimal image): no systemctl at all, or one that cannot reach a user bus. +_systemd_user_usable() { "$AGMSGD_SYSTEMCTL" --user show-environment >/dev/null 2>&1; } + _systemd_unit_path() { printf '%s/.config/systemd/user/agmsgd-%s.service' "$HOME" "$(_unit_id)"; } # NOT YET RUN ON LINUX in this environment -- configured with @@ -276,12 +280,18 @@ _unregister_linux() { unit_path="$(_systemd_unit_path)" unit_name="$(basename "$unit_path")" if [ -f "$unit_path" ]; then - if ! "$AGMSGD_SYSTEMCTL" --user disable --now "$unit_name"; then - echo "agmsg daemon: could not disable systemd user service $unit_name" >&2 - return 1 + # With no systemd user manager there is nothing registered with it to + # undo; drop the unit file so disable can still finish. + if _systemd_user_usable; then + if ! "$AGMSGD_SYSTEMCTL" --user disable --now "$unit_name"; then + echo "agmsg daemon: could not disable systemd user service $unit_name" >&2 + return 1 + fi + rm -f "$unit_path" + "$AGMSGD_SYSTEMCTL" --user daemon-reload + else + rm -f "$unit_path" fi - rm -f "$unit_path" - "$AGMSGD_SYSTEMCTL" --user daemon-reload fi } @@ -575,6 +585,11 @@ cmd_status() { cmd_enable() { _require_install_db + if [ "$(_os)" = linux ] && ! _systemd_user_usable; then + echo "agmsg daemon enable: systemd user services are not available here (systemctl --user does not work), so agmsgd cannot be registered to start on its own. Nothing was changed. 'agmsg daemon start' runs it for this session only." >&2 + _path_hint start >&2 + return 1 + fi local node_path node_path="$(_resolve_node node)" || { echo "agmsg daemon enable: no usable Node found (need >= 22.13.0 with node:sqlite). Install one and try again." >&2 diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index 1bfcd87aa..43f6ab8c7 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -383,3 +383,32 @@ EOF [ ! -f "$marker" ] [ ! -f "$TEST_SKILL_DIR/run/agmsgd-$(_unit_id).xml" ] } + +@test "daemon.sh enable/disable: without a systemd user manager enable explains and changes nothing, disable still works" { + _seed_install_db + _write_completion_record + mkdir -p "$TEST_SKILL_DIR/fake-linux" + printf '#!/usr/bin/env bash\necho Linux\n' > "$TEST_SKILL_DIR/fake-linux/uname" + chmod +x "$TEST_SKILL_DIR/fake-linux/uname" + local before + before="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT coalesce(desired, '') || '|' || (SELECT node_path FROM meta) FROM daemon_intent;")" + + PATH="$TEST_SKILL_DIR/fake-linux:$PATH" AGMSGD_SYSTEMCTL="$TEST_SKILL_DIR/no-such-systemctl" \ + run bash "$DAEMON" enable + [ "$status" -eq 1 ] + echo "$output" | grep -q "systemd user services are not available" + ! echo "$output" | grep -q "command not found" || false + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT coalesce(desired, '') || '|' || (SELECT node_path FROM meta) FROM daemon_intent;")" = "$before" ] + [ ! -e "$HOME/.config/systemd/user" ] + + # A unit file left by an earlier enable must not make disable fail either. + local unit_id unit + unit_id="$(printf '%s:%s' "$TEST_SKILL_DIR" daemon-sh-test | shasum -a 256 | cut -c1-12)" + unit="$HOME/.config/systemd/user/agmsgd-$unit_id.service" + mkdir -p "$(dirname "$unit")" + : > "$unit" + PATH="$TEST_SKILL_DIR/fake-linux:$PATH" AGMSGD_SYSTEMCTL="$TEST_SKILL_DIR/no-such-systemctl" \ + run bash "$DAEMON" disable + [ "$status" -eq 0 ] + [ ! -e "$unit" ] +} From 4e53e851a905e2807c23d541a2f61b4863159fce Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 10:22:53 +0900 Subject: [PATCH 21/97] claude-code session start: pick the delivery seat from registered pairs, say why when not switching (#1597) * claude-code session start: pick the delivery seat from registered pairs and say why when not switching The switch to agmsgd direct delivery looked the seat up with agmsg_role_session_match_unique, which counts every record of this session. A leftover record of a team that is no longer registered made the answer ambiguous, and a record with no session row matched nothing; both dropped the switch silently and fell back to the Monitor directive. Choose the seat the way the resume path does: only this project's registered (team, agent) pairs count, and a pair is a candidate when its record carries this session or this session owns its actas lock. Still exactly one or none, but every case that does not switch now prints one line with the reason and the next step. * claude-code session start: switch to direct delivery for a lone registered seat with no record With no record and no actas lock for this session, the plug stood down even when the project has exactly one registered pair of this type, which is the seat the Monitor directive itself would watch. Take that seat: write its record for this session, claim its lock, and switch. With several registered pairs and nothing recorded, print the reason and fall back to the Monitor directive. * claude-code session start: state the claim failure neutrally and drop the actas hint for several candidates The failure line asserted the seat was held by another live session, but actas_lock_claim also fails for unreadable, ambiguous and undecidable locks, and the old fallback overwrote the reason it had printed. Keep the reason and say only that the seat could not be claimed. For several candidate seats, actas does not clear the extra records, so the line no longer promises that; it points to the Monitor. * actas: switch a Claude Code seat to direct delivery when it is claimed; create a lone seat's record only after the claim actas-claim.sh now publishes the messaging socket for the claimed seat when agmsgd is ready and prints delivery=native, so a seat chosen by actas moves to the new path whatever the number of registered seats; the actas procedure skips the Monitor on that line. The socket and config-dir guards and the publish-and-read-back step move into role-session.sh and are shared with the SessionStart plug. In the plug, a lone registered seat's missing record is created only after the lock claim succeeds. Creating it first overwrote the record of another live session that holds the lock. * Declare the Claude native channel only when the daemon will accept the storage agmsgd's Claude channel blocks any storage other than sqlite and any unreadable config. actas-claim and the SessionStart plug declared native delivery on daemon-ready plus a socket alone, which for such an install left the seat with neither the daemon nor a Monitor. Both now check the same rule the daemon applies (a shared helper) and keep the Monitor path otherwise. * Make the native storage gate refuse what the daemon's config reader refuses The gate treated any absent path as a missing config and followed symlinks. The daemon lstat()s the file: a symlink or non-regular file is refused, only a true ENOENT is the sqlite default, and a parent it cannot search is an error. Match that, so a native declaration is never made for an install the daemon will not deliver to. * Ask the daemon's own reader whether its Claude channel accepts the storage Mimicking the daemon's config rules in bash drifted from them (parent directory permissions, symlinks). The native-channel gate now runs a small entry that calls the channel's readStorageDriver with the path the channel resolves, and treats a missing node or a failed check as not ready. * Run the storage gate with the node the daemon runs on The gate used the first node on PATH. An older PATH node fails the check while the daemon, running on the node recorded at enable, is fine, so the native channel was never declared. Read node_path from install.db and run the check with it; an unreadable or missing node_path is not ready. * Put the delivery=native step in the SKILL template, not only the rendered file The step was added to the repo-root SKILL.md directly. That file is rendered from the Claude Code template, so a fresh render dropped the line and the render-match test failed. Add it to the template; the rendered file is unchanged. --- SKILL.md | 2 +- scripts/actas-claim.sh | 26 +++++ scripts/daemon/channels/storage-gate.mjs | 17 +++ .../types/claude-code/_session-start.sh | 110 +++++++++++------- scripts/drivers/types/claude-code/template.md | 2 +- scripts/lib/role-session.sh | 49 ++++++++ ...sion_start_claude_code_native_channel.bats | 95 +++++++++++++-- 7 files changed, 249 insertions(+), 52 deletions(-) create mode 100644 scripts/daemon/channels/storage-gate.mjs diff --git a/SKILL.md b/SKILL.md index f118017cf..69e30f69a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -208,7 +208,7 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): a. Find this session's agmsg watch Monitor task: a task in TaskList whose description begins with "agmsg inbox stream", or the task_id returned by a Monitor call you made earlier in this conversation. Not every environment lists Monitor tasks in TaskList, so an empty list does not mean none is running. b. **If you have such a task_id**: TaskStop it. c. **If you have none** (typical when /agmsg actas runs as the first command of a fresh session — SessionStart hasn't fired the Monitor directive yet, or you're invoking actas before the agent acted on it): skip TaskStop. Do NOT attempt TaskStop with a guessed or empty task_id — it will fail with "Invalid tool parameters" and confuse the flow. Starting a new watcher for this session in step d replaces any previous one. - d. Run `~/.agents/skills/agmsg/scripts/delivery.sh status claude-code "$(pwd)"` and read its **first line**. + d. **If the step 4 output had a `delivery=native` line**: agmsgd now delivers to this session directly, so start no Monitor and skip the rest of this step. Otherwise run `~/.agents/skills/agmsg/scripts/delivery.sh status claude-code "$(pwd)"` and read its **first line**. - **`mode: monitor` or `mode: both`**: invoke a fresh Monitor, regardless of whether step b or c applied: - command: `~/.agents/skills/agmsg/scripts/watch.sh $CLAUDE_CODE_SESSION_ID "$(pwd)" claude-code --max-seconds=1750` - description: `agmsg inbox stream (acting as )` diff --git a/scripts/actas-claim.sh b/scripts/actas-claim.sh index 9e707fb79..b633df288 100755 --- a/scripts/actas-claim.sh +++ b/scripts/actas-claim.sh @@ -140,6 +140,31 @@ while IFS= read -r team; do agmsg_role_session_record "$team" "$NAME" "$BARE_SID" "$PROJECT_PHYS" "$TYPE" "$SESSION_ID" || true done <<< "$TEAMS" +# Once the seat is decided, move this Claude Code session onto agmsgd's direct +# delivery when the daemon is ready and the session has a usable messaging +# socket, so a seat chosen by actas does not depend on how many seats the +# project registers. Prints "delivery=native" after the status line; the actas +# procedure then skips the Monitor. Anything short of a verified record leaves +# the Monitor path as it was. +NATIVE_DELIVERY=0 +if [ "$TYPE" = claude-code ] && [ -n "${SKILL_DIR:-}" ] \ + && _native_target="$(agmsg_claude_code_native_target 2>/dev/null)" \ + && [ -r "$SKILL_DIR/scripts/lib/daemon-state.sh" ]; then + # shellcheck source=scripts/lib/daemon-state.sh + . "$SKILL_DIR/scripts/lib/daemon-state.sh" + command -v _agmsg_detect_platform >/dev/null 2>&1 || . "$SKILL_DIR/scripts/lib/compat.sh" + _agmsg_detect_platform + agmsg_daemon_read_state + if [ "${AGMSGD_HEALTH:-}" = ready ] && [ "$_agmsg_platform" != msys ] \ + && agmsg_claude_code_native_storage_ready; then + NATIVE_DELIVERY=1 + while IFS= read -r team; do + [ -z "$team" ] && continue + agmsg_role_session_publish_messaging "$team" "$NAME" "${_native_target%%$'\t'*}" "${_native_target#*$'\t'}" "$BARE_SID" || NATIVE_DELIVERY=0 + done <<< "$TEAMS" + fi +fi + # A monitored Codex seat's dispatcher reads its role pair from the seat request # rather than inferring it from the project roster. Actas can change the role # after SessionStart, so publish the new pair (or an empty pair when the claim @@ -261,4 +286,5 @@ while IFS= read -r team; do printf ' team=%s' "$team" done <<< "$TEAMS" printf '\n' +[ "$NATIVE_DELIVERY" -ne 1 ] || echo "delivery=native" exit 0 diff --git a/scripts/daemon/channels/storage-gate.mjs b/scripts/daemon/channels/storage-gate.mjs new file mode 100644 index 000000000..0e20b5cc9 --- /dev/null +++ b/scripts/daemon/channels/storage-gate.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +// Exit 0 only when agmsgd's Claude channel will accept this install's storage +// config. It calls the channel's own reader (readStorageDriver) with the same +// config path the channel resolves, so the answer cannot drift from the +// daemon's rules. Used by actas-claim.sh and the claude-code SessionStart plug +// before they declare the native channel (and drop the Monitor). +import { homedir } from "node:os"; +import { join } from "node:path"; +import { readStorageDriver } from "./codex-queue-store.mjs"; + +try { + const configPath = process.env.AGMSG_CONFIG || join(homedir(), ".agents", "agmsg", "config.json"); + const driver = readStorageDriver(configPath); + process.exit(driver.state === "ok" && driver.driver === "sqlite" ? 0 : 1); +} catch { + process.exit(1); +} diff --git a/scripts/drivers/types/claude-code/_session-start.sh b/scripts/drivers/types/claude-code/_session-start.sh index 6a8bce213..77d35709a 100644 --- a/scripts/drivers/types/claude-code/_session-start.sh +++ b/scripts/drivers/types/claude-code/_session-start.sh @@ -18,28 +18,17 @@ # to ~/.claude, since this machine's own accounts live elsewhere. agmsg_session_start() { - if ! declare -F agmsg_role_session_set_messaging >/dev/null 2>&1; then + if ! declare -F agmsg_claude_code_native_target >/dev/null 2>&1; then # shellcheck disable=SC1091 . "${SKILL_DIR:-}/scripts/lib/role-session.sh" fi - # Same guard shape codex-record-session.sh applies to CODEX_HOME: anything - # that is not an absolute path, or that carries a control character, is - # never published as a delivery destination (a malformed value is worse - # than none -- it would look addressable and silently never deliver). - local raw_socket="${CLAUDE_CODE_MESSAGING_SOCKET:-}" socket="" - case "$raw_socket" in - uds:/*) socket="${raw_socket#uds:}" ;; - /*) socket="$raw_socket" ;; - esac - case "$socket" in *[[:cntrl:]]*) socket="" ;; esac - - local config_dir="${CLAUDE_CONFIG_DIR:-${HOME:+$HOME/.claude}}" - case "$config_dir" in - *[[:cntrl:]]*) config_dir="" ;; - /*) ;; - *) config_dir="" ;; - esac + # A malformed or missing destination is never published (see + # agmsg_claude_code_native_target). + local target socket config_dir + target="$(agmsg_claude_code_native_target)" || return 0 + socket="${target%%$'\t'*}" + config_dir="${target#*$'\t'}" # Check every OTHER precondition FIRST, before ever touching a lock # (#1577 re-review): a session that cannot possibly use the native path @@ -48,7 +37,6 @@ agmsg_session_start() { # agmsgd disabled or no socket could still silently seize an actas lock it # was never going to use -- only Monitor's own watch.sh was ever supposed # to touch that lock in that case. - if [ -z "$socket" ]; then return 0; fi if ! command -v _agmsg_detect_platform >/dev/null 2>&1; then # shellcheck disable=SC1091 . "${SKILL_DIR:-}/scripts/lib/compat.sh" @@ -61,31 +49,67 @@ agmsg_session_start() { fi agmsg_daemon_read_state [ "${AGMSGD_HEALTH:-}" = ready ] || return 0 + if ! agmsg_claude_code_native_storage_ready; then + echo "AGMSG delivery: agmsgd is running, but its Claude channel only reads the sqlite storage and this install's storage config is not usable for it, so the native channel is not used. Next: arm the Monitor below." + return 0 + fi # Claim ONLY the single role this session is actually resuming as -- never # loop over every pair whose record happens to share this bare sid # (#1577 re-review: the same conversation can carry session= # into MORE THAN ONE role's record over its life, e.g. actas alice then # later actas bob; a resumed session must take back only whichever one - # role it is now, not seize every stale name it ever wore). Delegates to - # agmsg_role_session_match_unique -- the SAME ambiguity rule session- - # start.sh's own narrowing uses elsewhere in this file: a second - # qualifying record makes the whole lookup refuse rather than guess. - if ! command -v agmsg_role_session_match_unique >/dev/null 2>&1 \ - || ! command -v actas_lock_claim >/dev/null 2>&1; then + # role it is now, not seize every stale name it ever wore). + # + # The seat is chosen the way session-start.sh's own resume does: only the + # (team, agent) pairs registered for THIS project count, so a record left + # by a team that is no longer registered cannot make the answer ambiguous. + # A pair is a candidate when its role-session record carries this bare sid, + # or when this sid owns its actas lock (a record with no session row is + # still this session's seat). Still exactly one or none: a second + # candidate refuses rather than guesses -- but says so. + if ! command -v actas_lock_claim >/dev/null 2>&1; then # shellcheck disable=SC1091 . "${SKILL_DIR:-}/scripts/lib/actas-lock.sh" fi - local bare_sid my_instance project_phys match pair_team pair_agent + local bare_sid my_instance pair_team="" pair_agent="" bare_sid="$(agmsg_instance_bare_sid "${SESSION_ID:-}" 2>/dev/null || true)" my_instance="$(agmsg_normalize_instance_id "${SESSION_ID:-}" "${TYPE:-}" 2>/dev/null || true)" [ -n "$bare_sid" ] || return 0 agmsg_instance_is_composite "$my_instance" 2>/dev/null || return 0 - project_phys="$(agmsg_canonical_path "${PROJECT:-}" 2>/dev/null || printf '%s' "${PROJECT:-}")" - match="$(agmsg_role_session_match_unique "${TYPE:-}" "$project_phys" "$bare_sid" 2>/dev/null)" || return 0 - pair_team="${match%%$'\t'*}" - pair_agent="${match#*$'\t'}" - [ -n "$pair_team" ] && [ -n "$pair_agent" ] || return 0 + local _tab _p_team _p_agent _rs _own _owner _n=0 + _tab="$(printf '\t')" + while IFS="$_tab" read -r _p_team _p_agent; do + [ -n "$_p_team" ] && [ -n "$_p_agent" ] || continue + _rs="$(agmsg_role_session_uuid "$_p_team" "$_p_agent" 2>/dev/null || true)" + _owner="" + _own="$(actas_lock_read "$_p_team" "$_p_agent" 2>/dev/null)" || _own="" + if [ "${_own%%"$_tab"*}" = ok ]; then + _owner="$(agmsg_instance_bare_sid "${_own#*"$_tab"}" 2>/dev/null || true)" + fi + if [ "$_rs" = "$bare_sid" ] || [ "$_owner" = "$bare_sid" ]; then + _n=$((_n + 1)); pair_team="$_p_team"; pair_agent="$_p_agent" + fi + done < (it switches to direct delivery), or arm the Monitor below." + return 0 + fi + elif [ "$_n" -gt 1 ]; then + echo "AGMSG delivery: agmsgd is running, but this session is recorded under $_n registered seats, so the native channel is not used. Next: arm the Monitor below; the extra records stay until they are cleaned up." + return 0 + fi # actas_lock_claim is the write gate -- the SAME primitive watch.sh uses to # reclaim a role's lock across --resume (session-start.sh's own 4th-arg @@ -98,23 +122,29 @@ agmsg_session_start() { # "owning" the same pair, while a real --resume (new pid, old pid now # dead) correctly reclaims. local claim_result - claim_result="$(actas_lock_claim "$pair_team" "$pair_agent" "$my_instance" 2>/dev/null)" || claim_result="unknown:claim_failed" - [ "$claim_result" = ok ] || return 0 + claim_result="$(actas_lock_claim "$pair_team" "$pair_agent" "$my_instance" 2>/dev/null || true)" + [ -n "$claim_result" ] || claim_result="unknown:claim_failed" + if [ "$claim_result" != ok ]; then + echo "AGMSG delivery: agmsgd is running, but seat $pair_agent@$pair_team could not be claimed ($claim_result), so the native channel is not used. Next: arm the Monitor below." + return 0 + fi # Write, then read back: this is the SAME check the daemon itself applies # (messaging_socket/claude_config_dir both present and non-empty) before # it will ever address this seat, so "the record actually took" must be # verified here too, not assumed from the write call returning. - agmsg_role_session_set_messaging "$pair_team" "$pair_agent" "$socket" "$config_dir" "$bare_sid" - local readback_socket readback_config_dir readback_session - readback_socket="$(agmsg_role_session_get "$pair_team" "$pair_agent" messaging_socket 2>/dev/null || true)" - readback_config_dir="$(agmsg_role_session_get "$pair_team" "$pair_agent" claude_config_dir 2>/dev/null || true)" - readback_session="$(agmsg_role_session_uuid "$pair_team" "$pair_agent" 2>/dev/null || true)" - if [ "$readback_socket" = "$socket" ] && [ -n "$config_dir" ] && \ - [ "$readback_config_dir" = "$config_dir" ] && [ "$readback_session" = "$bare_sid" ]; then + # Only now, holding the lock, may a missing record be created: an existing + # record belongs to whoever it names, and the claim above is what shows the + # seat is ours. + if [ -z "$(agmsg_role_session_get "$pair_team" "$pair_agent" type 2>/dev/null || true)" ]; then + agmsg_role_session_record "$pair_team" "$pair_agent" "$bare_sid" \ + "$(agmsg_canonical_path "${PROJECT:-}" 2>/dev/null || printf '%s' "${PROJECT:-}")" "${TYPE:-}" "$my_instance" + fi + if agmsg_role_session_publish_messaging "$pair_team" "$pair_agent" "$socket" "$config_dir" "$bare_sid"; then cat < --max-seconds=1750` - description: `agmsg inbox stream (acting as )` diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index 8985c3e96..0f99a7996 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -475,3 +475,52 @@ agmsg_role_session_match_unique() { printf '%s\n' "$match" return 0 } + +# Print "\t" for this Claude Code process, or +# nothing (rc 1) when it has no usable messaging socket. Same guard shape +# codex-record-session.sh applies to CODEX_HOME: anything that is not an +# absolute path, or that carries a control character, is never published as a +# delivery destination (a malformed value would look addressable and silently +# never deliver). CLAUDE_CONFIG_DIR falls back to ~/.claude only when unset; +# it must never be hardcoded, since accounts can live elsewhere. +agmsg_claude_code_native_target() { + local raw="${CLAUDE_CODE_MESSAGING_SOCKET:-}" socket="" config_dir="${CLAUDE_CONFIG_DIR:-${HOME:+$HOME/.claude}}" + case "$raw" in + uds:/*) socket="${raw#uds:}" ;; + /*) socket="$raw" ;; + esac + case "$socket" in *[[:cntrl:]]*) socket="" ;; esac + case "$config_dir" in + *[[:cntrl:]]*) config_dir="" ;; + /*) ;; + *) config_dir="" ;; + esac + [ -n "$socket" ] && [ -n "$config_dir" ] || return 1 + printf '%s\t%s\n' "$socket" "$config_dir" +} + +# Publish / into /'s record for , +# then read it back: the same check the daemon applies before it will address +# the seat, so "the record took" is verified, not assumed. rc 0 only when it did. +agmsg_role_session_publish_messaging() { # + local team="$1" agent="$2" socket="$3" config_dir="$4" bare_sid="$5" + agmsg_role_session_set_messaging "$team" "$agent" "$socket" "$config_dir" "$bare_sid" + [ "$(agmsg_role_session_get "$team" "$agent" messaging_socket 2>/dev/null || true)" = "$socket" ] \ + && [ "$(agmsg_role_session_get "$team" "$agent" claude_config_dir 2>/dev/null || true)" = "$config_dir" ] \ + && [ "$(agmsg_role_session_uuid "$team" "$agent" 2>/dev/null || true)" = "$bare_sid" ] +} + +# rc 0 only when agmsgd's Claude channel will accept this install's storage. +# The answer comes from the daemon's own reader (storage-gate.mjs calls +# readStorageDriver), run with the node the daemon itself runs on (the +# node_path recorded at enable), not whatever node is first on PATH: an older +# PATH node would fail the check while the daemon is fine. Declaring the +# native channel when the daemon will refuse to deliver would leave the seat +# with neither the daemon nor a Monitor; an unreadable node_path or a failed +# check is "not ready". +agmsg_claude_code_native_storage_ready() { + local node_path + node_path="$(sqlite3 -readonly -cmd '.timeout 100' "${SKILL_DIR:-}/run/install.db" 'SELECT node_path FROM meta;' 2>/dev/null)" || return 1 + [ -n "$node_path" ] && [ -x "$node_path" ] || return 1 + "$node_path" "${SKILL_DIR:-}/scripts/daemon/channels/storage-gate.mjs" >/dev/null 2>&1 +} diff --git a/tests/test_session_start_claude_code_native_channel.bats b/tests/test_session_start_claude_code_native_channel.bats index 333f9cfee..2045ced91 100644 --- a/tests/test_session_start_claude_code_native_channel.bats +++ b/tests/test_session_start_claude_code_native_channel.bats @@ -37,7 +37,7 @@ _mark_daemon_ready() { Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; *) skip 'POSIX beta executor evidence' ;; esac - sqlite3 "$RUN_DIR/install.db" "UPDATE daemon_intent SET desired='on'; + sqlite3 "$RUN_DIR/install.db" "UPDATE meta SET node_path='$(command -v node)'; UPDATE daemon_intent SET desired='on'; UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='$boot', executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" } @@ -102,23 +102,42 @@ _mark_daemon_ready() { grep -qF "agmsgd is running and will deliver" <<<"$output" } +# rc.2: an old record of the same session under a team that is no longer +# registered used to make the seat lookup ambiguous, so the switch to direct +# delivery was dropped without a word. Only registered pairs count now. +@test "session-start: a stale record of an unregistered team does not block the switch to direct delivery" { + _mark_daemon_ready + env AGMSG_AGENT_PID=$$ bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "sid-stale-twin" >/dev/null + source "$SCRIPTS/lib/role-session.sh" + agmsg_role_session_record gone-team alice "sid-stale-twin" "$PROJ" claude-code + run env AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-stale-twin.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-stale-twin"}' + [ "$status" -eq 0 ] + refute grep -q "AGMSG monitor mode" <<<"$output" + grep -qF "agmsgd is running and will deliver" <<<"$output" +} + @test "session-start: agmsgd not running -> the usual Monitor directive still fires" { run _run_session_start "sid-no-daemon" [ "$status" -eq 0 ] grep -qF "AGMSG monitor mode" <<<"$output" } -# The counterexample #1577 review required: daemon ready is NOT enough by -# itself. A seat that was only joined (never actas-claimed, so it has no -# role-session record at all, let alone a messaging_socket field) must keep -# its only delivery path -- Monitor -- rather than be silenced on the -# assumption the daemon can reach it. -@test "session-start: agmsgd ready but NO role-session record -> Monitor directive still fires" { +# #1577 review required a seat with no record to keep Monitor, since the +# daemon cannot address it. rc.2 (the new path is the default) +# reverses that for the one case where the seat is not in doubt: the project +# has a single registered pair of this type, which is the seat the Monitor +# directive itself would watch. Nothing is recorded and no lock exists (the +# shape of a seat never recorded); the switch writes the record and takes the lock. +@test "session-start: agmsgd ready, NO record, NO lock, one registered seat -> switches to direct delivery" { _mark_daemon_ready - run _run_session_start "sid-ready-no-record" + run env AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-ready-no-record.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-ready-no-record"}' [ "$status" -eq 0 ] - grep -qF "AGMSG monitor mode" <<<"$output" - refute grep -q "agmsgd is running and will deliver" <<<"$output" + refute grep -q "AGMSG monitor mode" <<<"$output" + grep -qF "agmsgd is running and will deliver" <<<"$output" + source "$SCRIPTS/lib/role-session.sh" + [ "$(agmsg_role_session_get team alice messaging_socket)" = /tmp/cc-socks/sid-ready-no-record.sock ] } # #1577 re-review: comparing only the BARE session id let one process @@ -129,6 +148,62 @@ _mark_daemon_ready() { # own SESSION_ID is the SAME bare "shared" but a DIFFERENT live pid, so the # caller's own composite id differs from bob's lock owner even though their # bare sids are identical. Only an exact composite match may write. +# The seat's record belongs to whoever it names: another live session holding +# the lock must keep its record even when the record lacks a type line. +@test "session-start: a lone seat held by another live session keeps that session's record" { + _mark_daemon_ready + source "$SCRIPTS/lib/role-session.sh" + source "$SCRIPTS/lib/actas-lock.sh" + agmsg_role_session_record team alice peer "$PROJ" "" + actas_lock_claim team alice "peer.$$" >/dev/null + run env AGMSG_AGENT_PID=1 AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/new.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"new"}' + [ "$status" -eq 0 ] + [ "$(agmsg_role_session_uuid team alice)" = peer ] +} + +# The daemon's Claude channel blocks any non-sqlite storage, so declaring the +# native channel there would leave the seat with no delivery at all. +@test "actas-claim: a non-sqlite storage config never declares delivery=native" { + _mark_daemon_ready + printf '{"storage":"jsonl"}\n' > "$BATS_TEST_TMPDIR/config.json" + run env AGMSG_CONFIG="$BATS_TEST_TMPDIR/config.json" AGMSG_AGENT_PID=$$ CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-jsonl.sock" \ + bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "sid-jsonl" + [ "$status" -eq 0 ] + refute grep -qx "delivery=native" <<<"$output" + run env AGMSG_CONFIG="$BATS_TEST_TMPDIR/config.json" AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-jsonl.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-jsonl"}' + grep -qF "AGMSG monitor mode" <<<"$output" + refute grep -q "agmsgd is running and will deliver" <<<"$output" +} + +# The gate asks the daemon's own config reader, so what the daemon refuses +# (here a symlinked config) is refused here too. +@test "native storage gate: a symlinked sqlite config is refused, as the daemon refuses it" { + _mark_daemon_ready + source "$SCRIPTS/lib/role-session.sh" + mkdir -p "$BATS_TEST_TMPDIR/cfg" + printf '{"storage":"sqlite"}\n' > "$BATS_TEST_TMPDIR/cfg/real.json" + ln -s "$BATS_TEST_TMPDIR/cfg/real.json" "$BATS_TEST_TMPDIR/cfg/link.json" + AGMSG_CONFIG="$BATS_TEST_TMPDIR/cfg/real.json" agmsg_claude_code_native_storage_ready + run env AGMSG_CONFIG="$BATS_TEST_TMPDIR/cfg/link.json" SKILL_DIR="$SKILL_DIR" \ + bash -c '. "$1/scripts/lib/role-session.sh"; agmsg_claude_code_native_storage_ready' _ "$SCRIPTS/.." + [ "$status" -ne 0 ] +} + +# A seat chosen by actas moves to direct delivery on the spot, even when the +# project registers several seats (session-start alone cannot pick one then). +@test "actas-claim: daemon ready and a messaging socket -> records the socket and prints delivery=native" { + _mark_daemon_ready + bash "$SCRIPTS/join.sh" team bob claude-code "$PROJ" >/dev/null + run env AGMSG_AGENT_PID=$$ CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-actas-native.sock" \ + bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code bob "sid-actas-native" + [ "$status" -eq 0 ] + grep -qx "delivery=native" <<<"$output" + source "$SCRIPTS/lib/role-session.sh" + [ "$(agmsg_role_session_get team bob messaging_socket)" = /tmp/cc-socks/sid-actas-native.sock ] +} + @test "session-start: a different live pid with the SAME bare session id never overwrites that seat's record" { _mark_daemon_ready bash "$SCRIPTS/join.sh" team bob claude-code "$PROJ" >/dev/null From 82d30b74b3c3480c1e679e20c44b3dfa8731b203 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 10:32:00 +0900 Subject: [PATCH 22/97] Claude channel: keep one inbox nudge until the seat or socket changes (#1602) * Claude channel: send one inbox nudge and keep it until the seat or socket changes The channel expired an unconfirmed nudge after 60 seconds and sent a new one on the next poll. Claude Code holds a message received mid-work until the work pauses, so a seat that works for a minute or more collected a nudge per minute, and a wake from sleep released a night's worth at once. A nudge now stays pending for as long as its socket exists and the seat's record still names the same socket and session; no clock is read. It is sent again only when the socket is gone, the record names another socket or session (/compact, restart, --resume), or the send itself failed. While one is outstanding no second one is sent for newer messages. The transcript observation still confirms a delivery and still feeds the seat status, but no longer triggers a resend. The Claude Code queue support tests had no bats wrapper and did not run in CI; add one. * Claude channel: only a plainly missing socket makes a pending nudge stale A socket check that could not run (stat failed, wrong owner) was read as the socket being gone, so a transient failure that then recovered expired a nudge the seat still held and sent a duplicate. Only messaging_socket_missing ends it; an inconclusive check keeps the nudge pending and shows its reason. --- .../daemon/channels/claude-code-queue-io.mjs | 36 +++----- scripts/daemon/channels/claude-code-queue.mjs | 54 +++++++----- tests/agmsgd_claude_code_queue.test.mjs | 87 ++++++++++++++----- tests/test_agmsgd_claude_code_queue.bats | 6 ++ 4 files changed, 118 insertions(+), 65 deletions(-) create mode 100644 tests/test_agmsgd_claude_code_queue.bats diff --git a/scripts/daemon/channels/claude-code-queue-io.mjs b/scripts/daemon/channels/claude-code-queue-io.mjs index d7c446b3d..9189efbb4 100644 --- a/scripts/daemon/channels/claude-code-queue-io.mjs +++ b/scripts/daemon/channels/claude-code-queue-io.mjs @@ -16,7 +16,7 @@ import { closeSync, lstatSync, openSync, readFileSync, readSync, statSync } from import process from "node:process"; import { isAbsolute } from "node:path"; -export { newNonce, inboxNudge, QUEUE_CONFIRMATION_TTL_MS } from "./codex-queue-io.mjs"; +export { newNonce, inboxNudge } from "./codex-queue-io.mjs"; const SEND_TIMEOUT_MS = 5_000; // Bound the transcript read to its tail: a long-lived seat's jsonl grows @@ -50,7 +50,7 @@ export function resolveTranscriptPath(claudeConfigDir, project, sessionId) { // record must never be written to on trust alone. A symlinked path is // refused the same way every other agmsg state-file reader on this codebase // refuses one. -function checkSocketOwnership(socketPath) { +export function checkSocketOwnership(socketPath) { let stat; try { stat = lstatSync(socketPath); @@ -172,27 +172,15 @@ export function observeTranscript(transcriptPath, nonce) { return { state: "absent" }; } -// Single-observation analogue of codex-queue-io.mjs's resolvePendingQueue: -// this channel has only the transcript to read, not a separate queue-item -// check, so "present" collapses to one case (delivered) instead of two. -export function resolvePendingDelivery({ observation, ageMs, ttlMs }) { +// Reads one transcript observation as a delivery state. There is deliberately +// no clock here: a nudge the seat has not shown yet is "pending" for as long as +// the same session and socket stay alive, however long that is. Claude Code +// holds a message received mid-work until the work pauses, so elapsed time says +// nothing about whether it was lost, and a wall clock jumps across a sleep. +// Only the caller's own evidence (socket gone, record changed) ends a pending +// nudge, and that is not decided here. +export function resolvePendingDelivery({ observation }) { if (observation?.state === "delivered") return { state: "confirmed", reason: "nonce_observed" }; - const ageKnown = Number.isSafeInteger(ageMs) && ageMs >= 0; - if (observation?.state === "unreadable") { - // A transcript that is genuinely absent past the TTL is never coming - // back on its own (the session that would create it is gone, or this - // record's project/session no longer matches a live one) -- expire it - // with a reason that says so, rather than polling it forever. Any OTHER - // unreadable cause (a transient read failure, a malformed line) stays - // pending indefinitely: that one really could resolve on the next read, - // and timing it out would report a seat undeliverable that might not be. - if (observation.reason === "transcript_missing" && ageKnown && ageMs >= ttlMs) { - return { state: "expired", reason: "transcript_missing" }; - } - return { state: "pending", reason: observation.reason ?? "transcript_unreadable" }; - } - if (!ageKnown) return { state: "pending", reason: "pending_age_unreadable" }; - const reason = observation?.state === "held" ? "peer_held" : "awaiting_confirmation"; - if (ageMs >= ttlMs) return { state: "expired", reason: observation?.state === "held" ? "confirmation_timeout:peer_held" : "confirmation_timeout" }; - return { state: "pending", reason }; + if (observation?.state === "unreadable") return { state: "pending", reason: observation.reason ?? "transcript_unreadable" }; + return { state: "pending", reason: observation?.state === "held" ? "peer_held" : "awaiting_confirmation" }; } diff --git a/scripts/daemon/channels/claude-code-queue.mjs b/scripts/daemon/channels/claude-code-queue.mjs index 0dc2f6640..a16dc52c0 100644 --- a/scripts/daemon/channels/claude-code-queue.mjs +++ b/scripts/daemon/channels/claude-code-queue.mjs @@ -25,7 +25,7 @@ import { readClaudeRoleSession, } from "./claude-code-queue-store.mjs"; import { - QUEUE_CONFIRMATION_TTL_MS, + checkSocketOwnership, inboxNudge, newNonce, observeTranscript, @@ -116,11 +116,8 @@ function storagePaths(installRoot, env) { }; } -async function pendingObservation(pending, now, observe) { - const createdAt = Date.parse(pending.created_at); - const ageMs = Number.isFinite(createdAt) ? now() - createdAt : NaN; - const observation = observe(pending.transcript_path, pending.nonce); - return resolvePendingDelivery({ observation, ageMs, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); +function pendingObservation(pending, observe) { + return resolvePendingDelivery({ observation: observe(pending.transcript_path, pending.nonce) }); } export function createClaudeCodeQueueChannel({ @@ -138,6 +135,7 @@ export function createClaudeCodeQueueChannel({ readSnapshot = readUnreadSnapshot, send = sendClaudeCodeMessage, observe = observeTranscript, + socketAlive = checkSocketOwnership, hostPlatform = process.platform, }) { let stopped = false; @@ -147,29 +145,43 @@ export function createClaudeCodeQueueChannel({ const { team, agent, teamConfig, registered = true } = registration; const seat = seatKey(team, agent); const pending = readPending(db, seat); + // At most one nudge is outstanding per seat, and an outstanding one is not + // re-sent on a timer: Claude Code holds a message received mid-work until + // the work pauses, so a long wait is normal, not a loss. It is re-sent only + // when the thing it was sent to is gone (see below). if (pending) { - const observation = await pendingObservation(pending, now, observe); - if (!registered) { - if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); - else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); - saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "unaddressable", reason: "seat_registration_missing" }, now); - return; - } + const observation = pendingObservation(pending, observe); if (observation.state === "confirmed") { setQueueState(db, pending.id, "confirmed"); saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "addressable" }, now); return; } - if (observation.state === "expired") { + if (!registered) { setQueueState(db, pending.id, "expired"); - saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "blocked", reason: observation.reason }, now); + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "unaddressable", reason: "seat_registration_missing" }, now); return; } - // Still pending — includes the "held" case: Held is worth surfacing as - // a status, but it is not a delivery confirmation, so the seat must - // never be marked notified on it alone. - saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "addressable", reason: observation.reason }, now); - return; + const session = readSession(join(installRoot, "run"), team, agent); + const record = session.state === "present" ? session.record : null; + const transcriptNow = record + ? resolveTranscriptPath(record.claude_config_dir, record.project, record.session) + : ""; + // The nudge is out of date when its socket is gone, or the seat's record + // now names another socket or session (/compact, restart, --resume). + // Nothing else makes it stale: not the time that has passed. + // Only a socket that is plainly absent counts as gone. A check that merely + // could not run (stat failed, wrong owner) proves nothing about the nudge + // the seat still holds, and sending another would duplicate it. + const socketCheck = socketAlive(pending.messaging_socket); + const socketGone = socketCheck.reason === "messaging_socket_missing"; + const recordMoved = record && ( + record.messaging_socket !== pending.messaging_socket || + (transcriptNow && transcriptNow !== pending.transcript_path)); + if (!socketGone && !recordMoved) { + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "addressable", reason: socketCheck.state === "ok" ? observation.reason : socketCheck.reason }, now); + return; + } + setQueueState(db, pending.id, "expired"); } if (!registered) return; if (hostPlatform === "win32") { @@ -262,7 +274,7 @@ export function createClaudeCodeQueueChannel({ saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `send_failed:${result.reason}` }, now); return; } - const firstCheck = await pendingObservation(readPending(db, seat), now, observe); + const firstCheck = pendingObservation(readPending(db, seat), observe); if (firstCheck.state === "confirmed") { setQueueState(db, id, "confirmed"); saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "addressable" }, now); diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index 630ee55b2..0f66b9268 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -7,8 +7,10 @@ import test from 'node:test'; import { observeTranscript, resolvePendingDelivery, - QUEUE_CONFIRMATION_TTL_MS, } from '../scripts/daemon/channels/claude-code-queue-io.mjs'; +import { createClaudeCodeQueueChannel } from '../scripts/daemon/channels/claude-code-queue.mjs'; +import { ensureClaudeCodeChannelSchema } from '../scripts/daemon/channels/claude-code-queue-store.mjs'; +import { openInstallDb } from '../scripts/daemon/db.mjs'; function temporaryDirectory(t) { const dir = mkdtempSync(path.join(os.tmpdir(), 'agmsg-cc-queue-')); @@ -38,7 +40,7 @@ test('observeTranscript: a queue-operation enqueue line is not delivery evidence })}\n`); let observation = observeTranscript(transcriptPath, nonce); assert.equal(observation.state, 'absent'); - let resolved = resolvePendingDelivery({ observation, ageMs: 1000, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + let resolved = resolvePendingDelivery({ observation }); assert.equal(resolved.state, 'pending'); // The real delivered record appended afterward (as the genuine sequence @@ -56,26 +58,71 @@ test('observeTranscript: a queue-operation enqueue line is not delivery evidence })}\n`); observation = observeTranscript(transcriptPath, nonce); assert.equal(observation.state, 'delivered'); - resolved = resolvePendingDelivery({ observation, ageMs: 1000, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + resolved = resolvePendingDelivery({ observation }); assert.equal(resolved.state, 'confirmed'); }); -// #1577 review: a transcript that is genuinely missing (its session/project -// no longer has a live file to write to) must eventually expire with a -// reason, not poll forever -- but a transcript that merely failed to read -// (a transient fs error) must stay pending indefinitely, since that one -// really could resolve on the next read. Distinguishing these two is the -// property this test pins. -test('resolvePendingDelivery: a missing transcript expires at the TTL; a read failure never does', () => { - const missing = { state: 'unreadable', reason: 'transcript_missing' }; - const readFailed = { state: 'unreadable', reason: 'transcript_read_failed' }; +// No clock decides a pending nudge: whatever the transcript shows (or fails to +// show), it stays pending until the channel itself sees the socket or the +// seat's record change. +test('resolvePendingDelivery: nothing but a delivered nonce ends a pending nudge', () => { + for (const observation of [ + { state: 'absent' }, + { state: 'held' }, + { state: 'unreadable', reason: 'transcript_missing' }, + { state: 'unreadable', reason: 'transcript_read_failed' }, + ]) { + assert.equal(resolvePendingDelivery({ observation, ageMs: 10 ** 9 }).state, 'pending'); + } + assert.equal(resolvePendingDelivery({ observation: { state: 'delivered' } }).state, 'confirmed'); +}); + +function channelFixture(t) { + const root = temporaryDirectory(t); + mkdirSync(path.join(root, 'run')); + const db = openInstallDb(path.join(root, 'run', 'install.db')); + ensureClaudeCodeChannelSchema(db); + db.exec("UPDATE daemon_intent SET desired='on', op_gen=1"); + t.after(() => db.close()); + const state = { session: 'sess-1', socket: '/tmp/cc-socks/1.sock', sent: 0, hours: 0, alive: { state: 'ok' } }; + const channel = createClaudeCodeQueueChannel({ + db, installRoot: root, expectedOpGen: 1, env: { AGMSG_STORAGE_PATH: root }, + now: () => Date.UTC(2026, 9, 14) + state.hours * 3_600_000, + log: () => {}, + listSeats: () => ({ state: 'ok', seats: [{ team: 'test', agent: 'alice', teamConfig: { agents: { alice: { registrations: [{ type: 'claude-code', project: root }] } } } }] }), + readSession: () => ({ state: 'present', record: { team: 'test', agent: 'alice', type: 'claude-code', project: root, claude_config_dir: root, messaging_socket: state.socket, session: state.session } }), + readDriver: () => ({ state: 'ok', driver: 'sqlite' }), + openStore: () => ({ close() {} }), + readSnapshot: () => ({ state: 'ok', unread: true, upTo: '{}' }), + send: async () => { state.sent += 1; return { state: 'sent' }; }, + observe: () => ({ state: 'absent' }), + socketAlive: () => state.alive, + hostPlatform: 'linux', + }); + return { db, channel, state }; +} - const missingBeforeTtl = resolvePendingDelivery({ observation: missing, ageMs: 1000, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); - assert.equal(missingBeforeTtl.state, 'pending'); - const missingAfterTtl = resolvePendingDelivery({ observation: missing, ageMs: QUEUE_CONFIRMATION_TTL_MS + 1, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); - assert.equal(missingAfterTtl.state, 'expired'); - assert.equal(missingAfterTtl.reason, 'transcript_missing'); +test('an unconfirmed nudge is not sent again, however long the same session and socket stay up', async (t) => { + const { db, channel, state } = channelFixture(t); + await channel.pollOnce(); + for (const hours of [1, 9, 40]) { + state.hours = hours; + await channel.pollOnce(); + } + // A check that could not run is not evidence the socket is gone. + state.alive = { state: 'unreadable', reason: 'messaging_socket_stat_failed' }; + await channel.pollOnce(); + assert.equal(state.sent, 1); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM beta_claude_queue WHERE state='pending'").get().n, 1); + await channel.stop(); +}); - const readFailedAfterTtl = resolvePendingDelivery({ observation: readFailed, ageMs: QUEUE_CONFIRMATION_TTL_MS + 1, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); - assert.equal(readFailedAfterTtl.state, 'pending'); +test('a nudge is sent again once the seat record names another session', async (t) => { + const { db, channel, state } = channelFixture(t); + await channel.pollOnce(); + state.session = 'sess-2'; + await channel.pollOnce(); + assert.equal(state.sent, 2); + assert.deepEqual(db.prepare('SELECT state FROM beta_claude_queue ORDER BY id').all().map((row) => row.state), ['expired', 'pending']); + await channel.stop(); }); diff --git a/tests/test_agmsgd_claude_code_queue.bats b/tests/test_agmsgd_claude_code_queue.bats new file mode 100644 index 000000000..d3272b891 --- /dev/null +++ b/tests/test_agmsgd_claude_code_queue.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd Claude Code queue support tests" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_claude_code_queue.test.mjs" + [ "$status" -eq 0 ] +} From c01af8357cf333a62b8ec5ec7fe1f87c2dd27bcd Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 11:56:00 +0900 Subject: [PATCH 23/97] test(codex): wait for the detached launcher before cleanup (#1603) Record the real dispatcher PID in the Windows handoff fixture and wait for its exit before deleting the store. Its SQLite initialization and lock-release children must finish before Windows can unlink the database. A missing PID or an exit timeout fails teardown and retains the store. --- tests/test_codex_monitor.bats | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_codex_monitor.bats b/tests/test_codex_monitor.bats index ccd56b0d4..7ce7ca28e 100644 --- a/tests/test_codex_monitor.bats +++ b/tests/test_codex_monitor.bats @@ -85,6 +85,20 @@ teardown() { kill "$pid" 2>/dev/null || true wait_for_pid_exit "$pid" || true done + if [ -n "${MONITOR_TEST_LAUNCHER_PID_FILE:-}" ]; then + # The detached dispatcher can still be initializing messages.db after the + # fake TUI exits. Let its foreground SQLite children and EXIT trap finish + # before removing their store; killing just the shell can orphan sqlite3. + if ! wait_for_file_contains "$MONITOR_TEST_LAUNCHER_PID_FILE" '^[0-9][0-9]*$'; then + echo "codex-monitor teardown: launcher did not publish its pid" >&2 + return 1 + fi + pid="$(cat "$MONITOR_TEST_LAUNCHER_PID_FILE")" + if ! wait_for_pid_exit "$pid"; then + echo "codex-monitor teardown: launcher $pid did not exit; retaining its store" >&2 + return 1 + fi + fi rm -rf "$TEST_PROJECT" teardown_test_env } @@ -354,7 +368,19 @@ esac EOF chmod +x "$win_codex" + # Keep the real dispatcher, but retain its PID so teardown can wait for all + # of its DB work, including lock release, rather than racing a detached child. + export MONITOR_TEST_LAUNCHER_PID_FILE="$TEST_PROJECT/launcher.pid" + local tracked_launcher="$TEST_PROJECT/tracked-launcher" + cat > "$tracked_launcher" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$MONITOR_TEST_LAUNCHER_PID_FILE" +exec "$TYPES/codex/codex-bridge-launcher.sh" "$@" +EOF + chmod +x "$tracked_launcher" + run env AGMSG_REAL_CODEX="$win_codex" \ + AGMSG_CODEX_BRIDGE_LAUNCHER_CMD="$tracked_launcher" \ bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" --codex-command codex -- [ "$status" -eq 0 ] grep -q 'plain-codex <--remote> ' "$CALL_LOG" From aab4b500f75e613023db2736bbb0f1c202c9292f Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 11:56:07 +0900 Subject: [PATCH 24/97] fix(status): exclude never-recorded seats from older destinations (#1606) --- scripts/lib/daemon-seats.sh | 6 ++++++ tests/agmsgd_status_display.test.mjs | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/scripts/lib/daemon-seats.sh b/scripts/lib/daemon-seats.sh index 44492e0a1..0ec25c65f 100644 --- a/scripts/lib/daemon-seats.sh +++ b/scripts/lib/daemon-seats.sh @@ -77,6 +77,12 @@ agmsg_daemon_seat_report() { # Bare historical thread IDs have no process witness. Do not scan all # session markers for each old role just to infer activity. if agmsg_instance_is_composite "$owner" && agmsg_instance_alive "$owner"; then active=1; fi + # A registration alone is not a historical destination. Keep actionable + # bridge/pending evidence visible, but omit seats never recorded at all. + if [ "$action" = status ] && [ "$active" = 0 ] && [ "$bridge" = stopped ] \ + && [ ! -f "$_AGMSG_ROLE_SESSION_PATH" ]; then + continue + fi if [ "$action" = status ] && [ "${AGMSG_DAEMON_ALL:-0}" != 1 ] && [ "$active" = 0 ] && [ "$recent" != 1 ]; then omitted=$((omitted + 1)) continue diff --git a/tests/agmsgd_status_display.test.mjs b/tests/agmsgd_status_display.test.mjs index e20067865..eff139c73 100644 --- a/tests/agmsgd_status_display.test.mjs +++ b/tests/agmsgd_status_display.test.mjs @@ -71,6 +71,20 @@ test('old destinations are counted by default and available with --all after cur assert.match(status(), /Codex test\/ancient:.*waiting for delivery confirmation/); }); +test('never-recorded seats are not counted as older destinations, but pending evidence stays visible', t => { + const { root, db, config, status } = fixture(t); + config.agents = { fresh: { registrations: [{ type: 'codex', project: '/fresh' }] } }; + writeFileSync(join(root, 'teams/test/config.json'), JSON.stringify(config)); + for (const flags of [[], ['--all']]) { + const text = status(...flags); + assert.doesNotMatch(text, /older destination|Older destination|Codex test\/fresh:/); + assert.match(text, /agmsgd has never been started/); + } + db.prepare("INSERT INTO beta_codex_queue (seat,codex_home,thread,up_to,nonce,state,children,created_at) VALUES (?, ?, ?, '{}', 'fresh-pending', 'pending', 'incomplete', '2020-01-01T00:00:00Z')") + .run(JSON.stringify(['test', 'fresh']), root, thread); + assert.match(status(), /Codex test\/fresh: no destination record;.*waiting for delivery confirmation/); +}); + test('installer restart guidance tells users to resume, refresh their destination, and check status', () => { const source = readFileSync(join(repo, 'install.sh'), 'utf8'); const fn = source.match(/agmsg_install_restart_guidance\(\) \{[\s\S]*?\n\}/)[0]; From 8a001342b304da1b943920ae055f0c0da74ff37c Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 11:56:13 +0900 Subject: [PATCH 25/97] Drop a placement record whose team is gone instead of warning on every action (#1605) * Drop a placement record whose team is gone instead of warning on every action A spawn record left under a team that no longer exists, pointing at the same pane as the seat acting now, made every send print the placement-guard warning and kept the stale record forever. The seat acting now takes the pane and the old record is removed, with one short line saying so. The record is kept only when its owner is confirmed to be alive in this pane. Anything that cannot be decided counts as not confirmed. Peers in the same team, records of teams that still exist, id-keyed records and ambiguous suffixes keep the existing guard. * Keep the placement guard unless the claimant's team is shown to be gone Dropping an older placement record now needs positive evidence: the record name must split into a team and an agent, and the teams directory must be listable. A name with no separator, or a directory that cannot be listed, keeps the existing guard. The refusal message now names the file to remove. * Re-read the claimant's record before dropping it The liveness lookup that decides a placement record is stale can take a while, and the same path can hold a different record by the time it finishes. Re-read the record just before dropping it and keep it, with the existing refusal, when it no longer claims this pane or its owner is shown to be here. * Do the slow owner lookup before the final record re-read The record is now re-read after the last liveness question, so nothing slow sits between that re-read and the removal. --- scripts/lib/terminal-registry.sh | 61 +++++++++++++++++++++++++++++-- tests/test_terminal_registry.bats | 50 +++++++++++++++++++------ 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/scripts/lib/terminal-registry.sh b/scripts/lib/terminal-registry.sh index 7d8dc7467..267f8375a 100644 --- a/scripts/lib/terminal-registry.sh +++ b/scripts/lib/terminal-registry.sh @@ -1431,16 +1431,51 @@ agmsg_terminal_name_self() { # with; a caller with none (every action-hook caller routed through # self-name.sh) cannot ask either question and keeps refusing exactly as # every caller did before this existed. + # + # VANISHED-TEAM RECORD: a claimant whose team is no longer on disk is an + # older placement. It is dropped, and the seat acting now takes the pane, + # unless its owner is confirmed to be alive in this very pane. This needs + # no session id; undecidable counts as not confirmed. local _took_over=0 _rival_spawn="" _role_path="" local _want_id="" _want_id="$(_agmsg_placement_bare_pane_id "$_claim_ref" 2>/dev/null)" || _want_id="" - if [ -n "$sid" ] && [ -n "$_want_id" ]; then + if true; then local _mine_spawn="" _claimant_here="unknown" _mine_spawn="$(agmsg_spawn_path "$team" "$agent" 2>/dev/null)" || _mine_spawn="" if [ -n "$_mine_spawn" ]; then _rival_spawn="$(dirname "$_mine_spawn")/spawn.$_claimed_by" _claimant_here="$(_agmsg_placement_claimant_here_now "$_rival_spawn" "$_claim_ref" 2>/dev/null)" || _claimant_here="unknown" - if [ "$_claimant_here" = not_here ] && declare -F terminal_session_live >/dev/null 2>&1; then + # A record whose team no longer exists on disk is an older placement, + # not a peer: unless its owner is confirmed to be in this very pane, + # the seat acting now takes the pane and the old record is dropped. + # Undecidable counts as not confirmed. Only a suffix that splits into + # exactly one team and one agent, by name, can be judged this way; an + # id-keyed record or an ambiguous suffix keeps the stricter rule below. + if [ "$_claimant_here" != here ]; then + local _cb_team="${_claimed_by%%__*}" _cb_rest="${_claimed_by#*__}" _cb_t _cb_exists=0 + # No separator at all leaves both halves equal to the whole name. + case "$_claimed_by" in *__*) ;; *) _cb_rest="" ;; esac + # Only positive evidence counts: a teams directory that cannot be + # listed proves nothing about which teams exist. + ls -1 "$(dirname "$(dirname "$_mine_spawn")")/teams" >/dev/null 2>&1 || _cb_rest="" + case "$_cb_rest" in + *__*|"") ;; + *) + case "$_cb_team" in + "") ;; + ????????-????-????-????-????????????) ;; + "$(_actas_lock_encode "$team")") ;; + *) + for _cb_t in "$(dirname "$(dirname "$_mine_spawn")")"/teams/*/; do + [ -d "$_cb_t" ] || continue + _cb_t="${_cb_t%/}"; _cb_t="${_cb_t##*/}" + [ "$(_actas_lock_encode "$_cb_t")" = "$_cb_team" ] && { _cb_exists=1; break; } + done + [ "$_cb_exists" -eq 0 ] && _took_over=1 ;; + esac ;; + esac + fi + if [ "$_took_over" -eq 0 ] && [ -n "$sid" ] && [ -n "$_want_id" ] && [ "$_claimant_here" = not_here ] && declare -F terminal_session_live >/dev/null 2>&1; then local _mine_live="" _tab _tab="$(printf '\t')" _mine_live="$(terminal_session_live "$sid" 2>/dev/null)" || _mine_live="" @@ -1451,6 +1486,23 @@ agmsg_terminal_name_self() { fi fi fi + # The decision above took a while (a liveness query). The same path is + # not the same record: re-read it now, and only drop what still claims + # this pane and whose owner is still not shown to be here. A record that + # changed meanwhile is refused like any other claim. + if [ "$_took_over" -eq 1 ]; then + local _recheck="" _recheck_rc=0 + # The slow question first, the record re-read last: nothing slow sits + # between the re-read and the removal. + if [ "$(_agmsg_placement_claimant_here_now "$_rival_spawn" "$_claim_ref" 2>/dev/null)" = here ]; then + _took_over=0 + else + _recheck="$(_agmsg_placement_claimed_by "$_claim_ref" "$team" "$agent")" || _recheck_rc=$? + if [ "$_recheck_rc" -ne 0 ] || [ "$_recheck" != "$_claimed_by" ]; then + _took_over=0 + fi + fi + fi if [ "$_took_over" -eq 1 ]; then # The claimant's role stays registered -- every other field of its # record is untouched -- only the naming mark it can no longer back up @@ -1468,6 +1520,9 @@ agmsg_terminal_name_self() { fi declare -F agmsg_role_session_clear_named_at >/dev/null 2>&1 \ && agmsg_role_session_clear_named_at "$_role_path" 2>/dev/null + # The old record goes, so it does not warn again on the next action. + rm -f "$_rival_spawn" + echo "agmsg: dropped the older placement record $_claimed_by, which pointed at this seat's pane." >&2 # Fall through: this pane is this seat's to name/mark/record now. else # A claimant whose name ends in THIS seat's name may be this seat under a @@ -1478,7 +1533,7 @@ agmsg_terminal_name_self() { *"__$(_actas_lock_encode "$agent")") _self_hint=" If '$_claimed_by' is this seat under a team that no longer exists, remove run/spawn.$_claimed_by." ;; esac - echo "agmsg: did not name or record this pane: this seat resolved $_claim_ref, and that pane is already recorded as $_claimed_by's. Keeping that seat's name and record. If that seat is gone, drop or despawn it and act again.$_self_hint (#1114 placement guard, kept alongside #1112's label-first resolution.)" >&2 + echo "agmsg: did not name or record this pane: this seat resolved $_claim_ref, and that pane is already recorded as $_claimed_by's. Keeping that seat's name and record. To keep this seat instead, drop or despawn that seat (or remove run/spawn.$_claimed_by) and act again.$_self_hint (#1114 placement guard, kept alongside #1112's label-first resolution.)" >&2 return 0 fi fi diff --git a/tests/test_terminal_registry.bats b/tests/test_terminal_registry.bats index 424128340..b4cad8d3c 100644 --- a/tests/test_terminal_registry.bats +++ b/tests/test_terminal_registry.bats @@ -3695,11 +3695,10 @@ _tmux_op_args() { refute test -e "$mine" } -@test "placement guard: this seat's record under a VANISHED team blocks it (closed) and the message names the file to remove (#1114 review)" { - # The exact rule builds this agent's record path for each team on disk. A team - # dropped with its run/ record left behind has no path to build, so that - # record reads as a rival: fail-closed, and the refusal must say the way out - # is the file, since "drop or despawn" points at this seat itself. +@test "placement guard: a record under a VANISHED team is dropped and the seat acting now is recorded, without a warning" { + # A team dropped with its run/ record left behind: the record is older than + # any seat now acting, so it is replaced rather than left to warn on every + # action. A claimant whose team still exists keeps the guard (control below). _install_fake_tmux export PATH="$FAKEBIN:$PATH" export TMUX="/tmp/fake,1,0" TMUX_PANE="%1" @@ -3714,11 +3713,9 @@ _tmux_op_args() { : > "$ARGV_LOG" run agmsg_terminal_name_self "" seatteam alice /proj/MINE claude-code record [ "$status" -eq 0 ] - grep -q 'did not name or record' <<<"$output" - grep -q "recorded as gone__alice's" <<<"$output" - grep -q 'remove run/spawn.gone__alice' <<<"$output" - refute grep -qE '\[set-option\]|\[select-pane\]' "$ARGV_LOG" - refute test -e "$mine" + refute grep -q 'did not name or record' <<<"$output" + refute test -e "$stale" + grep -q '^tmux:/tmp/fake:%1 /proj/MINE claude-code$' "$mine" # Control: a genuine other seat gets no such hint. rm -f "$stale" @@ -3726,7 +3723,38 @@ _tmux_op_args() { printf 'tmux:/tmp/fake:%%1\t/proj/PEER\tclaude-code\n' > "$rival" run agmsg_terminal_name_self "" seatteam alice /proj/MINE claude-code record grep -q "recorded as seatteam__bob's" <<<"$output" - refute grep -q 'remove run/spawn' <<<"$output" + refute grep -q 'no longer exists' <<<"$output" + + # No proof the team is gone, no drop: a name without a team/agent split, and + # a teams directory that cannot be listed, both keep the claimant. + rm -f "$rival" + local nosplit; nosplit="$(dirname "$mine")/spawn.unsplittable" + printf 'tmux:/tmp/fake:%%1\t/proj/PEER\tclaude-code\n' > "$nosplit" + run agmsg_terminal_name_self "" seatteam alice /proj/MINE claude-code record + grep -q "recorded as unsplittable's" <<<"$output" + test -e "$nosplit" + rm -f "$nosplit" + printf 'tmux:/tmp/fake:%%1\t/proj/PEER\tclaude-code\n' > "$stale" + chmod 111 "$SKILL_DIR/teams" + run agmsg_terminal_name_self "" seatteam alice /proj/MINE claude-code record + chmod 755 "$SKILL_DIR/teams" + grep -q "recorded as gone__alice's" <<<"$output" + test -e "$stale" + + # The record is re-read before it is dropped: one that moved to another pane + # while the owner was being looked up is not the record that was judged. + chmod 755 "$SKILL_DIR/teams" + # The record moves during the LAST owner lookup, the one right before removal. + _agmsg_placement_claimant_here_now() { + if [ -e "$BATS_TEST_TMPDIR/asked" ]; then + printf 'tmux:/tmp/fake:%%2\t/proj/LIVE\tclaude-code\n' > "$stale" + fi + : > "$BATS_TEST_TMPDIR/asked" + echo unknown + } + run agmsg_terminal_name_self "" seatteam alice /proj/MINE claude-code record + test -e "$stale" + grep -q '%2' "$stale" } @test "placement guard: a claimant whose session is confirmed gone lets a live seat take the pane; a claimant still there still refuses (#1485)" { From 92394d4f65bd3d663f983e0d606c4dd55cc465f0 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 11:56:21 +0900 Subject: [PATCH 26/97] daemon: use kern.bootsessionuuid as the macOS boot id (#1601) * daemon: use kern.bootsessionuuid as the macOS boot id The macOS boot id was the seconds of kern.boottime, which the kernel shifts whenever the wall clock is stepped. A running agmsgd recorded 1791413341 while the same boot later read 1791413338, so `agmsg daemon status` reported "stopped while enabled (executor missing or boot/start evidence does not match)" for a live daemon, and the Codex child-group check (observeProcessGroup) could treat live children as absent. Use kern.bootsessionuuid, which is fixed for the whole boot, in both the shell and the Node side. A record written in the old numeric form is accepted when it is within ten minutes of the current kern.boottime, so an existing install is not reported as stopped until its next start writes the new form. Linux keeps /proc/stat btime. * daemon: require the process to predate the claim for an old-format boot id An old-format macOS boot id (kern.boottime seconds) was accepted within ten minutes of the current value without looking at the process, so a quick restart plus a reused pid made isAlive report a stranger as the owner. isAlive now takes the recorded claim time and, for an old-format record, also requires the pid to have started no later than that claim, as the shell check already does. * daemon: report "cannot tell" when an old-format boot id cannot be verified When the process start time or the recorded claim time could not be read, isAlive returned false (confirmed dead), which let takeOwnership take ownership from a live owner. Check that the pid exists first, then return null when the start-time evidence is unreadable, and false only when the process really started after the claim. * daemon: report "cannot tell" when kern.boottime cannot be read For an old-format macOS boot id, a failing sysctl made the tolerance check return false, so isAlive reported a live owner as confirmed dead and takeOwnership could take it over. The check now returns null when kern.boottime is unreadable, isAlive passes that through, and observeProcessGroup reports the child group as unreadable instead of absent. * daemon: treat an old-format macOS boot id as "cannot tell" Drop the ten-minute tolerance for records that hold kern.boottime seconds. Such a record is neither confirmed nor refuted while its pid exists: isAlive returns null (false once the pid is gone), observeProcessGroup reports the child group as unreadable, and `agmsg daemon status` says the record may come from an older version and to run `agmsg daemon stop` and `agmsg daemon start`. A start rewrites the record with the new boot id; stop reaches the running daemon over its control socket, so it does not depend on the boot id. * daemon: keep an old-format boot id "cannot tell" even when it equals the fallback When kern.bootsessionuuid could not be read, bootId() fell back to a number, and an old-format record equal to that number compared as the same boot. Classify the old format before comparing values. * daemon: let disable drop an unverifiable owner record A record from an older version whose pid was reused and whose control socket is gone is refused by every start and cannot be stopped. Disable is the deliberate way out: when the daemon did not confirm stopping and the record cannot be verified, drop it. The start refusal now names the disable and enable steps. * daemon: guard the health read in disable Read AGMSGD_HEALTH with a default so the unguarded-environment-read check passes. * daemon: drop only the exact record disable judged unverifiable The discard in disable matched any non-none owner, so a daemon started between the check and the update could be marked none. Match the gen, pid and boot id that were read, and only a ready owner. * daemon: use the shared pid liveness helper for the old boot id check The legacy-record check in daemon-state.sh called kill -0 directly, which reads EPERM as dead. Use _agmsg_pid_alive, the shared helper that only treats ESRCH as gone. --- scripts/daemon.sh | 19 +++++++++ scripts/daemon/channels/process-group.mjs | 6 ++- scripts/daemon/executor.mjs | 42 ++++++++++++++----- scripts/daemon/owner.mjs | 2 +- scripts/daemon/status.mjs | 10 ++++- scripts/lib/daemon-state.sh | 20 ++++++++- tests/agmsgd_executor.test.mjs | 31 +++++++++++++- tests/test_agmsgd_daemon_sh.bats | 9 ++++ tests/test_agmsgd_switch.bats | 12 +++++- ...sion_start_claude_code_native_channel.bats | 2 +- 10 files changed, 135 insertions(+), 18 deletions(-) diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 33023e58a..043c1ba66 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -629,6 +629,25 @@ cmd_disable() { waited=$((waited + 1)) sleep 0.1 done + if [ "$state" != "none" ]; then + # A record written by an older version cannot be checked against this + # boot. Disable is the deliberate way out of it: the daemon was asked to + # stop above, so drop the unverifiable record instead of leaving it to + # block every later start. + local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON seen_gen seen_pid seen_boot + IFS='|' read -r seen_gen seen_pid seen_boot < <(sqlite3 "$INSTALL_DB" "SELECT gen, COALESCE(executor_pid,0), COALESCE(executor_boot_id,'') FROM daemon_owner;" 2>/dev/null || true) + agmsg_daemon_read_state + # Only the exact record that was just judged unverifiable may be dropped: + # a daemon started since then has a different gen, and is left alone. + case "$seen_gen|$seen_pid|$seen_boot" in + *[!0-9\|]*|''|'||'*) ;; + *) + if [ "${AGMSGD_HEALTH:-}" = unverifiable ]; then + _with_op_lock "UPDATE daemon_owner SET state = 'none', socket = NULL, last_end_reason = 'record from an older version discarded by disable', last_end_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE gen = $seen_gen AND executor_pid = $seen_pid AND executor_boot_id = '$seen_boot' AND state = 'ready';" + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + fi ;; + esac + fi if [ "$state" != "none" ]; then echo "agmsg daemon disable: service was unregistered but agmsgd has not confirmed stopping -- check 'agmsg daemon status'" >&2 return 1 diff --git a/scripts/daemon/channels/process-group.mjs b/scripts/daemon/channels/process-group.mjs index 4ad9d2c6f..fc89f0ba4 100644 --- a/scripts/daemon/channels/process-group.mjs +++ b/scripts/daemon/channels/process-group.mjs @@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { platform } from "node:os"; -import { bootId } from "../executor.mjs"; +import { bootId, sameBoot } from "../executor.mjs"; export function processStartWitness(pid) { if (!Number.isSafeInteger(pid) || pid < 2) return null; @@ -74,7 +74,9 @@ export function observeProcessGroup(record) { return { state: "present" }; } if (record.kind !== "posix-process-group") return { state: "unreadable", reason: "child_group_kind_mismatch" }; - if (bootId() !== record.boot_id) return { state: "absent" }; + const sameBootId = sameBoot(record.boot_id); + if (sameBootId === null) return { state: "unreadable", reason: "child_boot_id_unreadable" }; + if (!sameBootId) return { state: "absent" }; try { process.kill(-record.pgid, 0); } catch (error) { diff --git a/scripts/daemon/executor.mjs b/scripts/daemon/executor.mjs index 6c1810a07..b1314f7ec 100644 --- a/scripts/daemon/executor.mjs +++ b/scripts/daemon/executor.mjs @@ -19,8 +19,8 @@ import { platform, uptime } from "node:os"; // the same comparison purpose. // // linux: /proc/stat's `btime` line is the kernel's own authoritative boot -// epoch. darwin: `sysctl -n kern.boottime` reports the same thing in a -// different format. Neither drifts between calls. +// epoch. darwin: `sysctl -n kern.bootsessionuuid`, a UUID fixed for the +// boot. Neither drifts between calls. // // Anything else (win32 included): no simple authoritative file to read // without shelling out to something heavier (wmic/PowerShell) for every @@ -44,12 +44,15 @@ export function bootId() { // to the approximate path rather than throw, since a boot id that is // merely approximate is still useful (see caller contract below). } else if (plat === "darwin") { + // kern.bootsessionuuid is fixed for the whole boot. kern.boottime is + // not: the kernel shifts it whenever the wall clock is stepped, so a + // value recorded days ago can differ by seconds from the same boot's + // current one. try { - const out = execFileSync("sysctl", ["-n", "kern.boottime"], { + const out = execFileSync("sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8", - }); - const m = out.match(/sec\s*=\s*(\d+)/); - if (m) return m[1]; + }).trim(); + if (out) return out; } catch { // fall through to the approximate path } @@ -57,6 +60,24 @@ export function bootId() { return String(Math.round(Date.now() / 1000 - uptime())); } +// A darwin record written before bootId() moved to kern.bootsessionuuid holds +// kern.boottime seconds. The kernel shifts that value whenever the wall clock +// is stepped, so it can neither be compared for equality nor be trusted as +// proof of the same boot: such a record is "cannot tell" while its pid still +// exists. +function isOldDarwinBootId(recorded) { + return platform() === "darwin" && /^\d+$/.test(String(recorded)); +} + +// true / false / null (cannot tell) for "`recorded` is this boot's id". +export function sameBoot(recorded) { + // Checked before the value comparison: when kern.bootsessionuuid cannot + // be read, bootId() falls back to a number, which must not make an + // old-format record look like an exact match. + if (isOldDarwinBootId(recorded)) return null; + return bootId() === recorded; +} + // This process's own executor triple, as of the call. `role` and `pid` are // the caller's own; `bootId` is captured fresh (see above). export function currentExecutor() { @@ -75,10 +96,10 @@ export function currentExecutor() { // where a stale boot id is itself the proof, not a reason to say "cannot // tell". export function isAlive({ pid, bootId: capturedBootId }) { - if (bootId() !== capturedBootId) return false; + const sameBootId = sameBoot(capturedBootId); + if (sameBootId === false) return false; try { process.kill(pid, 0); - return true; } catch (error) { if (error?.code === "ESRCH") return false; // EPERM: a process at that pid exists but this user cannot signal it -- @@ -86,7 +107,8 @@ export function isAlive({ pid, bootId: capturedBootId }) { // permission fact, but a permission-denied answer to `kill(pid, 0)` // never happens for a pid that does not exist). Anything else is a // genuine "cannot tell" (e.g. a platform where signal 0 is unsupported). - if (error?.code === "EPERM") return true; - return null; + if (error?.code !== "EPERM") return null; } + // The pid exists, but an old-format record cannot say it is the same boot. + return sameBootId === null ? null : true; } diff --git a/scripts/daemon/owner.mjs b/scripts/daemon/owner.mjs index 780e6bb85..2ace10ab5 100644 --- a/scripts/daemon/owner.mjs +++ b/scripts/daemon/owner.mjs @@ -52,7 +52,7 @@ export function takeOwnership(db, { installRoot, expectedDesired, expectedOpGen, } else { const alive = isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); if (alive === true) refuse = "current owner is alive"; - else if (alive === null) refuse = "current owner's liveness cannot be determined"; + else if (alive === null) refuse = "current owner's liveness cannot be determined; if agmsgd is not running, run agmsg daemon disable and then agmsg daemon enable"; // alive === false (confirmed dead): fall through, this call takes over. } } diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs index 52f6f819c..d466e0d6f 100644 --- a/scripts/daemon/status.mjs +++ b/scripts/daemon/status.mjs @@ -33,6 +33,14 @@ const STARTING_STOPPING_GRACE_MS = 30_000; export function classify({ owner, intent, alive, reachable, lastAttempt }, now = Date.now()) { const desired = intent?.desired ?? null; + if (desired === "on" && owner.state === "ready" && alive === null) { + return { + text: "agmsgd's record cannot confirm that it is still running (it may have been written by an older version). Run agmsg daemon stop and then agmsg daemon start to refresh it. Unread messages are preserved.", + exitCode: 1, + gen: owner.gen, + }; + } + if (desired === "on" && !(owner.state === "ready" && alive === true && reachable)) { const reason = lastAttempt?.reason ?? owner.last_end_reason ?? (owner.state === "ready" ? "executor or control socket unavailable" : owner.state); @@ -143,7 +151,7 @@ export function readOwnerAndIntentReadOnly(installRoot) { const alive = owner.state === "none" || owner.executor_pid == null ? null - : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); + : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id, startedAt: owner.executor_started_at }); return { owner, intent, alive, codexSeats, claudeCodeSeats, codexPending, lastAttempt }; } finally { db.close(); diff --git a/scripts/lib/daemon-state.sh b/scripts/lib/daemon-state.sh index 6b1edb500..985c9168b 100644 --- a/scripts/lib/daemon-state.sh +++ b/scripts/lib/daemon-state.sh @@ -4,6 +4,8 @@ [ -n "${_AGMSG_DAEMON_STATE_SH:-}" ] && return 0 _AGMSG_DAEMON_STATE_SH=1 _AGMSG_DAEMON_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/instance-id.sh" agmsg_daemon_read_state() { AGMSGD_DESIRED=unknown; AGMSGD_HEALTH=unknown; AGMSGD_REASON="install record unavailable" @@ -25,7 +27,7 @@ agmsg_daemon_read_state() { case "$pid" in ''|*[!0-9]*|0|1) return 0 ;; esac case "$(uname -s)" in Darwin) - current_boot="$(sysctl -n kern.boottime 2>/dev/null | sed -n 's/.*sec = \([0-9]*\),.*/\1/p')" + current_boot="$(sysctl -n kern.bootsessionuuid 2>/dev/null)" process_start="$(LC_ALL=C ps -p "$pid" -o lstart= 2>/dev/null)" || process_start="" started_epoch="$(LC_ALL=C date -j -f '%a %b %e %T %Y' "$process_start" +%s 2>/dev/null)" || started_epoch="" ;; @@ -42,6 +44,18 @@ agmsg_daemon_read_state() { ;; *) AGMSGD_REASON="platform liveness check unavailable"; return 0 ;; esac + # A macOS record written before the boot id became kern.bootsessionuuid + # holds kern.boottime seconds, which the kernel shifts when the clock is + # stepped. While its pid still exists it can be neither confirmed nor + # refuted. + if [ "$(uname -s)" = Darwin ] && [ -n "$current_boot" ] && [ "$current_boot" != "$boot" ]; then + case "$boot" in ''|*[!0-9]*) ;; *) + if _agmsg_pid_alive "$pid"; then + AGMSGD_HEALTH=unverifiable + return 0 + fi ;; + esac + fi if [ -z "$current_boot" ] || [ "$current_boot" != "$boot" ] || [ -z "$started_epoch" ]; then AGMSGD_REASON="executor missing or boot/start evidence does not match ($AGMSGD_REASON)" return 0 @@ -69,6 +83,10 @@ agmsg_daemon_read_state() { } agmsg_daemon_recovery_text() { + if [ "$AGMSGD_HEALTH" = unverifiable ]; then + printf 'agmsgd (Codex notices) was recorded by an older version, so whether it is still running cannot be checked. Run agmsg daemon stop and then agmsg daemon start to update the record. Unread messages are preserved. If agmsg is not found, use "%s/scripts/agmsg" daemon stop.\n' "$_AGMSG_DAEMON_ROOT" + return 0 + fi printf 'agmsgd (Codex notices) is stopped while enabled (reason: %s). Run agmsg daemon start (recommended), or agmsg daemon disable and restart your Codex sessions. Unread messages are preserved.' "$AGMSGD_REASON" case "$AGMSGD_REASON" in *Node*|*node*) printf ' Install Node >= 22.13.0 from https://nodejs.org/en/download, then run agmsg daemon enable.' ;; diff --git a/tests/agmsgd_executor.test.mjs b/tests/agmsgd_executor.test.mjs index faded4ab6..e7d99387d 100644 --- a/tests/agmsgd_executor.test.mjs +++ b/tests/agmsgd_executor.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import { bootId, currentExecutor, isAlive } from "../scripts/daemon/executor.mjs"; @@ -23,8 +26,34 @@ test("bootId is stable across calls and isAlive tells living/dead/wrong-boot apa // confirmed dead even for this process's own (very much alive) pid -- // no pid from a different boot can still be running. assert.equal( - isAlive({ pid: self.pid, bootId: "0" }), + isAlive({ pid: self.pid, bootId: "another-boot" }), false, "a mismatched boot id must read as confirmed dead regardless of the pid", ); + + if (process.platform === "darwin") { + // A record from when the boot id was kern.boottime seconds cannot be + // matched against this boot (the kernel shifts that value with clock + // adjustments): cannot tell while its pid exists, dead once it is gone. + const boottime = spawnSync("sysctl", ["-n", "kern.boottime"], { encoding: "utf8" }).stdout; + const legacy = String(Number(boottime.match(/sec\s*=\s*(\d+)/)[1]) - 3); + assert.equal(isAlive({ pid: self.pid, bootId: legacy }), null); + assert.equal(isAlive({ pid: dead.pid, bootId: legacy }), false); + + // Even when kern.bootsessionuuid cannot be read and bootId() falls back + // to a number, an old-format record that equals it is still "cannot tell". + const shimDir = mkdtempSync(join(tmpdir(), "sysctl-shim-")); + writeFileSync( + join(shimDir, "sysctl"), + '#!/bin/sh\n[ "$2" = kern.bootsessionuuid ] && exit 1\nexec /usr/sbin/sysctl "$@"\n', + ); + chmodSync(join(shimDir, "sysctl"), 0o755); + const savedPath = process.env.PATH; + process.env.PATH = `${shimDir}:${savedPath}`; + try { + assert.equal(isAlive({ pid: self.pid, bootId: bootId() }), null); + } finally { + process.env.PATH = savedPath; + } + } }); diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index 43f6ab8c7..c066103d1 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -309,6 +309,15 @@ EOF [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" = "none" ] grep -q '^locked bootstrap$' "$events" grep -q '^locked bootout$' "$events" + + # A record from an older version (boot id in kern.boottime seconds) whose + # pid still exists and that has no control socket cannot be checked; disable + # drops it rather than failing. + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='1', socket=NULL, executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" \ + run bash "$DAEMON" disable + [ "$status" -eq 0 ] + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" = "none" ] } @test "daemon.sh start: stale ready state asks the registered manager to start the service" { diff --git a/tests/test_agmsgd_switch.bats b/tests/test_agmsgd_switch.bats index 53c2e5662..c8e455a00 100644 --- a/tests/test_agmsgd_switch.bats +++ b/tests/test_agmsgd_switch.bats @@ -219,7 +219,7 @@ WRAPPER @test "executor evidence recognizes a live ready owner and rejects a changed boot" { local boot case "$(uname -s)" in - Darwin) boot="$(sysctl -n kern.boottime | sed -n 's/.*sec = \([0-9]*\),.*/\1/p')" ;; + Darwin) boot="$(sysctl -n kern.bootsessionuuid)" ;; Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; *) skip 'POSIX beta executor evidence' ;; esac @@ -227,6 +227,16 @@ WRAPPER run bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' [ "$status" -eq 0 ] [ "$output" = ready ] + if [ "$(uname -s)" = Darwin ]; then + # A record written when the boot id was kern.boottime seconds: the kernel + # shifts that value with clock adjustments, so while its pid exists it + # is neither confirmed nor refuted. + local legacy + legacy="$(( $(sysctl -n kern.boottime | sed -n 's/.*sec = \([0-9]*\),.*/\1/p') - 3 ))" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET executor_boot_id='$legacy';" + run bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$output" = unverifiable ] + fi sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET executor_boot_id='previous-boot';" run bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' [ "$output" = stopped ] diff --git a/tests/test_session_start_claude_code_native_channel.bats b/tests/test_session_start_claude_code_native_channel.bats index 2045ced91..6d8c90df9 100644 --- a/tests/test_session_start_claude_code_native_channel.bats +++ b/tests/test_session_start_claude_code_native_channel.bats @@ -33,7 +33,7 @@ _mark_daemon_ready() { sqlite3 "$RUN_DIR/install.db" < "$SCRIPTS/daemon/schema.sql" local boot case "$(uname -s)" in - Darwin) boot="$(sysctl -n kern.boottime | sed -n 's/.*sec = \([0-9]*\),.*/\1/p')" ;; + Darwin) boot="$(sysctl -n kern.bootsessionuuid)" ;; Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; *) skip 'POSIX beta executor evidence' ;; esac From 95a2bf214e850b4405cc6fb1e162f62ddb0f2c60 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 11:56:27 +0900 Subject: [PATCH 27/97] fix(daemon): bring an existing install.db up to schema.sql on --update and on enable (#1598) * fix(daemon): bring an existing install.db up to schema.sql on --update and on enable install.sh creates install.db's meta with two columns, and schema.sql's CREATE TABLE IF NOT EXISTS never alters an existing table. After an --update from 1.5.x, 'agmsg daemon enable' therefore stopped with 'no such column: node_path', and once that was patched by hand with 'no such table: daemon_intent', because schema.sql was never applied. Add agmsg_install_db_apply_schema: add the missing meta columns, then apply schema.sql. install.sh calls it after ensure_meta and 'agmsg daemon enable' calls it before writing, so both paths share one implementation. The existing install_id is never changed. * fix(daemon): bring install.db up to schema.sql before every daemon command status and disable read daemon_owner / daemon_intent, which an install.db that only has the two-column meta does not have yet, so they failed with 'no such table' right after an install. Apply the schema in _require_install_db instead of only in enable, and extend the test to run status, enable and disable against a two-column meta. * fix(daemon): keep status read-only; bring install.db up to date only in operations that change state status no longer applies the schema. On an install.db that predates the daemon tables it reports that agmsgd is not in use yet and exits 0. start, stop, enable and disable bring the database up to schema.sql first. * fix(daemon): report 'not in use yet' only when install.db answers and has no daemon tables A status query that fails (corrupt or locked database) is no longer shown as an unused daemon with exit 0; it falls through to the normal read and is reported as unknown. * fix(install): match meta columns by a scalar query, and keep new test assertions enforceable Native Windows sqlite3.exe ends every output line with a CR, so comparing a multi-line column list never matched an existing column and --update tried to ALTER a column that was already there. Ask for one count per column instead. Also write the two new status assertions as single-bracket tests so the unenforceable-assertion baseline stays at 612. * fix(daemon): also add meta.codex_path when bringing an old install.db up to date --- install.sh | 4 ++++ scripts/daemon.sh | 29 +++++++++++++++++++++++++---- scripts/lib/install-db.sh | 23 ++++++++++++++++++++--- tests/test_agmsgd_daemon_sh.bats | 26 ++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index 9c94475ea..f6be4ae83 100755 --- a/install.sh +++ b/install.sh @@ -372,6 +372,10 @@ agmsg_install_operation_begin() { echo " ! could not read or create install.db's meta row" >&2 return 1 fi + if ! agmsg_install_db_apply_schema "$install_db" "$SCRIPT_DIR/scripts/daemon/schema.sql"; then + echo " ! could not apply the daemon schema to install.db" >&2 + return 1 + fi agmsg_install_op_require || return 1 if ! agmsg_install_manifest_rotate_prev "$manifest"; then echo " ! could not move the previous completion record aside" >&2 diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 043c1ba66..034d35971 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -21,6 +21,8 @@ source "$SCRIPT_DIR/lib/daemon-state.sh" source "$SCRIPT_DIR/lib/storage.sh" # shellcheck source=lib/daemon-seats.sh source "$SCRIPT_DIR/lib/daemon-seats.sh" +# shellcheck source=lib/install-db.sh +source "$SCRIPT_DIR/lib/install-db.sh" _path_hint() { printf 'If agmsg is not found, use "%s/scripts/agmsg" daemon %s.\n' "$SKILL_DIR" "$1" @@ -50,6 +52,17 @@ _require_install_db() { fi } +# An install.db made by an older install.sh lacks meta's node columns and the +# daemon tables; bring it up to schema.sql. Only the commands that change +# daemon state call this -- status stays a read. +_ensure_install_db_schema() { + _require_install_db + agmsg_install_db_apply_schema "$INSTALL_DB" "$SCRIPT_DIR/daemon/schema.sql" || { + echo "agmsg daemon: could not bring run/install.db up to date" >&2 + exit 1 + } +} + # Runs $1 (one or more SQL statements against install.db's tables, # unqualified names -- see below) inside the operation lock. Optional # remaining arguments name one resident-manager action that must share the @@ -451,7 +464,7 @@ _wait_for_ready() { } cmd_start() { - _require_install_db + _ensure_install_db_schema local state state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;")" if [ "$state" = "ready" ] && node "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" >/dev/null 2>&1; then @@ -509,7 +522,7 @@ _request_stop_over_socket() { } cmd_stop() { - _require_install_db + _ensure_install_db_schema # Stop takes no operation lock. local desired desired="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || true)" @@ -543,6 +556,14 @@ cmd_status() { *) _usage; return 2 ;; esac done + local owner_tables + # Only a database that answers and has no daemon_owner table is "not in use + # yet"; a query that fails (corrupt, locked) falls through to the normal + # read below and is reported as unknown. + if owner_tables="$(sqlite3 "$INSTALL_DB" "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='daemon_owner';" 2>/dev/null)" && [ "$owner_tables" = 0 ] && [ "$json" != true ]; then + echo "agmsg daemon status: agmsgd is not in use yet (turn it on with 'agmsg daemon enable')" + return 0 + fi local node_path node_path="$(_resolve_node node 2>/dev/null || true)" if [ -n "$node_path" ]; then @@ -584,7 +605,7 @@ cmd_status() { } cmd_enable() { - _require_install_db + _ensure_install_db_schema if [ "$(_os)" = linux ] && ! _systemd_user_usable; then echo "agmsg daemon enable: systemd user services are not available here (systemctl --user does not work), so agmsgd cannot be registered to start on its own. Nothing was changed. 'agmsg daemon start' runs it for this session only." >&2 _path_hint start >&2 @@ -617,7 +638,7 @@ cmd_enable() { } cmd_disable() { - _require_install_db + _ensure_install_db_schema _with_op_lock "UPDATE daemon_intent SET desired = 'off', op_gen = op_gen + 1, set_by = 'disable', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" _unregister local socket socket="$(sqlite3 "$INSTALL_DB" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null || true)" diff --git a/scripts/lib/install-db.sh b/scripts/lib/install-db.sh index 62d836be9..a8737db4c 100644 --- a/scripts/lib/install-db.sh +++ b/scripts/lib/install-db.sh @@ -12,9 +12,9 @@ # # schema_version starts at 1 so a later migration has a value to gate a # table-adding ALTER on. This script initially writes only -# `meta(schema_version, install_id)`; the Node/CLI-only columns -# (node_path, node_version) are added by whichever PR implements -# `enable`/`disable`. +# `meta(schema_version, install_id)`; agmsg_install_db_apply_schema below +# adds the Node/CLI-only columns (node_path, node_version) and the daemon +# tables, for both a fresh install and an --update over an older install.db. # # Required caller-set variable: none. Sources scripts/lib/compat.sh (for # compat_uuid7) and scripts/lib/sqlite-output.sh (for normalized query output) @@ -59,3 +59,20 @@ agmsg_install_db_ensure_meta() { # fi printf '%s\n' "$id" } + +# Brings install.db up to scripts/daemon/schema.sql: adds the meta columns an +# older install.db lacks (CREATE TABLE IF NOT EXISTS never alters an existing +# table), then applies the idempotent schema. The one place both install.sh and +# `agmsg daemon enable` go through. Never touches an existing install_id. +# Returns 1 on any sqlite failure. +agmsg_install_db_apply_schema() { # + local db="$1" schema="$2" col present + for col in node_path node_version codex_path; do + # One scalar per column: a multi-line result carries a CR on every line + # from native Windows sqlite3.exe, which a name comparison would miss. + present="$(agmsg_sqlite_capture "$db" "SELECT count(*) FROM pragma_table_info('meta') WHERE name = '$col';")" || return 1 + [ "$present" = 1 ] && continue + sqlite3 "$db" "ALTER TABLE meta ADD COLUMN $col TEXT;" 2>/dev/null || return 1 + done + sqlite3 "$db" < "$schema" 2>/dev/null +} diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index c066103d1..f43b437f7 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -320,6 +320,32 @@ EOF [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" = "none" ] } +@test "daemon.sh: an install.db with only the 2-column meta -- status only reads, enable and disable bring it up to date" { + [ "$(uname -s)" = "Darwin" ] || skip "this test's fake stands in for launchd specifically" + mkdir -p "$TEST_SKILL_DIR/run" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "CREATE TABLE meta (schema_version INTEGER NOT NULL, install_id TEXT NOT NULL); INSERT INTO meta VALUES (1, 'daemon-sh-test');" + _write_completion_record + local fake_launchctl + fake_launchctl="$(_fake_launchctl)" + + run bash "$DAEMON" status + [ "$status" -eq 0 ] + [ "${output#*not in use yet}" != "$output" ] + [ -z "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT name FROM sqlite_master WHERE name='daemon_owner';")" ] + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$TEST_SKILL_DIR/fake-launchd/registered" AGMSGD_FAKE_LAUNCHD_EVENTS="$TEST_SKILL_DIR/fake-launchd/events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ + run bash "$DAEMON" enable + [ "$status" -eq 0 ] + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT install_id FROM meta;")" = "daemon-sh-test" ] + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT desired FROM daemon_intent;")" = "on" ] + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$TEST_SKILL_DIR/fake-launchd/registered" AGMSGD_FAKE_LAUNCHD_EVENTS="$TEST_SKILL_DIR/fake-launchd/events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" \ + run bash "$DAEMON" disable + [ "$status" -eq 0 ] + printf 'not a sqlite database' > "$TEST_SKILL_DIR/run/install.db" + run bash "$DAEMON" status + [ "$status" -ne 0 ] + [ "${output#*not in use yet}" = "$output" ] +} + @test "daemon.sh start: stale ready state asks the registered manager to start the service" { [ "$(uname -s)" = "Darwin" ] || skip "this test's fake stands in for launchd specifically" _seed_install_db From 8debf469b100be402d08bec215b36da87ae57da2 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 12:45:34 +0900 Subject: [PATCH 28/97] Codex SessionStart: rebind a restarted session to its seat when the seat is unambiguous (#1607) [skip ci] * fix(codex): refresh destinations on session startup [skip ci] * fix(codex): serialize automatic destination replacement [skip ci] --- scripts/drivers/types/codex/_session-start.sh | 72 ++++++++++++- scripts/lib/daemon-seats.sh | 7 +- scripts/lib/role-session.sh | 49 ++++++++- tests/agmsgd_status_display.test.mjs | 102 +++++++++++++++++- tests/test_role_session.bats | 7 +- 5 files changed, 225 insertions(+), 12 deletions(-) diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index 4c9618487..a23d5f47d 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -68,6 +68,11 @@ agmsg_resolve_codex_thread() { printf '%s' "$CODEX_THREAD_ID" return 0 fi + # Hook input identifies this session directly; a rollout lookup only infers it. + if [[ "${SESSION_ID:-}" =~ ^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$ ]]; then + printf '%s' "$SESSION_ID" + return 0 + fi local sessions_dir="$HOME/.codex/sessions" [ -d "$sessions_dir" ] || return 0 # Compare PHYSICAL paths. agmsg may open the project via a symlinked/logical @@ -102,6 +107,61 @@ INNER_EOF return 0 } +# A restart gets a new thread. Prefer this project's sole registration, or a +# unique placement in this terminal generation, rather than keeping an old +# conversation merely because its rollout is still on disk. +agmsg_codex_refresh_destination() { + local thread="$1" project="$2" pairs="$3" team name count chosen_team='' chosen_name='' + AGMSG_CODEX_REFRESHED_PAIR='' + [[ "$thread" =~ ^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$ ]] || return 1 + # Never bind a role to the newest rollout inference from another session. + [ "$thread" = "${CODEX_THREAD_ID:-}" ] || [ "$thread" = "${SESSION_ID:-}" ] || return 1 + pairs="$(printf '%s\n' "$pairs" | sort -u)" + count="$(printf '%s\n' "$pairs" | grep -c . || true)" + if [ "$count" = 1 ]; then + IFS=$'\t' read -r chosen_team chosen_name <<< "$pairs" + else + # Environment-only driver observations perform no terminal writes or RPCs. + source "$SKILL_DIR/scripts/lib/terminal-registry.sh" || return 1 + local here terminal id epoch ref path recorded record_project record_type mark matches=0 + here="$(agmsg_terminal_self_env)" + case "$here" in ''|unknown:*) return 1 ;; esac + IFS=$'\t' read -r terminal id epoch <<< "$here" + case "$terminal:$epoch" in plain:*|*:|*:unknown:*) return 1 ;; esac + ref="$(agmsg_terminal_ref "$terminal" "$id")" || return 1 + while IFS=$'\t' read -r team name; do + [ -n "$team" ] && [ -n "$name" ] || continue + path="$(agmsg_spawn_path "$team" "$name")" || continue + [ -f "$path" ] || continue + IFS=$'\t' read -r recorded record_project record_type < "$path" || continue + [ "$recorded" = "$ref" ] && [ "$record_type" = codex ] || continue + [ "$(agmsg_canonical_path "$record_project" 2>/dev/null)" = "$project" ] || continue + mark="$(agmsg_role_session_named "$team" "$name")" + [ "$mark" = "$ref"$'\t'"$epoch" ] || continue + matches=$((matches + 1)); chosen_team="$team"; chosen_name="$name" + done <<< "$pairs" + [ "$matches" = 1 ] || return 1 + fi + [ -n "$chosen_team" ] && [ -n "$chosen_name" ] || return 1 + local profile="${CODEX_HOME:-${HOME:+$HOME/.codex}}" owner + case "$profile" in /*|[A-Za-z]:/*|[A-Za-z]:\\*) ;; *) return 1 ;; esac + [ -d "$profile" ] || return 1 + profile="$(agmsg_canonical_path "$profile")" || return 1 + profile="$(agmsg_normalize_project_path "$profile")" || return 1 + case "$profile" in *[[:cntrl:]]*|'') return 1 ;; esac + owner="$(agmsg_instance_id "$thread" codex 2>/dev/null)" || return 1 + agmsg_role_session_record "$chosen_team" "$chosen_name" "$thread" "$project" codex "$owner" "$profile" inactive || return 1 + agmsg_role_session_load "$chosen_team" "$chosen_name" + [ "$AGMSG_ROLE_SESSION_UUID" = "$thread" ] \ + && [ "$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" codex_home)" = "$profile" ] || return 1 + AGMSG_CODEX_REFRESHED_PAIR="$chosen_team"$'\t'"$chosen_name" +} + +agmsg_codex_unseated_notice() { + source "$SKILL_DIR/scripts/lib/boot-command.sh" + printf 'AGMSG delivery: this session is not connected to a seat yet. Run %s in Codex to receive messages.\n' "$(agmsg_actas_prompt codex '')" +} + agmsg_session_start() { # #1477: report, once, if this profile's Codex config is missing this # install's writable_roots (Codex installed after agmsg, so @@ -115,16 +175,22 @@ agmsg_session_start() { fi agmsg_codex_writable_roots_notice "$SKILL_DIR" thread_id="$(agmsg_resolve_codex_thread "$PROJECT")" - [ -n "$thread_id" ] || exit 0 + if [ -z "$thread_id" ]; then + agmsg_codex_unseated_notice + exit 0 + fi # A recorded role belongs to its recorded Codex thread. The in-sandbox # fallback has no launcher to arbitrate this, so exclude a mismatched role # rather than ever injecting it into this session's thread (#150/#350). # shellcheck source=../../../lib/role-session.sh source "$SKILL_DIR/scripts/lib/role-session.sh" project_phys="$(agmsg_canonical_path "$PROJECT" 2>/dev/null || printf '%s' "$PROJECT")" + agmsg_codex_refresh_destination "$thread_id" "$project_phys" "$PAIRS" || true safe_pairs="" while IFS=$'\t' read -r candidate_team candidate_name; do [ -n "$candidate_team" ] || continue + if [ -n "${AGMSG_CODEX_REFRESHED_PAIR:-}" ] \ + && [ "$candidate_team"$'\t'"$candidate_name" != "$AGMSG_CODEX_REFRESHED_PAIR" ]; then continue; fi candidate_thread="$(agmsg_role_session_uuid "$candidate_team" "$candidate_name" 2>/dev/null || true)" if [ -n "$candidate_thread" ]; then candidate_project="$(agmsg_role_session_get "$candidate_team" "$candidate_name" project 2>/dev/null || true)" @@ -137,6 +203,10 @@ agmsg_session_start() { safe_pairs="${safe_pairs:+$safe_pairs$'\n'}${candidate_team}"$'\t'"${candidate_name}" done <<< "$PAIRS" PAIRS="$safe_pairs" + if [ "$(printf '%s\n' "$PAIRS" | grep -c . || true)" != 1 ] \ + || { [ "$thread_id" != "${CODEX_THREAD_ID:-}" ] && [ "$thread_id" != "${SESSION_ID:-}" ]; }; then + agmsg_codex_unseated_notice + fi # A request is an authority hand-off, not a best-effort hint. Never derive a # path from an absent RUN_DIR or publish a record whose type is empty: callers # that cannot provide either input must leave the previous request untouched. diff --git a/scripts/lib/daemon-seats.sh b/scripts/lib/daemon-seats.sh index 0ec25c65f..379526aa1 100644 --- a/scripts/lib/daemon-seats.sh +++ b/scripts/lib/daemon-seats.sh @@ -12,7 +12,10 @@ agmsg_daemon_seat_report() { local action="${1:-status}" cfg cfg_sql roster team agent project pid meta_type meta_project meta_pid local bridge record_status cached key driver record_type record_home label local seen='|' omitted=0 pending owner updated recent active display - local active_lines='' recent_lines='' older_lines='' + local active_lines='' recent_lines='' older_lines='' recovery + source "$SKILL_DIR/scripts/lib/type-registry.sh" + source "$SKILL_DIR/scripts/lib/boot-command.sh" + recovery="$(agmsg_actas_prompt codex '')" driver="$(agmsg_storage_driver)" || driver=unknown for cfg in "$SKILL_DIR/teams/"*/config.json; do [ -f "$cfg" ] || continue @@ -56,7 +59,7 @@ agmsg_daemon_seat_report() { agmsg_role_session_load "$team" "$agent" 2>/dev/null || true record_type="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" type)" record_home="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" codex_home)" - record_status='destination recorded' + record_status="destination recorded; current session not verified. In Codex, run $recovery to refresh the destination" if [ "$record_type" != codex ] || [ -z "${AGMSG_ROLE_SESSION_UUID:-}" ] || [ -z "$record_home" ]; then record_status='no destination record; restart Codex and run actas again to record it' fi diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index 0f99a7996..088bb0ad2 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -139,12 +139,41 @@ agmsg_role_session_load() { # owner= the actas owner token written by actas-claim # codex_home= the effective absolute Codex profile directory # updated_at= best-effort timestamp (empty if date(1) unavailable) +# All read/modify/write operations share this lock. In particular, startup's +# inactive-owner check and replacement must be one transaction with every writer. +_agmsg_role_session_write() ( + local dir + dir="$(_actas_lock_dir)" + [ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null || return 1 + AGMSG_HELD_LOCKS='' + source "$SKILL_DIR/scripts/lib/registry-lock.sh" || return 1 + agmsg_lock_acquire "$dir" || return 1 + trap agmsg_lock_release EXIT + "$@" +) + agmsg_role_session_record() { + [ -n "${1:-}" ] && [ -n "${2:-}" ] && [ -n "${3:-}" ] || return 0 + if _agmsg_role_session_write _agmsg_role_session_record_unlocked "$@"; then return 0; fi + # Existing callers use best-effort recording; automatic takeover needs a verdict. + [ "${8:-}" != inactive ] +} + +_agmsg_role_session_record_unlocked() { local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" codex_home="${7:-}" [ -n "$team" ] && [ -n "$agent" ] && [ -n "$bare_sid" ] || return 0 local path dir tmp ts named_ref="" named_epoch="" named_at="" _agmsg_role_session_path_into "$team" "$agent" path="$_AGMSG_ROLE_SESSION_PATH" + if [ "${8:-}" = inactive ]; then + agmsg_role_session_load "$team" "$agent" + if [ "$AGMSG_ROLE_SESSION_UUID" != "$bare_sid" ] \ + && agmsg_instance_is_composite "${AGMSG_ROLE_SESSION_OWNER:-}"; then + local alive=0 + agmsg_instance_alive "$AGMSG_ROLE_SESSION_OWNER" || alive=$? + [ "$alive" = 1 ] || return 1 + fi + fi # The naming mark (named_ref / named_at, see agmsg_role_session_mark_named) # survives a re-record: this function rewrites the session fields, and the # mark is a fact about the PANE, not about the session. Dropping it here @@ -220,7 +249,11 @@ agmsg_role_session_record() { # (the daemon derives the transcript path from BOTH claude_config_dir and # this session id together). Empty means "leave session= as it is" (every # other existing caller passes none). -agmsg_role_session_set_messaging() { # [] +agmsg_role_session_set_messaging() { + _agmsg_role_session_write _agmsg_role_session_set_messaging_unlocked "$@" || true +} + +_agmsg_role_session_set_messaging_unlocked() { # [] local team="$1" agent="$2" socket="$3" config_dir="$4" bare_sid="${5:-}" [ -n "$team" ] && [ -n "$agent" ] || return 0 local path dir tmp line @@ -246,6 +279,10 @@ agmsg_role_session_set_messaging() { # +agmsg_role_session_clear_named_at() { + _agmsg_role_session_write _agmsg_role_session_clear_named_at_unlocked "$@" || true +} + +_agmsg_role_session_clear_named_at_unlocked() { # local path="$1" dir tmp line [ -n "$path" ] && [ -f "$path" ] || return 0 dir="$(_actas_lock_dir)" @@ -323,6 +364,10 @@ agmsg_role_session_named() { # a restarted server is a NEW attempt, exactly as the naming mark is. # agmsg_role_session_mark_renamed [project] [type] agmsg_role_session_mark_renamed() { + _agmsg_role_session_write _agmsg_role_session_mark_renamed_unlocked "$@" || true +} + +_agmsg_role_session_mark_renamed_unlocked() { local team="$1" agent="$2" ref="$3" epoch="${4:-}" result="${5:-}" project="${6:-}" type="${7:-}" [ -n "$team" ] && [ -n "$agent" ] && [ -n "$ref" ] || return 0 local path dir tmp ts line diff --git a/tests/agmsgd_status_display.test.mjs b/tests/agmsgd_status_display.test.mjs index eff139c73..c7fc2e1bb 100644 --- a/tests/agmsgd_status_display.test.mjs +++ b/tests/agmsgd_status_display.test.mjs @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; -import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, cpSync, mkdirSync, mkdtempSync, existsSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { execFileSync, spawnSync } from 'node:child_process'; +import { basename, join } from 'node:path'; +import { execFileSync, spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; import { openInstallDb } from '../scripts/daemon/db.mjs'; @@ -32,6 +32,102 @@ function fixture(t) { return { root, db, config, status }; } +function codexStartupFixture(t, names) { + const setup = fixture(t); + const { root, config } = setup; + const profile = join(root, 'profile'); + mkdirSync(profile); + config.agents = Object.fromEntries(names.map(name => [name, { registrations: [{ type: 'codex', project: root }] }])); + writeFileSync(join(root, 'teams/test/config.json'), JSON.stringify(config)); + unlinkSync(join(root, 'run/codex-bridge.test.alice.pid')); + const old = name => `team=test\nagent=${name}\ntype=codex\nproject=${root}\nsession=${thread}\ncodex_home=${root}\n`; + for (const name of names) writeFileSync(join(root, `run/role-session.test__${name}`), old(name)); + const env = { ...process.env, CODEX_HOME: profile, AGMSG_SELF_NAME: 'off', AGMSG_AGENT_PID: String(process.pid), + AGMSG_CONFIG: join(root, 'config.json'), AGMSG_STORAGE_PATH: root, + AGMSG_CODEX_BRIDGE_LAUNCHER: '1', AGMSG_CODEX_SEAT_KEY: 'invalid!', AGMSG_CODEX_BRIDGE_APP_SERVER: '' }; + for (const key of ['CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'HERDR_ENV', 'HERDR_PANE_ID', 'HERDR_SOCKET_PATH', 'TMUX', 'TMUX_PANE', 'TERM_PROGRAM', 'ORCA_TERMINAL_HANDLE', 'AGMSG_TERMINAL_DRIVER']) delete env[key]; + const start = (id, extra = {}) => execFileSync(process.platform === 'darwin' ? '/bin/bash' : 'bash', [join(root, 'scripts/session-start.sh'), 'codex', root], { + encoding: 'utf8', input: JSON.stringify({ session_id: id }), env: { ...env, ...extra }, + }); + const startAsync = (id, extra = {}) => { + const child = spawn(process.platform === 'darwin' ? '/bin/bash' : 'bash', [join(root, 'scripts/session-start.sh'), 'codex', root], { env: { ...env, ...extra } }); + child.stdin.end(JSON.stringify({ session_id: id })); + let output = ''; + child.stdout.on('data', data => { output += data; }); + child.stderr.on('data', data => { output += data; }); + const timer = setTimeout(() => child.kill(), 10000); + const done = new Promise(resolve => child.on('close', code => { clearTimeout(timer); resolve({ code, output }); })); + t.after(() => child.kill()); + return { done }; + }; + return { ...setup, profile, old, start, startAsync, record: name => readFileSync(join(root, `run/role-session.test__${name}`), 'utf8') }; +} + +async function waitForFile(path) { + const deadline = Date.now() + 5000; + while (!existsSync(path) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); + assert.ok(existsSync(path), `timed out waiting for ${path}`); +} + +test('Codex startup refreshes the sole destination from the current hook thread without replacing a live owner', async t => { + const { root, profile, start, startAsync, record, old, status } = codexStartupFixture(t, ['alice']); + const current = '01a13352-3599-7d62-a149-4c8182bcf008'; + start(current); + assert.match(record('alice'), new RegExp(`session=${current}\\n`)); + assert.ok(record('alice').includes(`codex_home=${realpathSync(profile)}\n`)); + assert.match(status(), /current session not verified.*\$.* actas /); + assert.ok(status().includes(`$${basename(root)} actas `)); + const live = `${old('alice')}owner=${thread}.${process.pid}\n`; + writeFileSync(join(root, 'run/role-session.test__alice'), live); + assert.match(start(current), /not connected to a seat yet.*\$.* actas /); + assert.equal(record('alice'), live); + + // Gate both writers after the owner decision, but signal lock attempts before + // acquisition. With no shared lock, B passes the old decision and overwrites A. + const library = join(root, 'scripts/lib/role-session.sh'); + let source = readFileSync(library, 'utf8'); + source = source.replace(' agmsg_lock_acquire "$dir" || return 1', ' : > "$AGMSG_TEST_WRITE_GATE.attempt.$SESSION_ID"\n agmsg_lock_acquire "$dir" || return 1'); + source = source.replace(' # The naming mark', ' : > "$AGMSG_TEST_WRITE_GATE.ready.$bare_sid"\n for gate_tick in {1..500}; do [ -f "$AGMSG_TEST_WRITE_GATE.go.$bare_sid" ] && break; sleep 0.01; done\n # The naming mark'); + writeFileSync(library, source); + writeFileSync(join(root, 'run/role-session.test__alice'), old('alice')); + const second = '01a13352-3599-7d62-a149-4c8182bcf010'; + const gate = join(root, 'writer-gate'); + const first = startAsync(current, { AGMSG_TEST_WRITE_GATE: gate }); + await waitForFile(`${gate}.ready.${current}`); + const next = startAsync(second, { AGMSG_TEST_WRITE_GATE: gate }); + await waitForFile(`${gate}.attempt.${second}`); + writeFileSync(`${gate}.go.${current}`, ''); + assert.equal((await first.done).code, 0); + writeFileSync(`${gate}.go.${second}`, ''); + const result = await next.done; + assert.equal(result.code, 0); + assert.match(result.output, /not connected to a seat yet/); + assert.match(record('alice'), new RegExp(`session=${current}\\n`)); +}); + +test('Codex startup selects a unique matching pane and epoch, and reports ambiguity instead of guessing', t => { + const { root, start, record, old } = codexStartupFixture(t, ['alice', 'bob']); + const current = '01a13352-3599-7d62-a149-4c8182bcf009'; + const ref = 'tmux:/tmp/agmsg-hook-tmux:%4'; + const pane = { TMUX: '/tmp/agmsg-hook-tmux,321,0', TMUX_PANE: '%4' }; + const mark = name => `${old(name)}named_ref=${ref}\nnamed_epoch=pid=321\n`; + writeFileSync(join(root, 'run/spawn.test__bob'), `${ref}\t${root}\tcodex\n`); + writeFileSync(join(root, 'run/role-session.test__bob'), mark('bob')); + start(current, pane); + assert.match(record('bob'), new RegExp(`session=${current}\\n`)); + assert.equal(record('alice'), old('alice')); + writeFileSync(join(root, 'run/role-session.test__bob'), mark('bob').replace('pid=321', 'pid=320')); + assert.match(start(current, pane), /not connected to a seat yet.*\$.* actas /); + assert.match(record('bob'), /named_epoch=pid=320/); + writeFileSync(join(root, 'run/role-session.test__bob'), mark('bob')); + writeFileSync(join(root, 'run/spawn.test__alice'), `${ref}\t${root}\tcodex\n`); + writeFileSync(join(root, 'run/role-session.test__alice'), mark('alice')); + assert.match(start(current, pane), /not connected to a seat yet.*\$.* actas /); + assert.equal(record('alice'), mark('alice')); + assert.equal(record('bob'), mark('bob')); + assert.match(start(current), /not connected to a seat yet.*\$.* actas /); +}); + test('daemon status defaults to readable text and emits only JSON with --json', t => { const { status } = fixture(t); const text = status(); diff --git a/tests/test_role_session.bats b/tests/test_role_session.bats index 5ec8cdfb2..cf14fa598 100644 --- a/tests/test_role_session.bats +++ b/tests/test_role_session.bats @@ -133,10 +133,9 @@ fake_session() { @test "record: writes even when mkdir -p fails but the leaf dir already exists (#579)" { # Windows codex sandbox (workspace-write + elevated): mkdir -p walks the # already-existing intermediate path and returns EACCES, not EEXIST -- the - # leaf dir itself remains present and writable throughout. Stubbing mkdir to - # always fail reproduces that: the real RUN_DIR (made in setup()) is never - # touched, so mktemp on it still succeeds and the record must still be written. - mkdir() { return 1; } + # leaf dir itself remains present and writable throughout. Fail only -p: + # creating the writer lock directly inside RUN_DIR must still be allowed. + mkdir() { [ "${1:-}" != -p ] || return 1; command mkdir "$@"; } agmsg_role_session_record T alice "sid-x" /tmp/p1 unset -f mkdir [ "$(agmsg_role_session_uuid T alice)" = "sid-x" ] From c9122791c444e54905e5055446b3175e6d4e97e0 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 13:53:14 +0900 Subject: [PATCH 29/97] daemon disable: commit the intent before stopping the service so the daemon can record its own stop (#1610) [skip ci] * daemon: stop the daemon before unregistering the service, and fix the systemd start limit [skip ci] disable stopped the service from inside the install operation lock. The dying daemon then could not write stopping/none into install.db ("database is locked" after its 5 second wait), exited with an error, and left the record at ready: disable exited 1 and status reported a ready daemon that does not answer. Reproduced under a real systemd user manager. Commit the intent, release the lock, stop the daemon over its control socket and wait for it to record none, then unregister the service under the lock. If unregistering fails, restore the intent to on so it stays together with the registration, as before. launchd and Task Scheduler go through the same path. The generated unit also had StartLimitIntervalSec and StartLimitBurst in [Service], where systemd ignores them with a warning, so the restart limit never applied. Move them to [Unit]. * daemon: disable touches only the intent and service it owns [skip ci] Between committing the off intent and unregistering, another enable can change the intent and register a new service. Record the previous intent and the op_gen this disable wrote, and under the lock unregister only while the intent is still that one; otherwise leave the newer operation alone and say so. If unregistering fails, restore the previous intent (not always on), and only when it is still this disable's own. * tests: pass the unregister marker to the disable stub through the environment [skip ci] --- scripts/daemon.sh | 79 ++++++++++++++++++++++++++++---- tests/test_agmsgd_daemon_sh.bats | 59 ++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 034d35971..4dab52eac 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -273,13 +273,13 @@ _register_linux() { cat > "$unit_path" </dev/null || true)" - _request_stop_over_socket "$socket" +# Reads daemon_owner.state, waiting up to ~10s for it to become "none". +_wait_owner_none() { local waited=0 state while [ "$waited" -lt 100 ]; do state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" @@ -650,6 +646,73 @@ cmd_disable() { waited=$((waited + 1)) sleep 0.1 done + printf '%s' "$state" +} + +# Runs on the connection _with_op_lock holds (fds 8/9): one value from install.db. +_lock_query() { + local row marker + printf '%s\n.print Q_END\n' "$1" >&8 + IFS= read -r -t 10 row <&9 || return 1 + IFS= read -r -t 10 marker <&9 || return 1 + [ "${marker%$'\r'}" = Q_END ] || return 1 + printf '%s' "${row%$'\r'}" +} + +# Turns the intent off and records what it was and which op_gen this disable +# owns, so the later steps touch only their own change. +_disable_commit_intent() { + DISABLE_PREV_INTENT="$(_lock_query "SELECT desired FROM installdb.daemon_intent;")" || return 1 + printf '%s\n' "UPDATE installdb.daemon_intent SET desired = 'off', op_gen = op_gen + 1, set_by = 'disable', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" >&8 + DISABLE_GEN="$(_lock_query "SELECT op_gen FROM installdb.daemon_intent;")" || return 1 +} + +# Unregisters only while the intent is still the one this disable wrote; a +# later enable/start owns the service now and is left alone. +_disable_unregister_if_current() { + local now + now="$(_lock_query "SELECT desired || '|' || op_gen FROM installdb.daemon_intent;")" || return 1 + if [ "$now" != "off|$DISABLE_GEN" ]; then + DISABLE_SUPERSEDED=1 + return 0 + fi + _unregister +} + +# After a failed unregister: put the intent back as it was, but only if it is +# still the one this disable wrote. +_disable_restore_intent() { + local now + now="$(_lock_query "SELECT desired || '|' || op_gen FROM installdb.daemon_intent;")" || return 1 + [ "$now" = "off|$DISABLE_GEN" ] || return 0 + printf '%s\n' "UPDATE installdb.daemon_intent SET desired = '$DISABLE_PREV_INTENT', op_gen = op_gen + 1, set_by = 'disable', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" >&8 +} + +cmd_disable() { + _ensure_install_db_schema + # The daemon writes stopping -> none into install.db on its way out, so it + # must stop while no operation holds that database: commit the intent and + # release the lock first, let the daemon finish, and only then unregister + # the service under the lock. Stopping the service from inside the lock + # made the dying daemon fail with "database is locked" and left the record + # at ready. + local DISABLE_PREV_INTENT="" DISABLE_GEN="" DISABLE_SUPERSEDED=0 + _with_op_lock "" _disable_commit_intent || return 1 + local socket state + socket="$(sqlite3 "$INSTALL_DB" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null || true)" + _request_stop_over_socket "$socket" + state="$(_wait_owner_none)" + if ! _with_op_lock "" _disable_unregister_if_current; then + _with_op_lock "" _disable_restore_intent || true + return 1 + fi + if [ "$DISABLE_SUPERSEDED" = 1 ]; then + echo "agmsg daemon disable: the service was changed by another command while disable was waiting; it was left as that command set it -- check 'agmsg daemon status'" >&2 + return 1 + fi + if [ "$state" != "none" ]; then + state="$(_wait_owner_none)" + fi if [ "$state" != "none" ]; then # A record written by an older version cannot be checked against this # boot. Disable is the deliberate way out of it: the daemon was asked to diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index f43b437f7..ea3c2ab6e 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -398,6 +398,65 @@ EOF grep -q '^locked bootout$' "$events" } +@test "daemon.sh disable: a running daemon is stopped before the service is unregistered, so its record returns to none" { + _seed_install_db + _write_completion_record + _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + mkdir -p "$TEST_SKILL_DIR/fake-linux" + printf '#!/usr/bin/env bash\necho Linux\n' > "$TEST_SKILL_DIR/fake-linux/uname" + # Like systemd, "disable --now" terminates the daemon and waits for it to go. + # The pid is read now: install.db cannot be read while disable holds the lock. + local daemon_pid + daemon_pid="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT executor_pid FROM daemon_owner;")" + [ -n "$daemon_pid" ] + cat > "$TEST_SKILL_DIR/fake-linux/systemctl" </dev/null; then + kill -TERM "\$pid" + for _ in \$(seq 1 150); do kill -0 "\$pid" 2>/dev/null || break; sleep 0.1; done + fi ;; +esac +exit 0 +EOF + chmod +x "$TEST_SKILL_DIR/fake-linux/uname" "$TEST_SKILL_DIR/fake-linux/systemctl" + local unit_id unit + unit_id="$(printf '%s:%s' "$TEST_SKILL_DIR" daemon-sh-test | shasum -a 256 | cut -c1-12)" + unit="$HOME/.config/systemd/user/agmsgd-$unit_id.service" + mkdir -p "$(dirname "$unit")" + : > "$unit" + + PATH="$TEST_SKILL_DIR/fake-linux:$PATH" AGMSGD_SYSTEMCTL="$TEST_SKILL_DIR/fake-linux/systemctl" \ + _run_with_deadline 40 bash "$DAEMON" disable + [ "$status" -eq 0 ] + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" = "none" ] + [ ! -e "$unit" ] +} + +@test "daemon.sh disable: only touches the intent and service its own step owns" { + _seed_install_db + _write_completion_record + local db="$TEST_SKILL_DIR/run/install.db" fns="$SCRIPTS/daemon-fns.sh" + # daemon.sh without its trailing dispatch, so the steps can be overridden. + awk '/^case "\$\{1:-\}" in/ {exit} {print}' "$DAEMON" > "$fns" + # (a) already off, and unregistering fails: the intent must stay off. + sqlite3 "$db" "UPDATE daemon_intent SET desired='off';" + run bash -c 'set -u; source "$1"; _unregister() { return 1; }; cmd_disable' _ "$fns" + [ "$status" -ne 0 ] + [ "$(sqlite3 "$db" "SELECT desired FROM daemon_intent;")" = "off" ] + # (b) a newer enable lands while disable waits: its service is left alone. + sqlite3 "$db" "UPDATE daemon_intent SET desired='on';" + DB="$db" MARKER="$TEST_SKILL_DIR/unregistered" run bash -c 'set -u; source "$1"; _unregister() { echo unregistered >> "$MARKER"; } + _wait_owner_none() { sqlite3 "$DB" "UPDATE daemon_intent SET desired='"'"'on'"'"', op_gen = op_gen + 1;"; printf none; } + cmd_disable' _ "$fns" + [ "$status" -ne 0 ] + [ ! -e "$TEST_SKILL_DIR/unregistered" ] + [ "$(sqlite3 "$db" "SELECT desired FROM daemon_intent;")" = "on" ] +} + @test "daemon.sh Windows registration writes the declared UTF-16 task XML" { _seed_install_db _write_completion_record From fc26a0b72b31617c87bb90216442312e296a0cd5 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 16:51:39 +0900 Subject: [PATCH 30/97] release: 1.6.0-rc.2 (#1611) * release: 1.6.0-rc.2 * fix(codex): default the four session-start environment reads added by the seat rebind * test(status): expect never-recorded seats to stay out of status --all The display change that hides seats with no destination record (even under --all) left this switch test asserting the old row. Assert the recovery hint and that the never-recorded seat is omitted. --- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- package.json | 2 +- scripts/drivers/types/codex/_session-start.sh | 8 ++++---- tests/test_agmsgd_switch.bats | 5 ++--- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3a4b00452..49e06008c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agmsg", "description": "Cross-agent messaging via SQLite. Send messages between CLI AI agents. No daemon, no network.", - "version": "1.6.0-rc.1", + "version": "1.6.0-rc.2", "author": { "name": "fujibee" }, diff --git a/VERSION b/VERSION index 2b940c405..a0091334e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0-rc.1 +1.6.0-rc.2 diff --git a/package.json b/package.json index 3aa5547f4..8925ea7b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.6.0-rc.1", + "version": "1.6.0-rc.2", "description": "Cross-agent messaging via SQLite for CLI AI agents (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Antigravity, OpenCode). The npm package is a thin bootstrapper that fetches and runs the canonical bash installer at https://github.com/fujibee/agmsg.", "bin": { "agmsg": "bin/agmsg.js" diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index a23d5f47d..53a794d5e 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -70,7 +70,7 @@ agmsg_resolve_codex_thread() { fi # Hook input identifies this session directly; a rollout lookup only infers it. if [[ "${SESSION_ID:-}" =~ ^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$ ]]; then - printf '%s' "$SESSION_ID" + printf '%s' "${SESSION_ID:-}" return 0 fi local sessions_dir="$HOME/.codex/sessions" @@ -122,7 +122,7 @@ agmsg_codex_refresh_destination() { IFS=$'\t' read -r chosen_team chosen_name <<< "$pairs" else # Environment-only driver observations perform no terminal writes or RPCs. - source "$SKILL_DIR/scripts/lib/terminal-registry.sh" || return 1 + source "${SKILL_DIR:-}/scripts/lib/terminal-registry.sh" || return 1 local here terminal id epoch ref path recorded record_project record_type mark matches=0 here="$(agmsg_terminal_self_env)" case "$here" in ''|unknown:*) return 1 ;; esac @@ -152,13 +152,13 @@ agmsg_codex_refresh_destination() { owner="$(agmsg_instance_id "$thread" codex 2>/dev/null)" || return 1 agmsg_role_session_record "$chosen_team" "$chosen_name" "$thread" "$project" codex "$owner" "$profile" inactive || return 1 agmsg_role_session_load "$chosen_team" "$chosen_name" - [ "$AGMSG_ROLE_SESSION_UUID" = "$thread" ] \ + [ "${AGMSG_ROLE_SESSION_UUID:-}" = "$thread" ] \ && [ "$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" codex_home)" = "$profile" ] || return 1 AGMSG_CODEX_REFRESHED_PAIR="$chosen_team"$'\t'"$chosen_name" } agmsg_codex_unseated_notice() { - source "$SKILL_DIR/scripts/lib/boot-command.sh" + source "${SKILL_DIR:-}/scripts/lib/boot-command.sh" printf 'AGMSG delivery: this session is not connected to a seat yet. Run %s in Codex to receive messages.\n' "$(agmsg_actas_prompt codex '')" } diff --git a/tests/test_agmsgd_switch.bats b/tests/test_agmsgd_switch.bats index c8e455a00..7acd39ebe 100644 --- a/tests/test_agmsgd_switch.bats +++ b/tests/test_agmsgd_switch.bats @@ -208,12 +208,11 @@ WRAPPER refute grep -q agmsgd "$TEST_SKILL_DIR/two.out" } -@test "status on a never-started enabled install fails with recovery and missing destinations" { +@test "status on a never-started enabled install fails with recovery and omits never-recorded seats" { run bash "$SCRIPTS/daemon.sh" status --all [ "$status" -eq 1 ] assert_contains 'agmsg daemon start' - assert_contains 'no destination record' - assert_contains 'demo/alice' + assert_lacks 'demo/alice' } @test "executor evidence recognizes a live ready owner and rejects a changed boot" { From b8568d43d17104ab48cbd11c7623d85f5dd5a1e0 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 17:07:00 +0900 Subject: [PATCH 31/97] fix(agmsgd): confirm a Claude Code nudge absorbed mid-turn (#1615) [skip ci] * fix(agmsgd): confirm a Claude Code nudge absorbed mid-turn [skip ci] A nudge that reaches a seat while it is mid-turn is folded into that turn as a queued_command attachment; no user line with origin.body is ever written. The observer only accepted the user line, so the nudge stayed pending forever and, with one pending nudge per seat and no expiry, the seat received no further notices. Accept the queued_command attachment carrying the nonce as delivery. * docs(agmsgd): name the mid-turn attachment as delivery evidence [skip ci] --- .../daemon/channels/claude-code-queue-io.mjs | 13 ++++++++++- tests/agmsgd_claude_code_queue.test.mjs | 22 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/scripts/daemon/channels/claude-code-queue-io.mjs b/scripts/daemon/channels/claude-code-queue-io.mjs index 9189efbb4..b1eef53ed 100644 --- a/scripts/daemon/channels/claude-code-queue-io.mjs +++ b/scripts/daemon/channels/claude-code-queue-io.mjs @@ -136,9 +136,12 @@ function readTail(path, maxBytes) { // Reads the delivery outcome straight from the receiving session's own // transcript. Measured line shapes (2026-10-05 addendum): // - delivered: a `type:"user"` line with `origin.body` containing the -// nonce verbatim — the ONLY line this treats as proof of delivery. +// nonce verbatim — proof of delivery (with the mid-turn shape below). // - held/declined: a `type:"system"`, `subtype:"informational"` line whose // `content` (a possibly-truncated preview) contains the nonce. +// - absorbed mid-turn (measured 2026-10-15): a `type:"attachment"` line +// whose `attachment.type` is `queued_command` and whose +// `attachment.prompt` contains the nonce; the model read it in that turn. // - a `type:"queue-operation"`, `operation:"enqueue"` line fires on mere // receipt, held or not — deliberately never read as evidence here (G4: // never mark a seat notified on evidence that could be a hold). @@ -164,6 +167,14 @@ export function observeTranscript(transcriptPath, nonce) { if (row?.type === "user" && typeof row?.origin?.body === "string" && row.origin.body.includes(marker)) { return { state: "delivered" }; } + // A nudge that arrives while the seat is mid-turn is absorbed into that + // turn: the model reads it as a queued_command attachment and no user line + // is ever written for it. Without this the nudge stays pending forever and + // the seat gets no further notices. + if (row?.type === "attachment" && row?.attachment?.type === "queued_command" + && typeof row.attachment.prompt === "string" && row.attachment.prompt.includes(marker)) { + return { state: "delivered" }; + } if (row?.type === "system" && row?.subtype === "informational" && typeof row?.content === "string" && row.content.includes(marker)) { heldSeen = true; } diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index 0f66b9268..6dae01fa2 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -21,11 +21,12 @@ function temporaryDirectory(t) { // Load-bearing property: a `queue-operation`/`enqueue` line fires on mere // receipt, Held or not, so it must NEVER by itself cause a seat to be marked // delivered -- never notify on evidence that could just as well be a hold. -// Only the `type:"user"` line whose `origin.body` carries the nonce counts. +// Only the `type:"user"` line whose `origin.body` carries the nonce, or the +// mid-turn `queued_command` attachment whose `prompt` carries it, counts. // This reproduces both line shapes measured against a live Claude Code // transcript in one file and asserts the queue-operation line alone is not // enough, while the user/origin.body line is. -test('observeTranscript: a queue-operation enqueue line is not delivery evidence; only the user/origin.body line is', (t) => { +test('observeTranscript: a queue-operation enqueue line is not delivery evidence; the user/origin.body line and a mid-turn queued_command attachment are', (t) => { const dir = temporaryDirectory(t); const transcriptPath = path.join(dir, 'session.jsonl'); const nonce = 'abc123-test-nonce'; @@ -60,6 +61,23 @@ test('observeTranscript: a queue-operation enqueue line is not delivery evidence assert.equal(observation.state, 'delivered'); resolved = resolvePendingDelivery({ observation }); assert.equal(resolved.state, 'confirmed'); + + // Absorbed mid-turn: enqueue, remove, then a queued_command attachment and + // no user line at all. The model read it, so it is delivered. + writeFileSync(transcriptPath, `${JSON.stringify({ + type: 'queue-operation', + operation: 'enqueue', + content: `${marker}`, + })}\n${JSON.stringify({ + type: 'queue-operation', + operation: 'remove', + reason: 'absorbed_mid_turn', + content: `${marker}`, + })}\n${JSON.stringify({ + type: 'attachment', + attachment: { type: 'queued_command', prompt: `${marker}` }, + })}\n`); + assert.equal(observeTranscript(transcriptPath, nonce).state, 'delivered'); }); // No clock decides a pending nudge: whatever the transcript shows (or fails to From 631bdde9f86f9d32a5b3a1421ca501bf315b3dab Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 18:13:58 +0900 Subject: [PATCH 32/97] fix(agmsgd): release a Codex pending only on proof its writers are gone (#1618) [skip ci] * Do not let a finished Codex child or an old thread hold the seat's pending nudge [skip ci] A pending reservation whose child start could not be witnessed stayed pending forever, even when the child's result was already recorded. That stopped any later nudge to the seat and, through the shared Codex home, to the seats beside it. A recorded result now means the child is done, so the reservation goes on to the queue and rollout evidence and the confirmation window. A reservation made for a thread other than the one the seat's record names now is expired at once, and the nudge goes to the current thread. * Prove the child group is gone before releasing a pending Codex nudge [skip ci] A recorded result is the parent process's result, not proof that every process in its group has exited. Keep the group id even when the start cannot be witnessed, and treat the reservation as finished only when that group no longer exists. Presence stays unresolved. A reservation for a thread the seat no longer names is likewise dropped only once its group is shown to be gone; an old writer that may still run keeps the Codex home blocked. * Treat an unreadable child record as unresolved [skip ci] A reservation that never recorded its child (the value is not JSON) now counts as unresolved, so it keeps the Codex home blocked and is not dropped when the seat's thread changes. * Log why a child start witness could not be read [skip ci] When the process start time cannot be read, write one line with the cause (the command's exit status, signal and stderr) to the daemon log, so a failure that repeats on a machine can be told apart from a one-off. --- scripts/daemon/channels/codex-queue.mjs | 27 +++++++++++---- scripts/daemon/channels/process-group.mjs | 27 +++++++++++---- tests/agmsgd_codex_queue.test.mjs | 40 +++++++++++++++++++++++ 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/scripts/daemon/channels/codex-queue.mjs b/scripts/daemon/channels/codex-queue.mjs index 434016ab5..c16072905 100644 --- a/scripts/daemon/channels/codex-queue.mjs +++ b/scripts/daemon/channels/codex-queue.mjs @@ -115,10 +115,10 @@ function setQueueState(db, id, state, queueItemId = undefined) { }); } -function recordChildStart(db, id, pid, captureGroup) { +function recordChildStart(db, id, pid, captureGroup, log = () => {}) { let group; try { - group = captureGroup(pid); + group = captureGroup(pid, (detail) => log(`codex queue: child start witness unavailable for pid ${pid}: ${detail}`)); } catch { group = { state: "unreadable", reason: "child_start_witness_unavailable" }; } @@ -244,9 +244,9 @@ function bridgeState(runDir, team, agent, projects) { // crash before the child result was recorded cannot safely be retried here. async function pendingObservation(pending, now, observeGroup) { let children; - try { children = JSON.parse(pending.children); } catch { return { state: "pending", reason: "child_group_record_unreadable" }; } + try { children = JSON.parse(pending.children); } catch { return { state: "pending", reason: "child_group_record_unreadable", childrenUnresolved: true }; } if (children.state === "incomplete") return { state: "pending", reason: "queue_child_state_incomplete", childrenUnresolved: true }; - if (children.state === "complete") { + if (children.state === "complete" || (children.state === "unreadable" && children.pgid !== undefined)) { let group; try { group = observeGroup(children); } catch { group = { state: "unreadable", reason: "child_group_observation_failed" }; } if (group?.state !== "absent") return { state: "pending", reason: group?.reason ?? "queue_child_still_running", childrenUnresolved: true }; @@ -291,7 +291,22 @@ export function createCodexQueueChannel({ const seat = seatKey(team, agent); const registeredProjects = registrationProjects(teamConfig, agent); const bridge = bridgeState(join(installRoot, "run"), team, agent, registeredProjects); - const pending = readPending(db, seat); + let pending = readPending(db, seat); + if (pending && registered) { + // A reservation made for another thread than the one this seat's record + // names now is about a conversation that is gone. It is dropped and the + // current thread gets the nudge, but only once its child is shown to be + // finished: an old writer that may still be running keeps the Codex home + // blocked exactly as before. + const current = readSession(join(installRoot, "run"), team, agent); + if (current.state === "present" && current.record.thread && current.record.thread !== pending.thread) { + const old = await pendingObservation(pending, now, observeGroup); + if (!old.childrenUnresolved) { + setQueueState(db, pending.id, "expired"); + pending = null; + } + } + } if (pending) { const observation = await pendingObservation(pending, now, observeGroup); if (observation.childrenUnresolved && pending.codex_home) blockedCodexHomes.add(pending.codex_home); @@ -454,7 +469,7 @@ export function createCodexQueueChannel({ message: inboxNudge(nonce), timeoutMs, signal: activeController.signal, - onChildStart: (pid) => recordChildStart(db, id, pid, captureGroup), + onChildStart: (pid) => recordChildStart(db, id, pid, captureGroup, log), }); } finally { activeController = null; diff --git a/scripts/daemon/channels/process-group.mjs b/scripts/daemon/channels/process-group.mjs index fc89f0ba4..5ded988f6 100644 --- a/scripts/daemon/channels/process-group.mjs +++ b/scripts/daemon/channels/process-group.mjs @@ -7,7 +7,7 @@ import { readFileSync } from "node:fs"; import { platform } from "node:os"; import { bootId, sameBoot } from "../executor.mjs"; -export function processStartWitness(pid) { +export function processStartWitness(pid, onFailure = () => {}) { if (!Number.isSafeInteger(pid) || pid < 2) return null; const os = platform(); if (os === "linux") { @@ -16,15 +16,19 @@ export function processStartWitness(pid) { const rest = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/); const start = rest[19]; return /^\d+$/.test(start ?? "") ? `linux:${start}` : null; - } catch { + } catch (error) { + onFailure(`proc stat failed: ${error?.code ?? error?.message}`); return null; } } if (os === "darwin") { try { const start = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8" }).trim(); + if (!start) onFailure("ps printed no start time"); return start ? `darwin:${start}` : null; - } catch { + } catch (error) { + const stderr = String(error?.stderr ?? "").trim().replace(/\s+/g, " ").slice(0, 200); + onFailure(`ps failed: code=${error?.code ?? ""} status=${error?.status ?? ""} signal=${error?.signal ?? ""} stderr=${stderr}`); return null; } } @@ -44,9 +48,11 @@ export function processStartWitness(pid) { return null; } -export function captureProcessGroup(pgid) { - const witness = processStartWitness(pgid); - if (!witness) return { state: "unreadable", reason: "child_start_witness_unavailable" }; +export function captureProcessGroup(pgid, onFailure = () => {}) { + const witness = processStartWitness(pgid, onFailure); + // The group id is kept even without a witness: a later observation can still + // prove the group is gone (no such group), though never that it is present. + if (!witness) return { state: "unreadable", reason: "child_start_witness_unavailable", pgid }; return { state: "complete", kind: platform() === "win32" ? "windows-native-process" : "posix-process-group", @@ -57,6 +63,15 @@ export function captureProcessGroup(pgid) { } export function observeProcessGroup(record) { + if (record?.state === "unreadable" && record.reason === "child_start_witness_unavailable" + && platform() !== "win32" && Number.isSafeInteger(record.pgid) && record.pgid >= 2) { + try { + process.kill(-record.pgid, 0); + } catch (error) { + if (error?.code === "ESRCH") return { state: "absent" }; + } + return { state: "unreadable", reason: "child_start_witness_unavailable" }; + } if (!record || record.state !== "complete" || !Number.isSafeInteger(record.pgid) || record.pgid < 2 || !record.boot_id || !record.witness) { return { state: "unreadable", reason: "child_group_record_unreadable" }; } diff --git a/tests/agmsgd_codex_queue.test.mjs b/tests/agmsgd_codex_queue.test.mjs index 98426dd7d..611a94955 100644 --- a/tests/agmsgd_codex_queue.test.mjs +++ b/tests/agmsgd_codex_queue.test.mjs @@ -554,6 +554,46 @@ test('Codex channel queues one unread snapshot and confirms it before advancing writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ agents: { alice: { registrations: [{ type: 'codex', project: root }] } }, })); + // A child whose start could not be witnessed is still observed by its group + // id: a group that is gone no longer holds the seat, and a reservation for a + // thread the seat no longer names is dropped and delivered to the current + // thread only once that group is gone. + const unwitnessed = JSON.stringify({ state: 'unreadable', reason: 'child_start_witness_unavailable', pgid: 4242, result: 'failed' }); + const insertPending = (forThread, nonce, queueItem) => installDb.prepare(` + INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) + `).run(JSON.stringify(['alpha', 'alice']), codexHome, forThread, JSON.stringify({ eventSeq: 1, legacyId: 0 }), nonce, queueItem, unwitnessed, new Date().toISOString()); + const latestState = () => installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state; + const sentThreads = []; + const logs = []; + let groupState = 'absent'; + const staleChannel = createCodexQueueChannel({ + db: installDb, installRoot: root, expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir }, + observeGroup: () => ({ state: groupState }), + log: (message) => logs.push(message), + captureGroup: (pid, onFailure) => { onFailure('ps failed: status=1'); return { state: 'unreadable', reason: 'child_start_witness_unavailable', pgid: pid }; }, + queue: async (request) => { request.onChildStart(4243); sentThreads.push(request.thread); return { kind: 'failed', started: false, reason: 'spawn_failed:test' }; }, + }); + insertPending(thread, 'unwitnessed-nonce', itemId); + await staleChannel.pollOnce(); + assert.equal(latestState(), 'confirmed', 'a gone group with no start witness still resolves from the queue evidence'); + insertPending('01a0ea97-c011-73f3-8470-fd59db15adbb', 'old-thread-nonce', null); + groupState = 'present'; + await staleChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE nonce = 'old-thread-nonce'").get().state, 'pending', 'a group that may still write keeps the old reservation'); + assert.deepEqual(sentThreads, []); + groupState = 'absent'; + installDb.prepare("UPDATE beta_codex_queue SET children = 'incomplete' WHERE nonce = 'old-thread-nonce'").run(); + await staleChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE nonce = 'old-thread-nonce'").get().state, 'pending', 'a reservation that never recorded its child keeps the old one'); + assert.deepEqual(sentThreads, []); + installDb.prepare("UPDATE beta_codex_queue SET children = ? WHERE nonce = 'old-thread-nonce'").run(unwitnessed); + await staleChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE nonce = 'old-thread-nonce'").get().state, 'expired'); + assert.deepEqual(sentThreads, [thread], 'the nudge goes to the thread the seat names now'); + assert.ok(logs.some((line) => line.includes('ps failed: status=1')), 'a missing start witness leaves its cause in the log'); + await staleChannel.stop(); + const bridgePid = String(process.pid); const bridgeBase = path.join(runDir, 'codex-bridge.alpha.alice'); const bridgeWitness = processStartWitness(process.pid); From 80a80791527e7b1842c4dddd59f23b4d3a737fa4 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 18:14:06 +0900 Subject: [PATCH 33/97] fix(agmsgd): find a Claude Code transcript by session id when the project path misses (#1619) [skip ci] The transcript was only looked for under the directory built from the seat record's project. A conversation started in another directory was never found, so the nudge stayed pending as transcript_missing. When the expected file is absent, look for the session id under the other project directories of the same config dir and use it only when exactly one matches. --- .../daemon/channels/claude-code-queue-io.mjs | 31 +++++++++++++++++-- tests/agmsgd_claude_code_queue.test.mjs | 18 +++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/scripts/daemon/channels/claude-code-queue-io.mjs b/scripts/daemon/channels/claude-code-queue-io.mjs index b1eef53ed..83bfa071c 100644 --- a/scripts/daemon/channels/claude-code-queue-io.mjs +++ b/scripts/daemon/channels/claude-code-queue-io.mjs @@ -12,7 +12,7 @@ import { randomUUID } from "node:crypto"; import { createConnection } from "node:net"; -import { closeSync, lstatSync, openSync, readFileSync, readSync, statSync } from "node:fs"; +import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs"; import process from "node:process"; import { isAbsolute } from "node:path"; @@ -42,7 +42,34 @@ export function resolveTranscriptPath(claudeConfigDir, project, sessionId) { if (typeof sessionId !== "string" || !sessionId) return ""; const encoded = encodeClaudeProjectDir(project); if (!encoded) return ""; - return `${claudeConfigDir}/projects/${encoded}/${sessionId}.jsonl`; + const expected = `${claudeConfigDir}/projects/${encoded}/${sessionId}.jsonl`; + if (isFile(expected)) return expected; + // The seat's conversation can live under another project directory than the + // one its record names (it was started elsewhere). A session id is unique, so + // exactly one match under the config dir is that conversation; none or + // several keeps the expected path and the caller's own "missing" answer. + if (!/^[A-Za-z0-9._-]+$/.test(sessionId) || sessionId.startsWith(".")) return expected; + let dirs; + try { + dirs = readdirSync(`${claudeConfigDir}/projects`, { withFileTypes: true }); + } catch { + return expected; + } + const found = []; + for (const entry of dirs) { + if (!entry.isDirectory()) continue; + const candidate = `${claudeConfigDir}/projects/${entry.name}/${sessionId}.jsonl`; + if (isFile(candidate)) found.push(candidate); + } + return found.length === 1 ? found[0] : expected; +} + +function isFile(path) { + try { + return lstatSync(path).isFile(); + } catch { + return false; + } } // Refuses to write unless the path is a Unix domain socket owned by this diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index 6dae01fa2..611a5f5d9 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -6,6 +6,7 @@ import test from 'node:test'; import { observeTranscript, + resolveTranscriptPath, resolvePendingDelivery, } from '../scripts/daemon/channels/claude-code-queue-io.mjs'; import { createClaudeCodeQueueChannel } from '../scripts/daemon/channels/claude-code-queue.mjs'; @@ -144,3 +145,20 @@ test('a nudge is sent again once the seat record names another session', async ( assert.deepEqual(db.prepare('SELECT state FROM beta_claude_queue ORDER BY id').all().map((row) => row.state), ['expired', 'pending']); await channel.stop(); }); + +test('a conversation stored under another project directory is found by its session id, only when exactly one matches', (t) => { + const config = temporaryDirectory(t); + const session = '0d7c2a52-5d4c-4d0f-8c0a-6b6f5f3f9a11'; + const expected = `${config}/projects/-proj-recorded/${session}.jsonl`; + const elsewhere = `${config}/projects/-proj-started-elsewhere/${session}.jsonl`; + mkdirSync(path.dirname(elsewhere), { recursive: true }); + writeFileSync(elsewhere, ''); + assert.equal(resolveTranscriptPath(config, '/proj/recorded', session), elsewhere); + const second = `${config}/projects/-proj-another/${session}.jsonl`; + mkdirSync(path.dirname(second), { recursive: true }); + writeFileSync(second, ''); + assert.equal(resolveTranscriptPath(config, '/proj/recorded', session), expected, 'two matches decide nothing'); + mkdirSync(path.dirname(expected), { recursive: true }); + writeFileSync(expected, ''); + assert.equal(resolveTranscriptPath(config, '/proj/recorded', session), expected, 'the recorded project wins when it has the file'); +}); From 03969b2993b89161c33c9ae59fa49cde09818e4f Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 18:25:44 +0900 Subject: [PATCH 34/97] fix(agmsgd): get Windows enable through generation, lock and task registration (#1616) [skip ci] * daemon: pass native paths to sqlite3 on Windows so enable can read the install record The native sqlite3 on Windows cannot open a Git Bash path named inside readfile() or ATTACH, so the install generation read back empty and enable stopped with a message that gave no reason. Route those paths through agmsg_sql_readfile_path, do the same inline in the launcher's bootstrap version check, and say what to do when the record really cannot be read. [skip ci] * daemon: open the lock helper's FIFOs read-write on Windows and survive its early exit On Windows a FIFO opened for reading before any writer exists reads as end-of-file, so the lock helper's sqlite3 quit at once and the next write killed the shell with SIGPIPE: enable exited 141 with no message. Open both ends first there, and ignore SIGPIPE inside the lock so an early exit is reported as a failure to take the lock. [skip ci] * daemon: do not point the unreadable-record message at a flag install can refuse install --update stops on an unfinished operation or when several installs exist, so name plain install and tell the reader to follow what it prints. [skip ci] * daemon: drive the lock's sqlite3 through a coprocess on Windows The native sqlite3 on Windows never answered over the FIFO pair, so enable and disable could not take the install operation lock. Use the coprocess's anonymous pipes there and keep the FIFOs elsewhere. [skip ci] * daemon: make Windows registration work under Git Bash Git Bash has no shasum, so the unit id came out empty; use the shared SHA-256 helper. It also rewrote schtasks' leading-slash switches into paths and passed the task XML as a Git Bash path; turn the rewrite off for schtasks and hand it a native path. [skip ci] * daemon: when the Windows task starts but agmsgd never gets ready, show Task Scheduler's record of the run [skip ci] * daemon: point the unreadable-record message at an install entry that runs agmsg install is refused from the installed command, so name npx agmsg@ install (or a checkout's install.sh) and keep the installed version. [skip ci] * daemon: name the update command that runs when the install record cannot be read [skip ci] --- scripts/daemon.sh | 63 +++++++++++++++++++++++++------- scripts/daemon/agmsgd-launch.sh | 5 +++ tests/test_agmsgd_daemon_sh.bats | 13 ++++++- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 4dab52eac..36d7a7c23 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -23,6 +23,10 @@ source "$SCRIPT_DIR/lib/storage.sh" source "$SCRIPT_DIR/lib/daemon-seats.sh" # shellcheck source=lib/install-db.sh source "$SCRIPT_DIR/lib/install-db.sh" +# shellcheck source=lib/sqlpath.sh +source "$SCRIPT_DIR/lib/sqlpath.sh" +# shellcheck source=lib/hash.sh +source "$SCRIPT_DIR/lib/hash.sh" _path_hint() { printf 'If agmsg is not found, use "%s/scripts/agmsg" daemon %s.\n' "$SKILL_DIR" "$1" @@ -81,7 +85,7 @@ _ensure_install_db_schema() { _install_generation() { local install_id manifest_path_sql manifest_id manifest_gen install_id="$(sqlite3 "$INSTALL_DB" "SELECT install_id FROM meta;" 2>/dev/null)" || return 1 - manifest_path_sql="$(printf '%s' "$SKILL_DIR/run/install-manifest.json" | sed "s/'/''/g")" + manifest_path_sql="$(agmsg_sql_readfile_path "$SKILL_DIR/run/install-manifest.json")" IFS='|' read -r manifest_id manifest_gen < <(sqlite3 :memory: "SELECT json_extract(CAST(readfile('$manifest_path_sql') AS TEXT), '\$.install_id') || '|' || json_extract(CAST(readfile('$manifest_path_sql') AS TEXT), '\$.gen');" 2>/dev/null) || return 1 [ -n "$install_id" ] && [ "$manifest_id" = "$install_id" ] && [ -n "$manifest_gen" ] || return 1 printf '%s:%s' "$install_id" "$manifest_gen" @@ -92,21 +96,41 @@ _install_generation() { # acquisition and rechecked while the lock is held, so an upgrade between # those points cannot register an obsolete launcher. _with_op_lock() { + # A lock helper that exits early must show up as "could not acquire", not as + # this shell dying silently on a write to a closed pipe (exit 141). + trap '' PIPE + local rc=0 + _with_op_lock_run "$@" || rc=$? + trap - PIPE + return "$rc" +} + +_with_op_lock_run() { local statements="$1" expected_generation current_generation ready applied lock_pid attach_path lock_dir local AGMSGD_LOCK_INSTALL_ID shift expected_generation="$(_install_generation)" || { - echo "agmsg daemon: cannot read a complete install generation" >&2 + echo "agmsg daemon: could not read this install's record (run/install-manifest.json does not match run/install.db, or one of them cannot be read). Repair it with: npx agmsg@next install --update (add --cmd if this is a named install or several installs exist), then try again." >&2 return 1 } AGMSGD_LOCK_INSTALL_ID="${expected_generation%%:*}" lock_dir="$(mktemp -d "$SKILL_DIR/run/daemon-op-lock.XXXXXX")" || return 1 - mkfifo "$lock_dir/in" "$lock_dir/out" - sqlite3 -batch "$LOCK_DB" < "$lock_dir/in" > "$lock_dir/out" 3>&- 4>&- & - lock_pid=$! - exec 8> "$lock_dir/in" - exec 9< "$lock_dir/out" - attach_path="$(printf '%s' "$INSTALL_DB" | sed "s/'/''/g")" + if [ "$(_os)" = windows ]; then + # The native sqlite3 on Windows cannot be driven through an MSYS FIFO (it + # never answers), so talk to it over a coprocess's anonymous pipes. eval + # keeps the coproc syntax out of the parse for shells that lack it. + eval 'coproc AGMSGD_LOCK_SQL { sqlite3 -batch "$LOCK_DB"; }' + lock_pid="$AGMSGD_LOCK_SQL_PID" + exec 8>&"${AGMSGD_LOCK_SQL[1]}" 9<&"${AGMSGD_LOCK_SQL[0]}" + eval "exec ${AGMSGD_LOCK_SQL[1]}>&- ${AGMSGD_LOCK_SQL[0]}<&-" + else + mkfifo "$lock_dir/in" "$lock_dir/out" + sqlite3 -batch "$LOCK_DB" < "$lock_dir/in" > "$lock_dir/out" 3>&- 4>&- & + lock_pid=$! + exec 8> "$lock_dir/in" + exec 9< "$lock_dir/out" + fi + attach_path="$(agmsg_sql_readfile_path "$INSTALL_DB")" printf '%s\n' '.bail on' "ATTACH DATABASE '$attach_path' AS installdb;" 'BEGIN EXCLUSIVE;' '.print LOCKED' >&8 if ! IFS= read -r -t 10 ready <&9 || [ "$ready" != "LOCKED" ]; then printf 'ROLLBACK;\n.quit\n' >&8 2>/dev/null || true @@ -119,7 +143,7 @@ _with_op_lock() { return 1 fi local generation_row generation_marker manifest_sql - manifest_sql="$(printf '%s' "$SKILL_DIR/run/install-manifest.json" | sed "s/'/''/g")" + manifest_sql="$(agmsg_sql_readfile_path "$SKILL_DIR/run/install-manifest.json")" printf '%s\n' "SELECT CASE WHEN json_extract(CAST(readfile('$manifest_sql') AS TEXT), '\$.install_id') = (SELECT install_id FROM installdb.meta) THEN (SELECT install_id FROM installdb.meta) || ':' || json_extract(CAST(readfile('$manifest_sql') AS TEXT), '\$.gen') ELSE '' END;" '.print GENERATION_END' >&8 IFS= read -r -t 10 generation_row <&9 || generation_row="" IFS= read -r -t 10 generation_marker <&9 || generation_marker="" @@ -191,7 +215,8 @@ _unit_id() { if [ -z "$install_id" ]; then install_id="$(sqlite3 "$INSTALL_DB" "SELECT install_id FROM meta;" 2>/dev/null)" fi - printf '%s:%s' "$SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12 + # shasum is absent from Git Bash; agmsg_sha256 falls back to sha256sum. + printf '%s:%s' "$SKILL_DIR" "$install_id" | agmsg_sha256 | cut -c1-12 } _os() { @@ -323,7 +348,7 @@ _start_registered_service() { case "$(_os)" in darwin) "$AGMSGD_LAUNCHCTL" kickstart "gui/$(id -u)/$(_launchd_label)" ;; linux) "$AGMSGD_SYSTEMCTL" --user start "$(basename "$(_systemd_unit_path)")" ;; - windows) "$AGMSGD_SCHTASKS" /Run /TN "$(_schtasks_name)" ;; + windows) _schtasks /Run /TN "$(_schtasks_name)" ;; *) echo "agmsg daemon start: unsupported resident manager" >&2; return 1 ;; esac } @@ -337,6 +362,12 @@ _start_unregistered_launcher() { # NOT YET RUN ON WINDOWS in this environment -- written to the Windows # Task Scheduler contract (LogonTrigger + RestartOnFailure via XML, since plain # `/Create` flags do not set restart-on-failure) and shellchecked only. +# schtasks takes /Create-style switches; without this Git Bash rewrites a +# leading-slash argument into a path ("C:/Program Files/Git/Create"). +_schtasks() { + MSYS2_ARG_CONV_EXCL='*' "$AGMSGD_SCHTASKS" "$@" +} + _register_windows() { local node_path="$1" name xml name="$(_schtasks_name)" @@ -366,7 +397,9 @@ _register_windows() { EOF { printf '\xff\xfe'; iconv -f UTF-8 -t UTF-16LE "$xml_utf8"; } > "$xml" rm -f "$xml_utf8" - "$AGMSGD_SCHTASKS" /Create /TN "$name" /XML "$xml" /F + local native_xml + native_xml="$(cygpath -w "$xml" 2>/dev/null || printf '%s' "$xml")" + _schtasks /Create /TN "$name" /XML "$native_xml" /F } _unregister_windows() { @@ -376,7 +409,7 @@ _unregister_windows() { if [ ! -f "$xml" ]; then return 0 fi - if ! "$AGMSGD_SCHTASKS" /Delete /TN "$name" /F; then + if ! _schtasks /Delete /TN "$name" /F; then echo "agmsg daemon: could not delete scheduled task $name" >&2 return 1 fi @@ -491,6 +524,10 @@ cmd_start() { _path_hint status else echo "agmsg daemon start: did not become ready in time -- check 'agmsg daemon status'" >&2 + if [ "$(_os)" = windows ] && _service_registered; then + echo "agmsg daemon start: the scheduled task $(_schtasks_name) was created and started, but agmsgd never reported ready. Task Scheduler's own record of the run follows; 'agmsg daemon disable' removes the task." >&2 + _schtasks /Query /TN "$(_schtasks_name)" /V /FO LIST 2>&1 | grep -E 'Status|Last Run Time|Last Result|Task To Run' >&2 || true + fi agmsg_daemon_warn_if_stopped always _path_hint status >&2 return 1 diff --git a/scripts/daemon/agmsgd-launch.sh b/scripts/daemon/agmsgd-launch.sh index 82e3dd9e9..c322cd36e 100755 --- a/scripts/daemon/agmsgd-launch.sh +++ b/scripts/daemon/agmsgd-launch.sh @@ -56,6 +56,11 @@ _agmsgd_launch_lock_held() { # path; this does not decide completeness, only the version match. _agmsgd_launch_check_bootstrap_version() { local manifest_path="$1" recorded + # sqlite3 is a native binary on Windows and cannot open a Git Bash path from + # inside a readfile() literal; this file sources no lib, so convert inline. + if command -v cygpath >/dev/null 2>&1; then + manifest_path="$(cygpath -w "$manifest_path" 2>/dev/null || printf '%s' "$manifest_path")" + fi recorded="$(sqlite3 :memory: " WITH raw(json) AS (SELECT CAST(readfile('$(printf '%s' "$manifest_path" | sed "s/'/''/g")') AS TEXT)) SELECT json_extract(json, '\$.bootstrap_version') FROM raw; diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index ea3c2ab6e..41e0eec53 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -277,7 +277,14 @@ case "$1" in /Delete) rm -f "${AGMSGD_FAKE_TASK_MARKER:?}" ;; esac EOF - chmod +x "$TEST_SKILL_DIR/fake-windows/uname" "$TEST_SKILL_DIR/fake-windows/schtasks" + # Passes the path through and records what was asked to be converted: the + # native sqlite3 on Windows cannot open a Git Bash path named inside SQL. + cat > "$TEST_SKILL_DIR/fake-windows/cygpath" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$2" >> "${AGMSGD_FAKE_CYGPATH_LOG:?}" +printf '%s\n' "$2" +EOF + chmod +x "$TEST_SKILL_DIR/fake-windows/uname" "$TEST_SKILL_DIR/fake-windows/schtasks" "$TEST_SKILL_DIR/fake-windows/cygpath" } @test "daemon.sh enable/disable: registers a resident unit (via a fake launchctl on macOS) and cleans it up" { @@ -465,9 +472,13 @@ EOF capture="$TEST_SKILL_DIR/fake-windows/captured.xml" marker="$TEST_SKILL_DIR/fake-windows/registered" + export AGMSGD_FAKE_CYGPATH_LOG="$TEST_SKILL_DIR/fake-windows/cygpath.log" PATH="$TEST_SKILL_DIR/fake-windows:$PATH" AGMSGD_SCHTASKS="$TEST_SKILL_DIR/fake-windows/schtasks" AGMSGD_FAKE_TASK_XML="$capture" AGMSGD_FAKE_TASK_MARKER="$marker" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ _run_with_deadline 15 bash "$DAEMON" enable [ "$status" -eq 0 ] + # Every path named inside SQL went through the native-path conversion. + grep -Fq "$TEST_SKILL_DIR/run/install-manifest.json" "$AGMSGD_FAKE_CYGPATH_LOG" + grep -Fq "$TEST_SKILL_DIR/run/install.db" "$AGMSGD_FAKE_CYGPATH_LOG" [ "$(od -An -tx1 -N2 "$capture" | tr -d ' \n')" = "fffe" ] iconv -f UTF-16LE -t UTF-8 "$capture" | grep -q 'encoding="UTF-16"' From 546a55296d7333b84ab0f87e25a05da7bf587870 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 15 Oct 2026 23:57:31 +0900 Subject: [PATCH 35/97] daemon start: find and record Node when none is recorded (#1620) [skip ci] Without a service manager, enable refuses and records nothing, and tells the user to run agmsg daemon start instead. start then turned the intent on and launched agmsgd, whose launcher stopped with "no usable Node recorded (run 'agmsg daemon enable' again)", so neither command could make progress and the intent stayed on. start now resolves Node the same way enable does when none is recorded (or the recorded path is no longer executable), records it together with the intent change, and starts. If no usable Node exists it stops before changing anything and says what to install. --- scripts/daemon.sh | 18 +++++++++++++++++- tests/test_agmsgd_daemon_sh.bats | 10 ++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 36d7a7c23..0406da23f 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -507,8 +507,24 @@ cmd_start() { return 0 fi + # A install that was never enabled (enable refuses without a service + # manager) has no Node recorded, and the launcher cannot start without + # one. Find it here the way enable does, and stop before touching the + # intent when there is none. + local recorded_node found_node found_version record_node_sql="" + recorded_node="$(sqlite3 "$INSTALL_DB" "SELECT node_path FROM meta;" 2>/dev/null || true)" + if [ -z "$recorded_node" ] || [ ! -x "$recorded_node" ]; then + found_node="$(_resolve_node node)" || { + echo "agmsg daemon start: no usable Node found (need >= 22.13.0 with node:sqlite), so agmsgd cannot start. Nothing was changed. Install Node from https://nodejs.org/en/download, then run agmsg daemon start." >&2 + _path_hint start >&2 + return 1 + } + found_version="$("$found_node" -e 'console.log(process.versions.node)')" + record_node_sql="UPDATE meta SET node_path = '$(printf '%s' "$found_node" | sed "s/'/''/g")', node_version = '$found_version';" + fi + local new_op_gen - _with_op_lock "UPDATE daemon_intent SET desired = 'on', op_gen = op_gen + 1, set_by = 'start', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" + _with_op_lock "$record_node_sql UPDATE daemon_intent SET desired = 'on', op_gen = op_gen + 1, set_by = 'start', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" new_op_gen="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;")" if _service_registered; then if ! _start_registered_service; then diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index 41e0eec53..1a024231d 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -182,6 +182,16 @@ _assert_start_result() { bash "$DAEMON" stop >/dev/null 2>&1 || true } +@test "daemon.sh start: an install that never recorded Node finds and records it, then starts" { + _seed_install_db + _write_completion_record + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET node_path = NULL, node_version = NULL;" + _run_with_deadline 15 bash "$DAEMON" start + _assert_start_result 'running' + [ -x "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT node_path FROM meta;")" ] + bash "$DAEMON" stop >/dev/null 2>&1 || true +} + @test "daemon.sh start: a second start while already running is a no-op" { _seed_install_db _write_completion_record From 8694c78419f3b4389fc3a56bdaafbd2f86d018fe Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 00:10:21 +0900 Subject: [PATCH 36/97] install --update: restart an enabled agmsgd and backfill the Codex path (#1621) [skip ci] * daemon: start an enabled agmsgd after --update, and fill an empty meta.codex_path on start [skip ci] After --update the running daemon steps aside for the new files within one poll and nothing started it again, so an enabled agmsgd stayed stopped until someone ran daemon start. When the install has the daemon turned on, the update now waits (up to 25s) for the step-aside and starts the updated version; a failure prints the command and does not fail the update. An install enabled by rc.1 can keep an empty meta.codex_path, which blocks every Codex seat. daemon start now resolves Codex from PATH and fills the column when it is empty, and never overwrites a recorded path. * daemon: an update restarts agmsgd only if the intent it saw is still current [skip ci] The update read desired=on once, waited up to 25s for the daemon to step aside, then ran a plain daemon start, which writes desired=on. A stop or disable made during the wait was undone. The update now records the op_gen it saw and runs start --only-if-op-gen N; the guard is part of the UPDATE inside the start operation lock, so a newer intent is left alone. --- install.sh | 27 +++++++++++++++++++++++++- scripts/daemon.sh | 30 +++++++++++++++++++++++++++-- tests/test_agmsgd_daemon_sh.bats | 33 ++++++++++++++++++++++++++++++++ tests/test_install.bats | 13 +++++++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index f6be4ae83..9fdcac171 100755 --- a/install.sh +++ b/install.sh @@ -140,12 +140,36 @@ agmsg_source_version() { fi } +# After an --update the running agmsgd notices the new files within one poll +# (5s), stops itself with "stepped_aside_for_update" and nothing starts it +# again. When the install had the daemon turned on, wait for that step-aside +# (bounded) and start the new version, so an enabled daemon comes back by +# itself. A failure here never fails the update; it says how to start it. +agmsg_install_restart_daemon() { # + local skill="$1" db="$1/run/install.db" intent op_gen state waited=0 + [ -f "$db" ] || return 0 + # The intent as the update saw it; the start below only goes ahead if it is + # still this one, so a stop or disable during the wait is not undone. + intent="$(sqlite3 "$db" "SELECT desired || '|' || op_gen FROM daemon_intent;" 2>/dev/null || true)" + case "$intent" in on\|[0-9]*) op_gen="${intent#on|}" ;; *) return 0 ;; esac + while [ "$waited" -lt 25 ]; do + state="$(sqlite3 "$db" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + [ "$state" = ready ] || break + waited=$((waited + 1)) + sleep 1 + done + echo " ~ agmsgd is turned on; starting the updated version" + if ! bash "$skill/scripts/daemon.sh" start --only-if-op-gen "$op_gen"; then + echo " ! agmsgd did not start after the update; run: bash $(printf '%q' "$skill/scripts/agmsg") daemon start" >&2 + fi +} + agmsg_install_restart_guidance() { echo ' Next: finish your current work, then close and reopen each running agent.' echo ' Resume the same conversation and run your messaging skill with actas to refresh its destination.' printf ' Check notice delivery from that session with: bash %q status \n' "$SKILL_DIR/scripts/delivery.sh" printf ' If you use agmsgd, check it with: bash %q daemon status\n' "$SKILL_DIR/scripts/agmsg" - echo ' If status says the enabled daemon is stopped, run agmsg daemon start.' + echo ' An update starts an enabled agmsgd again by itself; if status still says it is stopped, run agmsg daemon start.' echo ' This installation does not enable agmsgd or switch existing sessions automatically.' } @@ -1237,6 +1261,7 @@ $_agmsg_running_team" echo " Check with 'delivery.sh status '. (#133)" echo "" agmsg_install_operation_finish || exit 1 + agmsg_install_restart_daemon "$SKILL_DIR" echo " ✓ Update complete" echo "" exit 0 diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 0406da23f..ea4c03c22 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -496,7 +496,18 @@ _wait_for_ready() { return 1 } +# `start --only-if-op-gen N` is what an update uses to bring a daemon back: it +# starts only if the intent is still the "on" at op_gen N the update saw, so a +# stop/disable/start made by a person in the meantime is never undone. cmd_start() { + local only_gen="" + case "${1:-}" in + --only-if-op-gen) + only_gen="${2:-}" + case "$only_gen" in ''|*[!0-9]*) _usage; return 2 ;; esac ;; + '') ;; + *) _usage; return 2 ;; + esac _ensure_install_db_schema local state state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;")" @@ -523,9 +534,24 @@ cmd_start() { record_node_sql="UPDATE meta SET node_path = '$(printf '%s' "$found_node" | sed "s/'/''/g")', node_version = '$found_version';" fi + # An install enabled by an earlier version (or updated before Codex was on + # PATH) can hold an empty meta.codex_path, which blocks every Codex seat: + # fill it from the current PATH, never overwriting a path already there. + local codex_fill="" codex_path + codex_path="$(_resolve_codex 2>/dev/null || true)" + if [ -n "$codex_path" ]; then + codex_fill="UPDATE meta SET codex_path = '$(printf '%s' "$codex_path" | sed "s/'/''/g")' WHERE COALESCE(codex_path, '') = '';" + fi local new_op_gen - _with_op_lock "$record_node_sql UPDATE daemon_intent SET desired = 'on', op_gen = op_gen + 1, set_by = 'start', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" + local start_where="" + [ -z "$only_gen" ] || start_where=" WHERE desired = 'on' AND op_gen = $only_gen" + _with_op_lock "$record_node_sql $codex_fill UPDATE daemon_intent SET desired = 'on', op_gen = op_gen + 1, set_by = 'start', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')$start_where;" new_op_gen="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;")" + # The guarded UPDATE applied only if it left "on" at op_gen+1 written by start. + if [ -n "$only_gen" ] && [ "$(sqlite3 "$INSTALL_DB" "SELECT desired || '|' || op_gen || '|' || set_by FROM daemon_intent;")" != "on|$((only_gen + 1))|start" ]; then + echo "agmsg daemon start: agmsgd was turned off or changed while waiting; left as it is now" + return 0 + fi if _service_registered; then if ! _start_registered_service; then echo "agmsg daemon start: the registered service manager could not start agmsgd" >&2 @@ -797,7 +823,7 @@ cmd_disable() { } case "${1:-}" in - start) cmd_start ;; + start) shift; cmd_start "$@" ;; stop) cmd_stop ;; status) shift; cmd_status "$@" ;; enable) cmd_enable ;; diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index 1a024231d..86c198778 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -474,6 +474,39 @@ EOF [ "$(sqlite3 "$db" "SELECT desired FROM daemon_intent;")" = "on" ] } +@test "daemon.sh start: an empty meta.codex_path is filled from PATH, and a path already recorded is kept" { + _seed_install_db + _write_completion_record + local db="$TEST_SKILL_DIR/run/install.db" fake="$TEST_SKILL_DIR/fake-codex" + mkdir -p "$fake" + printf '#!/usr/bin/env bash\nexit 0\n' > "$fake/codex" + chmod +x "$fake/codex" + [ -z "$(sqlite3 "$db" "SELECT COALESCE(codex_path, '') FROM meta;")" ] + AGMSG_REAL_CODEX="$fake/codex" _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + [ "$(sqlite3 "$db" "SELECT codex_path FROM meta;")" = "$(cd "$fake" && pwd -P)/codex" ] + bash "$DAEMON" stop + sqlite3 "$db" "UPDATE meta SET codex_path = '/already/recorded/codex';" + AGMSG_REAL_CODEX="$fake/codex" _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + [ "$(sqlite3 "$db" "SELECT codex_path FROM meta;")" = "/already/recorded/codex" ] +} + +@test "daemon.sh start --only-if-op-gen: a stop or disable after that op_gen is not undone" { + _seed_install_db + _write_completion_record + local db="$TEST_SKILL_DIR/run/install.db" seen + sqlite3 "$db" "UPDATE daemon_intent SET desired = 'on', op_gen = op_gen + 1;" + seen="$(sqlite3 "$db" "SELECT op_gen FROM daemon_intent;")" + # a person turns it off after the update looked + sqlite3 "$db" "UPDATE daemon_intent SET desired = 'off', op_gen = op_gen + 1, set_by = 'disable';" + _run_with_deadline 15 bash "$DAEMON" start --only-if-op-gen "$seen" + [ "$status" -eq 0 ] + [ "$(sqlite3 "$db" "SELECT desired FROM daemon_intent;")" = "off" ] + [ "$(sqlite3 "$db" "SELECT state FROM daemon_owner;")" = "none" ] + [ "${output#*left as it is now}" != "$output" ] +} + @test "daemon.sh Windows registration writes the declared UTF-16 task XML" { _seed_install_db _write_completion_record diff --git a/tests/test_install.bats b/tests/test_install.bats index 292c28a22..abdb0b78a 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -408,6 +408,19 @@ teardown() { [ "$status" -eq 0 ] } +@test "install: --update starts an agmsgd that was turned on again, instead of leaving it stopped" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + sqlite3 "$SK/run/install.db" "UPDATE meta SET node_path = '$(command -v node)'; UPDATE daemon_intent SET desired = 'on';" + [ "$(sqlite3 "$SK/run/install.db" "SELECT state FROM daemon_owner;")" = "none" ] + HOME="$FAKE_HOME" run bash "$REPO_ROOT/install.sh" --update + local owner_state + owner_state="$(sqlite3 "$SK/run/install.db" "SELECT state FROM daemon_owner;")" + bash "$SK/scripts/daemon.sh" stop || true + [ "$status" -eq 0 ] + [ "${output#*starting the updated version}" != "$output" ] + [ "$owner_state" = "ready" ] +} + @test "install: ships an executable uninstall.sh so npx/curl installs have one to run later" { # setup.sh's temp checkout is deleted right after install, so a copy inside # the skill dir is the only uninstaller npx/curl-installed users ever have. From dd36826bb21c0f9991a094fae5565e3b050365e3 Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 07:38:01 +0900 Subject: [PATCH 37/97] release: 1.6.0-rc.3 (#1623) * fix(daemon): guard the Windows lock coprocess handles Name the coprocess in lowercase like other locals so its fd array and PID are not read as environment variables, and refuse with a reason when the coprocess did not start instead of exec-ing empty descriptors. * release: 1.6.0-rc.3 * fix(daemon): keep the Windows lock coprocess off bats fds and off bash 3.2 The coprocess inherited fd 3/4, which the spawn guard rejects, and bash 3.2 has no coproc, so a Windows code path exercised on macOS failed to parse. Close fd 3/4 in the coprocess and use the FIFO path when bash is older than 4. --- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- package.json | 2 +- scripts/daemon.sh | 18 ++++++++++++------ 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 49e06008c..5db94ac5d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agmsg", "description": "Cross-agent messaging via SQLite. Send messages between CLI AI agents. No daemon, no network.", - "version": "1.6.0-rc.2", + "version": "1.6.0-rc.3", "author": { "name": "fujibee" }, diff --git a/VERSION b/VERSION index a0091334e..7cb25adcb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0-rc.2 +1.6.0-rc.3 diff --git a/package.json b/package.json index 8925ea7b9..fd6b38f0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.6.0-rc.2", + "version": "1.6.0-rc.3", "description": "Cross-agent messaging via SQLite for CLI AI agents (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Antigravity, OpenCode). The npm package is a thin bootstrapper that fetches and runs the canonical bash installer at https://github.com/fujibee/agmsg.", "bin": { "agmsg": "bin/agmsg.js" diff --git a/scripts/daemon.sh b/scripts/daemon.sh index ea4c03c22..2d9877670 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -115,14 +115,20 @@ _with_op_lock_run() { } AGMSGD_LOCK_INSTALL_ID="${expected_generation%%:*}" lock_dir="$(mktemp -d "$SKILL_DIR/run/daemon-op-lock.XXXXXX")" || return 1 - if [ "$(_os)" = windows ]; then + if [ "$(_os)" = windows ] && [ "${BASH_VERSINFO[0]}" -ge 4 ]; then # The native sqlite3 on Windows cannot be driven through an MSYS FIFO (it # never answers), so talk to it over a coprocess's anonymous pipes. eval - # keeps the coproc syntax out of the parse for shells that lack it. - eval 'coproc AGMSGD_LOCK_SQL { sqlite3 -batch "$LOCK_DB"; }' - lock_pid="$AGMSGD_LOCK_SQL_PID" - exec 8>&"${AGMSGD_LOCK_SQL[1]}" 9<&"${AGMSGD_LOCK_SQL[0]}" - eval "exec ${AGMSGD_LOCK_SQL[1]}>&- ${AGMSGD_LOCK_SQL[0]}<&-" + # keeps the coproc syntax out of the parse for shells that lack it (bash + # 3.2 has no coproc; Git Bash ships bash 5). + eval 'coproc agmsgd_lock_sql { sqlite3 -batch "$LOCK_DB" 3>&- 4>&-; }' + lock_pid="${agmsgd_lock_sql_PID:-}" + if [ -z "$lock_pid" ] || [ -z "${agmsgd_lock_sql[0]:-}" ] || [ -z "${agmsgd_lock_sql[1]:-}" ]; then + rmdir "$lock_dir" 2>/dev/null || true + echo 'agmsg daemon: could not start sqlite3 to hold the install operation lock' >&2 + return 1 + fi + exec 8>&"${agmsgd_lock_sql[1]:-}" 9<&"${agmsgd_lock_sql[0]:-}" + eval "exec ${agmsgd_lock_sql[1]:-}>&- ${agmsgd_lock_sql[0]:-}<&-" else mkfifo "$lock_dir/in" "$lock_dir/out" sqlite3 -batch "$LOCK_DB" < "$lock_dir/in" > "$lock_dir/out" 3>&- 4>&- & From d9bcb7e2642c37dc99d19dad7373c3c6f863da3b Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 11:01:44 +0900 Subject: [PATCH 38/97] Stop two false warnings in the Codex app (#1632) * Stop two false warnings in the Codex app [skip ci] The daemon health check treated a boot id or process start time it could not read (an app sandbox denies the tools) the same as a mismatch, and told the user to restart a running daemon. While the recorded pid is alive, an unreadable check is now reported as unchecked, with no restart advice and no warning on ordinary commands. A real mismatch still reads as stopped. The Codex app and Claude desktop terminal drivers now say they have no locator instance, so every command no longer prints that the driver cannot resolve one. * Tell a refused liveness signal from a missing process [skip ci] Measured in the app sandbox: the boot id, the process start time and the signal check on a live pid are all refused, and the signal check fails the same way for a live and a missing pid. Read the error text instead of the exit status: not permitted means the process exists, no such process means it is gone, and anything else is unknown. Only a clearly missing process still reads as stopped. * Let a readable boot mismatch win over an unreadable start time [skip ci] A boot id that can be read and differs from the record proves the recorded executor is from another boot, so it reads as stopped even when the start time cannot be read. A denied boot id read no longer aborts a caller running under errexit; it degrades to unchecked. * Set the C locale inside the subshell that reads the signal error [skip ci] A variable prefix on a builtin does not reliably change the locale on Bash 3.2, so export it inside the subshell, as the instance id code already does. * Let a readable start-time contradiction win over unchecked [skip ci] A process start time that is readable and later than the owner's recorded claim is a reused pid, so it reads as stopped even when the boot id cannot be read. --- .../drivers/terminals/claude-desktop/ops.sh | 3 ++ scripts/drivers/terminals/codex-app/ops.sh | 3 ++ scripts/lib/daemon-state.sh | 49 +++++++++++++++++-- tests/test_agmsgd_switch.bats | 34 +++++++++++++ tests/test_terminal_registry.bats | 4 ++ 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/scripts/drivers/terminals/claude-desktop/ops.sh b/scripts/drivers/terminals/claude-desktop/ops.sh index 33c869775..2c4ae506b 100644 --- a/scripts/drivers/terminals/claude-desktop/ops.sh +++ b/scripts/drivers/terminals/claude-desktop/ops.sh @@ -133,6 +133,9 @@ terminal_name() { _claude_desktop_unsupported "name"; } # No independent agent key to compare — this driver never names a pane. terminal_expected_label() { printf 'n/a:no_addressable_pane\n'; } +# The app is one conversation, not an instance of a multiplexer: its ref is +# already complete, so a locator needs nothing added. +terminal_instance_for_ref() { printf 'n/a:bare\n'; } # No activity, label, key or title concept of its own — same shape as # plain's own terminal_team_observe, all four fields n/a. diff --git a/scripts/drivers/terminals/codex-app/ops.sh b/scripts/drivers/terminals/codex-app/ops.sh index 29458798c..c3b79def2 100644 --- a/scripts/drivers/terminals/codex-app/ops.sh +++ b/scripts/drivers/terminals/codex-app/ops.sh @@ -56,6 +56,9 @@ terminal_poke() { _codex_app_unsupported poke; } terminal_arrange() { _codex_app_unsupported arrange; } terminal_name() { _codex_app_unsupported name; } terminal_expected_label() { printf 'n/a:no_addressable_pane\n'; } +# The app is one conversation, not an instance of a multiplexer: its ref is +# already complete, so a locator needs nothing added. +terminal_instance_for_ref() { printf 'n/a:bare\n'; } terminal_team_observe() { printf 'n/a:no_addressable_pane\tn/a:no_addressable_pane\tn/a:no_addressable_pane\tn/a:no_addressable_pane\n' } diff --git a/scripts/lib/daemon-state.sh b/scripts/lib/daemon-state.sh index 985c9168b..751f574a3 100644 --- a/scripts/lib/daemon-state.sh +++ b/scripts/lib/daemon-state.sh @@ -7,6 +7,19 @@ _AGMSG_DAEMON_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # shellcheck disable=SC1091 source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/instance-id.sh" +# Does the recorded pid exist? Answers "gone" only on a clear "no such +# process". A sandbox may refuse the signal even for a live process (not +# permitted), which says the process exists; anything else is "unknown". +_agmsg_daemon_pid_presence() { # + local out + out="$(export LC_ALL=C; kill -0 "$1" 2>&1)" && { echo alive; return 0; } + case "$out" in + *"No such process"*) echo gone ;; + *"not permitted"*) echo alive ;; + *) echo unknown ;; + esac +} + agmsg_daemon_read_state() { AGMSGD_DESIRED=unknown; AGMSGD_HEALTH=unknown; AGMSGD_REASON="install record unavailable" local row state pid boot recorded_start current_boot process_start started_epoch @@ -27,7 +40,7 @@ agmsg_daemon_read_state() { case "$pid" in ''|*[!0-9]*|0|1) return 0 ;; esac case "$(uname -s)" in Darwin) - current_boot="$(sysctl -n kern.bootsessionuuid 2>/dev/null)" + current_boot="$(sysctl -n kern.bootsessionuuid 2>/dev/null)" || current_boot="" process_start="$(LC_ALL=C ps -p "$pid" -o lstart= 2>/dev/null)" || process_start="" started_epoch="$(LC_ALL=C date -j -f '%a %b %e %T %Y' "$process_start" +%s 2>/dev/null)" || started_epoch="" ;; @@ -56,12 +69,17 @@ agmsg_daemon_read_state() { fi ;; esac fi - if [ -z "$current_boot" ] || [ "$current_boot" != "$boot" ] || [ -z "$started_epoch" ]; then + # Evidence that could not be gathered is not evidence of a mismatch. A + # sandboxed caller (an app that runs commands in its own sandbox) may be + # denied the boot id, the process start time and even the liveness signal; + # unless the pid is clearly gone, say the check is unavailable, not stopped. + # A boot id that can be read and differs from the record is proof, whatever + # else cannot be read: that executor is from another boot. + if [ -n "$current_boot" ] && [ "$current_boot" != "$boot" ]; then AGMSGD_REASON="executor missing or boot/start evidence does not match ($AGMSGD_REASON)" return 0 fi - # The owner records its claim time. A process born after that claim is a - # reused PID, never evidence that the recorded executor is still running. + # Likewise a readable start time later than the owner's claim: a reused pid. local claim_epoch case "$recorded_start" in ????-??-??T??:??:??*) @@ -71,6 +89,23 @@ agmsg_daemon_read_state() { esac ;; *) claim_epoch="" ;; esac + if [ -n "$started_epoch" ] && [ -n "$claim_epoch" ] && [ "$started_epoch" -gt "$claim_epoch" ]; then + AGMSGD_REASON="executor start evidence does not match ($AGMSGD_REASON)" + return 0 + fi + if [ -z "$current_boot" ] || [ -z "$started_epoch" ]; then + case "$(_agmsg_daemon_pid_presence "$pid")" in + gone) ;; + *) + AGMSGD_HEALTH=unchecked + AGMSGD_REASON="the boot id or process start time cannot be read from here" + return 0 ;; + esac + fi + if [ -z "$current_boot" ] || [ "$current_boot" != "$boot" ] || [ -z "$started_epoch" ]; then + AGMSGD_REASON="executor missing or boot/start evidence does not match ($AGMSGD_REASON)" + return 0 + fi if [ -z "$claim_epoch" ] || [ "$started_epoch" -gt "$claim_epoch" ]; then AGMSGD_REASON="executor start evidence does not match ($AGMSGD_REASON)" return 0 @@ -83,6 +118,10 @@ agmsg_daemon_read_state() { } agmsg_daemon_recovery_text() { + if [ "$AGMSGD_HEALTH" = unchecked ]; then + printf 'agmsgd (Codex notices) could not be checked from this environment (%s). It may be running; nothing needs to be done unless notices stop arriving.\n' "$AGMSGD_REASON" + return 0 + fi if [ "$AGMSGD_HEALTH" = unverifiable ]; then printf 'agmsgd (Codex notices) was recorded by an older version, so whether it is still running cannot be checked. Run agmsg daemon stop and then agmsg daemon start to update the record. Unread messages are preserved. If agmsg is not found, use "%s/scripts/agmsg" daemon stop.\n' "$_AGMSG_DAEMON_ROOT" return 0 @@ -108,6 +147,8 @@ agmsg_daemon_warn_if_stopped() { fi agmsg_daemon_read_state [ "$AGMSGD_DESIRED" = on ] && [ "$AGMSGD_HEALTH" != ready ] || return 0 + # Not being able to look is not a stop: say nothing on ordinary commands. + [ "$AGMSGD_HEALTH" != unchecked ] || [ "${1:-}" = always ] || return 0 if [ "${1:-}" = always ]; then agmsg_daemon_recovery_text >&2 return 0 diff --git a/tests/test_agmsgd_switch.bats b/tests/test_agmsgd_switch.bats index 7acd39ebe..357c340e4 100644 --- a/tests/test_agmsgd_switch.bats +++ b/tests/test_agmsgd_switch.bats @@ -226,6 +226,40 @@ WRAPPER run bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' [ "$status" -eq 0 ] [ "$output" = ready ] + # A caller that is denied the tool that reads the process start time (an app + # sandbox) cannot check: that is neither ready nor stopped, and it must not + # tell anyone to restart a daemon that is running. + mkdir -p "$BATS_TEST_TMPDIR/denied" + printf '#!/bin/sh\nexit 1\n' > "$BATS_TEST_TMPDIR/denied/ps" + printf '#!/bin/sh\nexit 1\n' > "$BATS_TEST_TMPDIR/denied/getconf" + chmod +x "$BATS_TEST_TMPDIR/denied/ps" "$BATS_TEST_TMPDIR/denied/getconf" + run env PATH="$BATS_TEST_TMPDIR/denied:$PATH" bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"; agmsg_daemon_warn_if_stopped 2>&1' + [ "$status" -eq 0 ] + [ "$output" = unchecked ] + # A sandbox that refuses the signal for a live process ("not permitted") is + # still "exists"; only "no such process" is a stopped daemon. + run env PATH="$BATS_TEST_TMPDIR/denied:$PATH" bash -ec 'source "$SCRIPTS/lib/daemon-state.sh"; kill() { echo "kill: ($2) - Operation not permitted" >&2; return 1; }; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$status" -eq 0 ] + [ "$output" = unchecked ] + # A readable boot id that differs from the record is proof even when the + # start time cannot be read. + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET executor_boot_id='previous-boot';" + run env PATH="$BATS_TEST_TMPDIR/denied:$PATH" bash -ec 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$output" = stopped ] + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET executor_boot_id='$boot';" + run env PATH="$BATS_TEST_TMPDIR/denied:$PATH" bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; kill() { echo "kill: ($2) - No such process" >&2; return 1; }; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$output" = stopped ] + # A readable start time later than the recorded claim is a reused pid, even + # when the boot id is denied. + if [ "$(uname -s)" = Darwin ]; then + mkdir -p "$BATS_TEST_TMPDIR/boot-denied" + printf '#!/bin/sh\nexit 1\n' > "$BATS_TEST_TMPDIR/boot-denied/sysctl" + chmod +x "$BATS_TEST_TMPDIR/boot-denied/sysctl" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET executor_started_at='2000-01-01T00:00:00Z';" + run env PATH="$BATS_TEST_TMPDIR/boot-denied:$PATH" bash -ec 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$output" = stopped ] + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" + fi if [ "$(uname -s)" = Darwin ]; then # A record written when the boot id was kern.boottime seconds: the kernel # shifts that value with clock adjustments, so while its pid exists it diff --git a/tests/test_terminal_registry.bats b/tests/test_terminal_registry.bats index b4cad8d3c..537d9f04b 100644 --- a/tests/test_terminal_registry.bats +++ b/tests/test_terminal_registry.bats @@ -4002,6 +4002,10 @@ EOF [ "$(terminal_self_env)" = "$CODEX_THREAD_ID" ] [ "$(terminal_where "$CODEX_THREAD_ID")" = n/a:no_container_concept ] [ "$(agmsg_terminal_get codex-app capabilities)" = where ] + # No instance to add, and nothing said about it on every command. + run agmsg_terminal_ref_qualify "codex-app:$CODEX_THREAD_ID" + [ "$status" -eq 0 ] + [ "$output" = "codex-app:$CODEX_THREAD_ID" ] local verb for verb in spawn despawn peek poke arrange name pane_state; do run "terminal_$verb" "$CODEX_THREAD_ID" From 801b641ce2ead5de9071df9e029f8a3a469389b7 Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 11:20:40 +0900 Subject: [PATCH 39/97] watch.sh: stop re-arming once this seat's delivery is native (#1633) Once a claude-code seat has moved to agmsgd's direct delivery channel, stop re-arming the Monitor-renewed watch.sh instead of leaving duplicate delivery and leftover processes behind. Also require daemon_intent.desired=on alongside HEALTH=ready so the disable-in-flight window never gets mistaken for native readiness. --- scripts/watch.sh | 68 +++++++++++++++++++++++++++++++++++++++++++ tests/test_watch.bats | 47 ++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/scripts/watch.sh b/scripts/watch.sh index 3162d6fa5..2b76c0e12 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -88,6 +88,15 @@ source "$SCRIPT_DIR/lib/resolve-project.sh" # reaching for $TMUX_PANE behind its own back. # shellcheck disable=SC1091 [ -r "$SCRIPT_DIR/lib/terminal-registry.sh" ] && . "$SCRIPT_DIR/lib/terminal-registry.sh" +# Same two libs actas-claim.sh reads the native-delivery record through +# (role-session.sh's messaging_socket field, daemon-state.sh's AGMSGD_HEALTH) — +# see _agmsg_watch_native_delivery_active below. Guarded the same way: an +# install predating native delivery simply lacks these files, and the re-arm +# loop falls back to its original always-renew-or-stop-on-silence behavior. +# shellcheck disable=SC1091 +[ -r "$SCRIPT_DIR/lib/role-session.sh" ] && . "$SCRIPT_DIR/lib/role-session.sh" +# shellcheck disable=SC1091 +[ -r "$SCRIPT_DIR/lib/daemon-state.sh" ] && . "$SCRIPT_DIR/lib/daemon-state.sh" # Resolve a session id when the launcher could not bake one in (empty first arg). # Grok Build's `monitor` tool runs the watcher with $GROK_SESSION_ID unset, so @@ -1186,9 +1195,68 @@ _WATCH_DELIVERED="${AGMSG_WATCH_DELIVERED_BASE:-0}" case "$_WATCH_DELIVERED" in ''|*[!0-9]*) _WATCH_DELIVERED=0 ;; esac unset AGMSG_WATCH_ELAPSED_BASE AGMSG_WATCH_DELIVERED_BASE +# True only once agmsgd has actually taken over delivery for every pair this +# watcher serves -- the same two reads actas-claim.sh/daemon.sh status use to +# decide and report native delivery (daemon-state.sh's AGMSGD_HEALTH, role- +# session.sh's messaging_socket field), reused rather than re-derived so this +# can never disagree with what `delivery=native`/`daemon.sh status` already +# told the seat. +# +# Native delivery is only ever claimed for an exclusive (actas) role (see +# actas-claim.sh's own TYPE=claude-code, NAME-scoped gate) -- a broad watcher +# (no ACTIVE_NAME) never has a role-session record to check, and must keep +# renewing exactly as before. +# +# "Recorded" means agmsg_role_session_uuid for THIS pair equals this session's +# own bare sid (not merely that some session claimed native once) and +# messaging_socket is non-empty -- a stale record from a since-exited session +# must not stop a watcher that is this seat's only remaining delivery path. +# AGMSGD_HEALTH=ready is re-checked every call (not cached) so a daemon that +# stops after actas claimed native correctly falls back to normal re-arming +# instead of leaving the seat with neither channel. +# +# AGMSGD_DESIRED must also be `on`, not just HEALTH=ready (review, #1633): +# during `daemon disable`, the intent flips to off before the still-running +# owner finishes shutting down, so HEALTH can read `ready` for a window after +# DESIRED already reads `off`. The queue this seat would otherwise rely on +# (claude-code-queue.mjs createPending) refuses to enqueue at all once +# desired != on, so stopping on HEALTH alone during that window would leave +# the seat with no delivery path whatsoever -- exactly the failure this whole +# check exists to avoid, just reached from the other direction (disable +# in-flight instead of a dead executor). +_agmsg_watch_native_delivery_active() { + [ "$AGENT_TYPE" = claude-code ] && [ -n "$ACTIVE_NAME" ] || return 1 + declare -F agmsg_role_session_uuid >/dev/null 2>&1 || return 1 + declare -F agmsg_daemon_read_state >/dev/null 2>&1 || return 1 + local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON + agmsg_daemon_read_state + [ "${AGMSGD_DESIRED:-}" = on ] && [ "${AGMSGD_HEALTH:-}" = ready ] || return 1 + declare -F _agmsg_detect_platform >/dev/null 2>&1 && _agmsg_detect_platform + [ "${_agmsg_platform:-}" != msys ] || return 1 + declare -F agmsg_claude_code_native_storage_ready >/dev/null 2>&1 || return 1 + agmsg_claude_code_native_storage_ready || return 1 + local bare_sid="$SESSION_ID" + if declare -F agmsg_instance_bare_sid >/dev/null 2>&1; then + bare_sid="$(agmsg_instance_bare_sid "$SESSION_ID")" + fi + [ -n "$bare_sid" ] || return 1 + local _team _agent _any=0 + while IFS=$'\t' read -r _team _agent; do + [ -z "$_team" ] && continue + _any=1 + [ "$(agmsg_role_session_uuid "$_team" "$_agent" 2>/dev/null)" = "$bare_sid" ] || return 1 + [ -n "$(agmsg_role_session_get "$_team" "$_agent" messaging_socket 2>/dev/null)" ] || return 1 + done <<< "$PAIRS" + [ "$_any" -eq 1 ] +} + _watch_renew_or_stop() { local elapsed=$((SECONDS + _WATCH_ELAPSED_BASE)) cmd desc arg [ "$elapsed" -ge "$WATCH_MAX_SECONDS" ] || return 0 + if _agmsg_watch_native_delivery_active; then + watch_report "stopping - this seat's delivery has moved to agmsgd's native channel, so this watch is not re-armed" + exit 0 + fi if [ "$_WATCH_DELIVERED" -gt 0 ] || [ -n "${AGMSG_CC_MONITOR_KEEP_ALIVE:-}" ]; then cmd="$(printf '%q' "$SCRIPT_DIR/watch.sh")" for arg in "${ORIG_ARGS[@]}"; do diff --git a/tests/test_watch.bats b/tests/test_watch.bats index 5be36b93a..036dd2254 100644 --- a/tests/test_watch.bats +++ b/tests/test_watch.bats @@ -409,6 +409,53 @@ _wait_exit() { # fi _stop_watcher "$pid" refute grep -qF 'agmsg watch:' "$out" + + # #1XXX: once this seat's own role-session record shows agmsgd has taken + # over delivery (the same record/health reads actas-claim.sh and daemon.sh + # status use, see test_session_start_claude_code_native_channel.bats), the + # renewal loop must stop rather than re-arm -- even though this run DID + # deliver a message, which on its own would otherwise mean re-arm (see the + # first case above). + bash "$SCRIPTS/join.sh" team erin claude-code "$PROJ" >/dev/null + sqlite3 "$TEST_SKILL_DIR/run/install.db" < "$SCRIPTS/daemon/schema.sql" + local boot + case "$(uname -s)" in + Darwin) boot="$(sysctl -n kern.bootsessionuuid)" ;; + Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; + *) skip 'POSIX beta executor evidence' ;; + esac + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET node_path='$(command -v node)'; UPDATE daemon_intent SET desired='on'; + UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='$boot', executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" + AGMSG_AGENT_PID=$$ CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/s-ms-native.sock" \ + bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code erin "s-ms-native" | grep -qx "delivery=native" + + bash "$SCRIPTS/send.sh" team bob erin "native-delivered probe" >/dev/null + AGMSG_AGENT_PID=$$ AGMSG_WATCH_INTERVAL=1 \ + bash "$SCRIPTS/watch.sh" s-ms-native "$PROJ" claude-code erin --max-seconds=2 >"$out" 2>/dev/null 3>&- 4>&- & + pid=$! + _bg_track "$pid" + _wait_exit "$pid" 150 || { _stop_watcher "$pid"; false; } + wait "$pid" + grep -qF 'native-delivered probe' "$out" + grep -qF 'agmsg watch: stopping' "$out" + refute grep -qF 'agmsg watch: re-arm' "$out" + + # #1633 review: `daemon disable` flips daemon_intent.desired to off + # before the still-running owner finishes shutting down, so HEALTH can read + # `ready` for a window after DESIRED already reads `off`. The queue this + # seat would otherwise rely on refuses to enqueue once desired != on, so the + # watcher must NOT treat that window as native-delivered -- it has to keep + # re-arming, same as a seat that never went native at all. + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='off';" + bash "$SCRIPTS/send.sh" team bob erin "disable-window probe" >/dev/null + AGMSG_AGENT_PID=$$ AGMSG_WATCH_INTERVAL=1 \ + bash "$SCRIPTS/watch.sh" s-ms-native "$PROJ" claude-code erin --max-seconds=2 >"$out" 2>/dev/null 3>&- 4>&- & + pid=$! + _bg_track "$pid" + _wait_exit "$pid" 150 || { _stop_watcher "$pid"; false; } + wait "$pid" + grep -qF 'disable-window probe' "$out" + grep -qF 'agmsg watch: re-arm' "$out" } @test "watch: restart delivers messages that arrived while the watcher was down" { From 91951c1d760c4daf713ce55cc1bfa5ca9a346174 Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 11:33:56 +0900 Subject: [PATCH 40/97] watch.sh: validate full native destination record before stopping (#1635) Mirror claude-code-queue.mjs's own createPending gates (type, claude_config_dir, project, and now team/agent content fields) before the watcher trusts a role-session record as proof of native delivery, instead of treating a non-empty messaging_socket alone as sufficient. --- scripts/watch.sh | 41 +++++++++++++++++++++++++++++++++++------ tests/test_watch.bats | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/scripts/watch.sh b/scripts/watch.sh index 2b76c0e12..9e6b219a5 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -1208,12 +1208,33 @@ unset AGMSG_WATCH_ELAPSED_BASE AGMSG_WATCH_DELIVERED_BASE # renewing exactly as before. # # "Recorded" means agmsg_role_session_uuid for THIS pair equals this session's -# own bare sid (not merely that some session claimed native once) and -# messaging_socket is non-empty -- a stale record from a since-exited session -# must not stop a watcher that is this seat's only remaining delivery path. -# AGMSGD_HEALTH=ready is re-checked every call (not cached) so a daemon that -# stops after actas claimed native correctly falls back to normal re-arming -# instead of leaving the seat with neither channel. +# own bare sid (not merely that some session claimed native once) -- a stale +# record from a since-exited session must not stop a watcher that is this +# seat's only remaining delivery path. AGMSGD_HEALTH=ready is re-checked every +# call (not cached) so a daemon that stops after actas claimed native +# correctly falls back to normal re-arming instead of leaving the seat with +# neither channel. +# +# Beyond the bare sid match, this mirrors every other gate +# claude-code-queue.mjs's createPending itself applies to the SAME record +# before it will ever enqueue a nudge for this pair (follow-up to #1633 +# review: checking messaging_socket alone proved the record existed, not that +# the daemon would actually treat it as addressable): +# - type must read "claude-code" (role_session_type_mismatch) +# - claude_config_dir must be non-empty, same as messaging_socket +# (messaging_destination_missing) +# - project must canonically match this watcher's own project +# (role_session_project_mismatch) -- agmsg_canonical_path is the same +# normalization actas-claim.sh applies before writing PROJECT_PHYS, so +# comparing against it here can never disagree with what was recorded. +# team/agent identity mismatch (the mjs reader's own first check, +# role_session_identity_mismatch) DOES need its own comparison here (review +# round 2): being looked up BY (team, agent) only proves the FILENAME +# encodes this pair -- the daemon's reader separately checks the record's own +# team=/agent= CONTENT fields, which a corrupted or hand-edited record can +# disagree with the name it is filed under. The shell getters have no +# built-in enforcement of that either, so it is compared explicitly, the +# same as every other field above. # # AGMSGD_DESIRED must also be `on`, not just HEALTH=ready (review, #1633): # during `daemon disable`, the intent flips to off before the still-running @@ -1240,12 +1261,20 @@ _agmsg_watch_native_delivery_active() { bare_sid="$(agmsg_instance_bare_sid "$SESSION_ID")" fi [ -n "$bare_sid" ] || return 1 + local _canon_project + _canon_project="$(agmsg_canonical_path "$PROJECT_PATH")" + [ -n "$_canon_project" ] || return 1 local _team _agent _any=0 while IFS=$'\t' read -r _team _agent; do [ -z "$_team" ] && continue _any=1 [ "$(agmsg_role_session_uuid "$_team" "$_agent" 2>/dev/null)" = "$bare_sid" ] || return 1 + [ "$(agmsg_role_session_get "$_team" "$_agent" team 2>/dev/null)" = "$_team" ] || return 1 + [ "$(agmsg_role_session_get "$_team" "$_agent" agent 2>/dev/null)" = "$_agent" ] || return 1 + [ "$(agmsg_role_session_get "$_team" "$_agent" type 2>/dev/null)" = claude-code ] || return 1 [ -n "$(agmsg_role_session_get "$_team" "$_agent" messaging_socket 2>/dev/null)" ] || return 1 + [ -n "$(agmsg_role_session_get "$_team" "$_agent" claude_config_dir 2>/dev/null)" ] || return 1 + [ "$(agmsg_role_session_get "$_team" "$_agent" project 2>/dev/null)" = "$_canon_project" ] || return 1 done <<< "$PAIRS" [ "$_any" -eq 1 ] } diff --git a/tests/test_watch.bats b/tests/test_watch.bats index 036dd2254..4c8b10da0 100644 --- a/tests/test_watch.bats +++ b/tests/test_watch.bats @@ -456,6 +456,47 @@ _wait_exit() { # wait "$pid" grep -qF 'disable-window probe' "$out" grep -qF 'agmsg watch: re-arm' "$out" + + # Follow-up to the #1633 review: messaging_socket alone proves the + # record EXISTS, not that the daemon would treat it as addressable -- + # claude-code-queue.mjs's own createPending also demands type=claude-code, + # a non-empty claude_config_dir, and a project match before it will ever + # enqueue. Simulate a stale record left behind by this exact (team, agent) + # pair once having been claimed from a DIFFERENT project (the daemon's own + # role_session_project_mismatch) by corrupting just the project= line; the + # socket/session/type fields are all still genuinely this seat's own. + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='on';" + local rs_file + rs_file="$(ls "$TEST_SKILL_DIR"/run/role-session.*erin 2>/dev/null | head -1)" + [ -n "$rs_file" ] + sed -i.bak 's#^project=.*#project=/tmp/agmsg-some-other-project#' "$rs_file" + bash "$SCRIPTS/send.sh" team bob erin "project-mismatch probe" >/dev/null + AGMSG_AGENT_PID=$$ AGMSG_WATCH_INTERVAL=1 \ + bash "$SCRIPTS/watch.sh" s-ms-native "$PROJ" claude-code erin --max-seconds=2 >"$out" 2>/dev/null 3>&- 4>&- & + pid=$! + _bg_track "$pid" + _wait_exit "$pid" 150 || { _stop_watcher "$pid"; false; } + wait "$pid" + grep -qF 'project-mismatch probe' "$out" + grep -qF 'agmsg watch: re-arm' "$out" + + # Review round 2: being looked up BY (team, agent) only proves the + # FILENAME encodes this pair, not that the record's own team=/agent= + # CONTENT fields agree with it -- the daemon's reader checks those + # separately (role_session_identity_mismatch) and the shell getters enforce + # nothing on their own. Restore the project= line this test corrupted above, + # then corrupt only agent= to a different seat name. + mv -f "$rs_file.bak" "$rs_file" + sed -i.bak 's#^agent=.*#agent=some-other-seat#' "$rs_file" + bash "$SCRIPTS/send.sh" team bob erin "identity-mismatch probe" >/dev/null + AGMSG_AGENT_PID=$$ AGMSG_WATCH_INTERVAL=1 \ + bash "$SCRIPTS/watch.sh" s-ms-native "$PROJ" claude-code erin --max-seconds=2 >"$out" 2>/dev/null 3>&- 4>&- & + pid=$! + _bg_track "$pid" + _wait_exit "$pid" 150 || { _stop_watcher "$pid"; false; } + wait "$pid" + grep -qF 'identity-mismatch probe' "$out" + grep -qF 'agmsg watch: re-arm' "$out" } @test "watch: restart delivers messages that arrived while the watcher was down" { From 7b93a71d5d29aca12d401caceb8bd886735aeb17 Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 13:28:24 +0900 Subject: [PATCH 41/97] Start agmsgd on Windows: run Git Bash by full path and listen on a named pipe (#1634) The scheduled task named a bare bash.exe, which Task Scheduler resolves to WSL's launcher in System32, so agmsgd never started. The task now names the running Git Bash's bin\bash.exe by full path (paths are XML-escaped), and registration is refused with a reason when it is not found. Node on Windows cannot listen on a file path (EACCES), so there the control endpoint is a named pipe, \\.\pipe\agmsgd--, with no file mode set. Clients read the endpoint from daemon_owner.socket, so the name is decided in one place. --- scripts/daemon.sh | 23 +++++++++++++++++++++-- scripts/daemon/control.mjs | 3 ++- scripts/daemon/owner.mjs | 13 +++++++++++-- tests/agmsgd_owner.test.mjs | 10 ++++++++++ tests/test_agmsgd_daemon_sh.bats | 14 ++++++++++++++ 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 2d9877670..f5c02294e 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -374,12 +374,31 @@ _schtasks() { MSYS2_ARG_CONV_EXCL='*' "$AGMSGD_SCHTASKS" "$@" } +# Paths go into the task XML as text; a "&" in a user name would otherwise +# make the task impossible to create. +_xml_escape() { + printf '%s' "$1" | sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' +} + _register_windows() { local node_path="$1" name xml name="$(_schtasks_name)" xml="$SKILL_DIR/run/${name}.xml" - local native_launcher + # A bare `bash.exe` in the task is resolved by CreateProcess, which looks in + # System32 first and finds WSL's launcher there, not Git Bash. Name the Git + # Bash that is running this script by its full path instead. + local git_root git_bash native_bash native_launcher + git_root="$(cygpath -m / 2>/dev/null || true)" + git_root="${git_root%/}" + git_bash="$git_root/bin/bash.exe" + if [ -z "$git_root" ] || [ ! -f "$git_bash" ]; then + echo "agmsg daemon: cannot find Git Bash's bin/bash.exe (looked for '$git_bash'); not registering the scheduled task" >&2 + return 1 + fi + native_bash="$(cygpath -w "$git_bash" 2>/dev/null || printf '%s' "$git_bash")" native_launcher="$(cygpath -w "$LAUNCHER" 2>/dev/null || printf '%s' "$LAUNCHER")" + native_bash="$(_xml_escape "$native_bash")" + native_launcher="$(_xml_escape "$native_launcher")" local xml_utf8="$xml.utf8" cat > "$xml_utf8" < @@ -389,7 +408,7 @@ _register_windows() { - bash.exe + $native_bash "$native_launcher" diff --git a/scripts/daemon/control.mjs b/scripts/daemon/control.mjs index 8259c4476..58dd44f1b 100644 --- a/scripts/daemon/control.mjs +++ b/scripts/daemon/control.mjs @@ -161,7 +161,8 @@ export function createControlServer(socketPath, handlers) { server.listen(socketPath, () => { server.removeListener("error", reject); try { - chmodSync(socketPath, 0o600); + // A Windows named pipe has no file mode to set. + if (!socketPath.startsWith("\\\\.\\pipe\\")) chmodSync(socketPath, 0o600); } catch (error) { server.close(); reject(error); diff --git a/scripts/daemon/owner.mjs b/scripts/daemon/owner.mjs index 2ace10ab5..811ca793c 100644 --- a/scripts/daemon/owner.mjs +++ b/scripts/daemon/owner.mjs @@ -10,6 +10,7 @@ // generation and through takeOwnership() plus control-socket readiness, so // an install cannot begin between validation and this claim. +import { createHash } from "node:crypto"; import { join } from "node:path"; import { currentExecutor, isAlive } from "./executor.mjs"; import { withImmediateTransaction } from "./db.mjs"; @@ -22,7 +23,15 @@ export function readIntent(db) { return db.prepare("SELECT * FROM daemon_intent").get(); } -function socketPath(installRoot, gen) { +// Node on Windows cannot listen on a file path (EACCES); a control endpoint +// there is a named pipe, whose name carries the same install identity and gen +// the file name does elsewhere. Everything that connects reads the endpoint +// back from daemon_owner.socket, so this is the one place it is decided. +export function controlSocketPath(installRoot, gen, installId, platform = process.platform) { + if (platform === "win32") { + const unit = createHash("sha256").update(`${installRoot}:${installId ?? ""}`).digest("hex").slice(0, 12); + return `\\\\.\\pipe\\agmsgd-${unit}-${gen}`; + } return join(installRoot, "run", `agmsgd.${gen}.sock`); } @@ -60,7 +69,7 @@ export function takeOwnership(db, { installRoot, expectedDesired, expectedOpGen, const gen = owner.gen + 1; const executor = currentExecutor(); - const socket = socketPath(installRoot, gen); + const socket = controlSocketPath(installRoot, gen, db.prepare("SELECT install_id FROM meta").get()?.install_id); const startedAt = new Date().toISOString(); db.prepare( `UPDATE daemon_owner SET gen = ?, state = 'starting', diff --git a/tests/agmsgd_owner.test.mjs b/tests/agmsgd_owner.test.mjs index b44ca29a4..700ec856a 100644 --- a/tests/agmsgd_owner.test.mjs +++ b/tests/agmsgd_owner.test.mjs @@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process"; import test from "node:test"; import { openInstallDb } from "../scripts/daemon/db.mjs"; import { + controlSocketPath, markReady, markStopping, readOwner, @@ -33,6 +34,15 @@ test("takeOwnership: none -> starting -> ready is a clean run", () => { assert.equal(r.ok, true); assert.equal(r.gen, 1); assert.equal(readOwner(db).state, "starting"); + const installId = db.prepare("SELECT install_id FROM meta").get().install_id; + assert.equal(readOwner(db).socket, controlSocketPath(dir, 1, installId)); + assert.equal(controlSocketPath(dir, 1, installId, "linux"), join(dir, "run", "agmsgd.1.sock")); + // On Windows the control endpoint is a named pipe (a file path cannot be + // listened on there), still keyed by install and gen. + const pipe = controlSocketPath(dir, 7, "id-a", "win32"); + assert.match(pipe, /^\\\\\.\\pipe\\agmsgd-[0-9a-f]{12}-7$/); + assert.notEqual(pipe, controlSocketPath(dir, 7, "id-b", "win32")); + assert.notEqual(pipe, controlSocketPath(dir, 8, "id-a", "win32")); assert.equal(markReady(db, r.gen), true); assert.equal(readOwner(db).state, "ready"); } finally { diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index 86c198778..a8050e8a1 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -292,6 +292,7 @@ EOF cat > "$TEST_SKILL_DIR/fake-windows/cygpath" <<'EOF' #!/usr/bin/env bash printf '%s\n' "$2" >> "${AGMSGD_FAKE_CYGPATH_LOG:?}" +if [ "$1 $2" = "-m /" ]; then printf '%s/\n' "${AGMSGD_FAKE_GIT_ROOT:?}"; exit 0; fi printf '%s\n' "$2" EOF chmod +x "$TEST_SKILL_DIR/fake-windows/uname" "$TEST_SKILL_DIR/fake-windows/schtasks" "$TEST_SKILL_DIR/fake-windows/cygpath" @@ -516,9 +517,22 @@ EOF marker="$TEST_SKILL_DIR/fake-windows/registered" export AGMSGD_FAKE_CYGPATH_LOG="$TEST_SKILL_DIR/fake-windows/cygpath.log" + export AGMSGD_FAKE_GIT_ROOT="$TEST_SKILL_DIR/fake-windows/Git & Root" + # Without a Git Bash to name, nothing is registered. + PATH="$TEST_SKILL_DIR/fake-windows:$PATH" AGMSGD_SCHTASKS="$TEST_SKILL_DIR/fake-windows/schtasks" AGMSGD_FAKE_TASK_XML="$capture" AGMSGD_FAKE_TASK_MARKER="$marker" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ + _run_with_deadline 15 bash "$DAEMON" enable + [ "$status" -ne 0 ] + grep -q "cannot find Git Bash" <<<"$output" + [ ! -f "$marker" ] + mkdir -p "$AGMSGD_FAKE_GIT_ROOT/bin" + : > "$AGMSGD_FAKE_GIT_ROOT/bin/bash.exe" PATH="$TEST_SKILL_DIR/fake-windows:$PATH" AGMSGD_SCHTASKS="$TEST_SKILL_DIR/fake-windows/schtasks" AGMSGD_FAKE_TASK_XML="$capture" AGMSGD_FAKE_TASK_MARKER="$marker" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ _run_with_deadline 15 bash "$DAEMON" enable [ "$status" -eq 0 ] + # The task runs Git Bash by full path, not a bare bash.exe that Task + # Scheduler would resolve to WSL's launcher in System32. + # A & in the path is escaped in the XML. + iconv -f UTF-16LE -t UTF-8 "$capture" | grep -Fq "${AGMSGD_FAKE_GIT_ROOT//&/&}/bin/bash.exe" # Every path named inside SQL went through the native-path conversion. grep -Fq "$TEST_SKILL_DIR/run/install-manifest.json" "$AGMSGD_FAKE_CYGPATH_LOG" grep -Fq "$TEST_SKILL_DIR/run/install.db" "$AGMSGD_FAKE_CYGPATH_LOG" From 01432fe96e7ed88d715642bbc420eeda1e3eff84 Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 14:48:54 +0900 Subject: [PATCH 42/97] Do not warn that agmsgd is stopped while an update is restarting it (#1636) While install --update restarts the daemon, a marker in run/ keeps the stopped-while-enabled warning quiet for up to two minutes. A missing, expired or future-dated marker warns as before. [skip ci] --- install.sh | 4 ++++ scripts/lib/daemon-state.sh | 16 +++++++++++++++- tests/test_agmsgd_switch.bats | 12 ++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 9fdcac171..4618261b5 100755 --- a/install.sh +++ b/install.sh @@ -152,6 +152,9 @@ agmsg_install_restart_daemon() { # # still this one, so a stop or disable during the wait is not undone. intent="$(sqlite3 "$db" "SELECT desired || '|' || op_gen FROM daemon_intent;" 2>/dev/null || true)" case "$intent" in on\|[0-9]*) op_gen="${intent#on|}" ;; *) return 0 ;; esac + # Tells the "stopped while enabled" warning that this gap is the update's own + # restart. It expires by age, so a killed update cannot silence it for good. + date +%s > "$skill/run/agmsgd-updating" 2>/dev/null || true while [ "$waited" -lt 25 ]; do state="$(sqlite3 "$db" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" [ "$state" = ready ] || break @@ -162,6 +165,7 @@ agmsg_install_restart_daemon() { # if ! bash "$skill/scripts/daemon.sh" start --only-if-op-gen "$op_gen"; then echo " ! agmsgd did not start after the update; run: bash $(printf '%q' "$skill/scripts/agmsg") daemon start" >&2 fi + rm -f "$skill/run/agmsgd-updating" } agmsg_install_restart_guidance() { diff --git a/scripts/lib/daemon-state.sh b/scripts/lib/daemon-state.sh index 751f574a3..50cfa1bdf 100644 --- a/scripts/lib/daemon-state.sh +++ b/scripts/lib/daemon-state.sh @@ -136,8 +136,22 @@ agmsg_daemon_recovery_text() { agmsg_daemon_warn_if_stopped() { # Keep health state local without forking a shell that inherits caller FDs. local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON - local now last=0 marker slot + local now last=0 marker slot updating=0 marker="$_AGMSG_DAEMON_ROOT/run/agmsgd-warning-at" + # install --update is restarting the daemon right now (see + # agmsg_install_restart_daemon): it is not stopped, so say nothing. The + # marker is only believed for two minutes; after that, or once the update + # removes it, a daemon that did not come back warns as usual. + if [ -f "$_AGMSG_DAEMON_ROOT/run/agmsgd-updating" ]; then + IFS= read -r updating < "$_AGMSG_DAEMON_ROOT/run/agmsgd-updating" || updating=0 + case "$updating" in ''|*[!0-9]*) updating=0 ;; esac + now="$(date +%s)" || now=0 + # Only a readable clock and a timestamp not in the future count. + if [ "$now" -gt 0 ] && [ "$updating" -gt 0 ] \ + && [ "$((now - updating))" -ge 0 ] && [ "$((now - updating))" -lt 120 ]; then + return 0 + fi + fi if [ "${1:-}" != always ]; then now="$(date +%s)" || return 0 [ ! -f "$marker" ] || IFS= read -r last < "$marker" || last=0 diff --git a/tests/test_agmsgd_switch.bats b/tests/test_agmsgd_switch.bats index 357c340e4..b5f3514a9 100644 --- a/tests/test_agmsgd_switch.bats +++ b/tests/test_agmsgd_switch.bats @@ -101,6 +101,18 @@ setup() { assert_lacks 'stopped while enabled' PATH="$TEST_SKILL_DIR/clock:$PATH" TEST_NOW=1799 run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex assert_contains 'stopped while enabled' + # While install --update restarts the daemon the warning stays quiet and + # does not use up the interval; a marker older than two minutes is ignored. + printf '2950\n' > "$TEST_SKILL_DIR/run/agmsgd-updating" + PATH="$TEST_SKILL_DIR/clock:$PATH" TEST_NOW=3000 run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + assert_lacks 'stopped while enabled' + printf '2800\n' > "$TEST_SKILL_DIR/run/agmsgd-updating" + PATH="$TEST_SKILL_DIR/clock:$PATH" TEST_NOW=3000 run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + assert_contains 'stopped while enabled' + # A marker dated in the future (clock moved back, bad value) never hides it. + printf '9999999999\n' > "$TEST_SKILL_DIR/run/agmsgd-updating" + PATH="$TEST_SKILL_DIR/clock:$PATH" TEST_NOW=4000 run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + assert_contains 'stopped while enabled' } @test "storage skips daemon helper loading without an install record" { From ddcf83f025b2608c4b144c342814dd1bfed1942a Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 14:50:18 +0900 Subject: [PATCH 43/97] Show a recorded Codex destination neutrally unless a notice failed to arrive (#1637) Ask for a destination refresh only when the newest notice attempt for the seat and its recorded conversation expired unconfirmed. [skip ci] --- scripts/lib/daemon-seats.sh | 11 +++++++++-- tests/agmsgd_status_display.test.mjs | 9 +++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/lib/daemon-seats.sh b/scripts/lib/daemon-seats.sh index 379526aa1..72d38046f 100644 --- a/scripts/lib/daemon-seats.sh +++ b/scripts/lib/daemon-seats.sh @@ -10,7 +10,7 @@ source "$SKILL_DIR/scripts/lib/role-session.sh" agmsg_daemon_seat_report() { local action="${1:-status}" cfg cfg_sql roster team agent project pid meta_type meta_project meta_pid - local bridge record_status cached key driver record_type record_home label + local bridge record_status cached key driver record_type record_home label latest local seen='|' omitted=0 pending owner updated recent active display local active_lines='' recent_lines='' older_lines='' recovery source "$SKILL_DIR/scripts/lib/type-registry.sh" @@ -59,7 +59,7 @@ agmsg_daemon_seat_report() { agmsg_role_session_load "$team" "$agent" 2>/dev/null || true record_type="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" type)" record_home="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" codex_home)" - record_status="destination recorded; current session not verified. In Codex, run $recovery to refresh the destination" + record_status='destination recorded' if [ "$record_type" != codex ] || [ -z "${AGMSG_ROLE_SESSION_UUID:-}" ] || [ -z "$record_home" ]; then record_status='no destination record; restart Codex and run actas again to record it' fi @@ -70,6 +70,13 @@ agmsg_daemon_seat_report() { thread_sql="$(printf '%s' "${AGMSG_ROLE_SESSION_UUID:-}" | sed "s/'/''/g")" cached="$(sqlite3 -readonly -cmd '.timeout 100' "$SKILL_DIR/run/install.db" "SELECT reason FROM beta_codex_seat WHERE seat=json_array('$key','$agent_sql') AND thread='$thread_sql';" 2>/dev/null)" || cached="" pending="$(sqlite3 -readonly -cmd '.timeout 100' "$SKILL_DIR/run/install.db" "SELECT count(*) FROM beta_codex_queue WHERE seat=json_array('$key','$agent_sql') AND state='pending';" 2>/dev/null)" || pending=0 + # Ask for a refresh only on evidence about the current destination: the + # newest notice attempt for this seat and thread expired unconfirmed. An + # attempt for another conversation says nothing about the recorded one. + latest="$(sqlite3 -readonly -cmd '.timeout 100' "$SKILL_DIR/run/install.db" "SELECT state FROM beta_codex_queue WHERE seat=json_array('$key','$agent_sql') AND thread='$thread_sql' ORDER BY id DESC LIMIT 1;" 2>/dev/null)" || latest="" + if [ "$record_status" = 'destination recorded' ] && [ "$latest" = expired ]; then + record_status="destination recorded; the last notice was not delivered. In Codex, run $recovery to refresh the destination" + fi owner="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" owner)" updated="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" updated_at)" updated="$(printf '%s' "$updated" | sed "s/'/''/g")" diff --git a/tests/agmsgd_status_display.test.mjs b/tests/agmsgd_status_display.test.mjs index c7fc2e1bb..b9c77acb5 100644 --- a/tests/agmsgd_status_display.test.mjs +++ b/tests/agmsgd_status_display.test.mjs @@ -70,12 +70,17 @@ async function waitForFile(path) { } test('Codex startup refreshes the sole destination from the current hook thread without replacing a live owner', async t => { - const { root, profile, start, startAsync, record, old, status } = codexStartupFixture(t, ['alice']); + const { root, db, profile, start, startAsync, record, old, status } = codexStartupFixture(t, ['alice']); const current = '01a13352-3599-7d62-a149-4c8182bcf008'; start(current); assert.match(record('alice'), new RegExp(`session=${current}\\n`)); assert.ok(record('alice').includes(`codex_home=${realpathSync(profile)}\n`)); - assert.match(status(), /current session not verified.*\$.* actas /); + const add = (state, id) => db.prepare("INSERT INTO beta_codex_queue(seat, codex_home, thread, up_to, nonce, state, children, created_at) VALUES (json_array('test','alice'), ?, ?, '1', 'n', ?, '{}', datetime('now'))").run(root, id, state); + assert.match(status(), /alice: destination recorded\n/); + add('expired', '01a13352-3599-7d62-a149-4c8182bcf0ff'); + add('confirmed', current); + assert.doesNotMatch(status(), /actas /); + add('expired', current); assert.ok(status().includes(`$${basename(root)} actas `)); const live = `${old('alice')}owner=${thread}.${process.pid}\n`; writeFileSync(join(root, 'run/role-session.test__alice'), live); From 69a728cf5d966a34e1d1c8085ef8bbd541b26942 Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 14:51:04 +0900 Subject: [PATCH 44/97] Show agmsgd delivery for Codex projects without a hooks file (#1638) [skip ci] --- scripts/drivers/types/codex/_delivery.sh | 35 ++++++++++++++++++++++++ tests/test_delivery.bats | 30 ++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh index 57663e104..6732e0032 100644 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -206,6 +206,41 @@ agmsg_codex_report_missing_bridge() { esac } +# With agmsgd on and every registered seat's destination recorded, Codex needs +# no hooks file, so "no settings file found" would read as broken when nothing +# is. Hooks that exist keep their own mode; agmsgd off keeps the default text. +agmsg_delivery_status() { + local type="$1" project="$2" full first pairs team name seats=0 + full="$(agmsg_delivery_status_default "$type" "$project")" + first="${full%%$'\n'*}" + case "$first" in "mode: off (unrecognized: no settings file found"*) ;; *) printf '%s\n' "$full"; return 0 ;; esac + local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON + # shellcheck disable=SC1091 + source "${SKILL_DIR:-}/scripts/lib/daemon-state.sh" + # shellcheck disable=SC1091 + source "${SKILL_DIR:-}/scripts/lib/role-session.sh" + agmsg_daemon_read_state + if [ "${AGMSGD_DESIRED:-}" = on ] && [ "${AGMSGD_HEALTH:-}" = ready ]; then + pairs="$("${SCRIPT_DIR:-}/identities.sh" "$project" "$type" 2>/dev/null || true)" + while IFS=$'\t' read -r team name _rest; do + [ -n "$team" ] && [ -n "$name" ] || continue + agmsg_role_session_load "$team" "$name" 2>/dev/null || { seats=0; break; } + if [ "$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" type)" = codex ] \ + && [ -n "${AGMSG_ROLE_SESSION_UUID:-}" ] \ + && [ -n "$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" codex_home)" ]; then + seats=$((seats + 1)) + else + seats=0; break + fi + done <<< "$pairs" + fi + if [ "$seats" -gt 0 ]; then + echo "mode: agmsgd (agmsgd delivers to this project's Codex seats; no hooks file needed)" + else + printf '%s\n' "$full" + fi +} + agmsg_delivery_runtime_status() { local type="$1" project="$2" local pairs found=0 any_alive=0 diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index 72aaa4b4b..e8a8b72e8 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -405,6 +405,36 @@ eperm_pid() { [[ "$output" == *"$TEST_PROJECT"* ]] } +@test "delivery status: a Codex project with no hooks file reads as agmsgd-delivered only while agmsgd is on and its seats have destinations" { + export SKILL_DIR="$TEST_SKILL_DIR" + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" + grep -q '^mode: off (unrecognized: no settings file found' <<<"$output" + + mkdir -p "$TEST_SKILL_DIR/run" + sqlite3 "$TEST_SKILL_DIR/run/install.db" < "$SCRIPTS/daemon/schema.sql" + local boot + case "$(uname -s)" in + Darwin) boot="$(sysctl -n kern.bootsessionuuid)" ;; + Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; + *) skip 'POSIX beta executor evidence' ;; + esac + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='on'; + UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='$boot', executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" + # agmsgd on, but alice has no recorded destination yet: unchanged. + run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" + grep -q '^mode: off (unrecognized: no settings file found' <<<"$output" + + source "$SCRIPTS/lib/role-session.sh" + agmsg_role_session_record team alice thread-1 "$TEST_PROJECT" codex "" "$TEST_PROJECT/codex-home" + run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" + [ "${output%%$'\n'*}" = "mode: agmsgd (agmsgd delivers to this project's Codex seats; no hooks file needed)" ] + + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='off';" + run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" + grep -q '^mode: off (unrecognized: no settings file found' <<<"$output" +} + @test "delivery status: a settings file that exists but is not valid JSON is also unrecognized, not deliberate off (review)" { # A third way has_ss/has_st both land on 0: not "missing" and not "genuinely # empty of agmsg entries" but unreadable/malformed, which the has_ss/has_st From 1e49fcaa947dbafcf02b4323fa4b99f7b87ccdc7 Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 16:27:56 +0900 Subject: [PATCH 45/97] Rebind a Codex seat after /clear from the SessionStart hook (#1639) After /clear in Codex the first message starts a new thread in the same process and fires SessionStart, but the old owner's pid is still alive so the seat was never rebound. When no seat of the project is bound to the current thread (identity from CODEX_THREAD_ID or the hook input, never a rollout guess), the hook now runs the proof-gated rebind that fix uses, with the full sync off since the hook hands the bridge off itself. --- scripts/drivers/types/codex/_session-start.sh | 20 ++++++++++++ scripts/lib/self-fix.sh | 11 ++++++- tests/test_delivery.bats | 31 +++++++++++++++++++ tests/test_self_fix.bats | 15 +++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index 53a794d5e..a012ac400 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -185,6 +185,26 @@ agmsg_session_start() { # shellcheck source=../../../lib/role-session.sh source "$SKILL_DIR/scripts/lib/role-session.sh" project_phys="$(agmsg_canonical_path "$PROJECT" 2>/dev/null || printf '%s' "$PROJECT")" + # #1468: /clear starts a new thread inside the same Codex process, so no + # seat of this project is recorded on it and the old owner's pid is still + # alive. When nothing is bound to this thread, let the proof-gated rebind + # that `fix` uses move the one seat that proves it is this pane. + # Only an identity the session itself supplied (env or hook input) may drive + # it, never a rollout-inferred one. + if [ "$thread_id" = "${CODEX_THREAD_ID:-}" ] || [ "$thread_id" = "${SESSION_ID:-}" ]; then + clear_bound=0 + while IFS=$'\t' read -r clear_team clear_name; do + [ -n "$clear_team" ] || continue + [ "$(agmsg_role_session_uuid "$clear_team" "$clear_name" 2>/dev/null || true)" = "$thread_id" ] && clear_bound=1 + done <<< "$PAIRS" + if [ "$clear_bound" = 0 ]; then + ( export CODEX_THREAD_ID="$thread_id" + # shellcheck disable=SC1091 + . "$SKILL_DIR/scripts/lib/self-fix.sh" \ + && cd "$PROJECT" \ + && AGMSG_FIX_REASSIGN_FROM_HOOK=1 _fix_codex_thread_reassign ) >/dev/null 2>&1 || true + fi + fi agmsg_codex_refresh_destination "$thread_id" "$project_phys" "$PAIRS" || true safe_pairs="" while IFS=$'\t' read -r candidate_team candidate_name; do diff --git a/scripts/lib/self-fix.sh b/scripts/lib/self-fix.sh index 863229048..b58f6f58d 100644 --- a/scripts/lib/self-fix.sh +++ b/scripts/lib/self-fix.sh @@ -433,7 +433,16 @@ _fix_codex_thread_reassign() { # the two commands' exit codes -- both routinely exit 0 without having # changed anything. local sync_reason sync_rc=0 - sync_reason="$(_fix_codex_thread_sync "$proved_team" "$proved_agent" "$project")" || sync_rc=$? + if [ "${AGMSG_FIX_REASSIGN_FROM_HOOK:-}" = 1 ]; then + # Called from the codex SessionStart hook: that hook is itself the + # session-start.sh run the full sync would shell out to, and it hands the + # bridge off right after this returns. Only the record has to move here. + "$SKILL_DIR/scripts/drivers/types/codex/codex-record-session.sh" "$proved_team" "$proved_agent" "$project" >/dev/null 2>&1 || true + sync_reason=role_session_record_still_old + [ "$(agmsg_role_session_uuid "$proved_team" "$proved_agent" 2>/dev/null || true)" = "$CODEX_THREAD_ID" ] || sync_rc=1 + else + sync_reason="$(_fix_codex_thread_sync "$proved_team" "$proved_agent" "$project")" || sync_rc=$? + fi if [ "$sync_rc" -ne 0 ]; then printf 'partial\t%s\t%s\t%s\t%s\n' "$proved_team" "$proved_agent" "$new_owner" "$sync_reason" return 2 diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index e8a8b72e8..cba370030 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -2914,6 +2914,37 @@ EOF [ ! -f "$log" ] } +@test "session-start.sh for codex runs the /clear rebind only for an identity the session supplied (#1468)" { + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + _seed_role_record team alice 11111111-1111-4111-8111-111111111111 "$TEST_PROJECT" codex + local spy="$TEST_SKILL_DIR/rebind.log" + # The proof-gated helper is covered in test_self_fix.bats; here only the + # hook entry is under test, so stand in for it and record how it was called. + cat >"$TEST_SKILL_DIR/scripts/lib/self-fix.sh" <> "$spy" + return 1 +} +STUB + local new_id=22222222-2222-4222-8222-222222222222 + + # Hook input alone (no CODEX_THREAD_ID) names the new thread: rebind runs. + printf '{"session_id":"%s","source":"clear"}' "$new_id" \ + | env -u CODEX_THREAD_ID bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null + [ "$(cat "$spy")" = "called thread=$new_id hook=1" ] + + # No identity from the session (only a rollout could name the thread): never. + : > "$spy" + env -u CODEX_THREAD_ID bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" /dev/null + [ ! -s "$spy" ] + + # A seat already bound to this thread: nothing to rebind. + : > "$spy" + _seed_role_record team alice "$new_id" "$TEST_PROJECT" codex + CODEX_THREAD_ID="$new_id" bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" /dev/null + [ ! -s "$spy" ] +} + # #1477: Codex installed after agmsg never gets this install's writable_roots # (configure_codex_sandbox only writes them into a Codex config that already # existed at install time), and nothing later says so. session-start.sh's diff --git a/tests/test_self_fix.bats b/tests/test_self_fix.bats index 889267b2f..eba5af43e 100644 --- a/tests/test_self_fix.bats +++ b/tests/test_self_fix.bats @@ -496,6 +496,21 @@ FAKEEOF [ "$(agmsg_role_session_uuid T cx)" = "new-thread-222" ] [ "$(cat "$RUN_DIR/codex-bridge.T.cx.thread")" = "new-thread-222" ] + # The SessionStart hook runs the same rebind with the full sync switched + # off: the record moves, and the bridge hand-off is the hook's own next + # step, so session-start.sh must not be run from inside it. + : > "$SPY" + agmsg_role_session_record T cx new-thread-222 "$project" codex "" + printf 'old-thread-111.%s\n' "$$" > "$(actas_lock_path T cx)" + printf '%s\n' "old-thread-111.$$" > "$RUN_DIR/cc-instance.$$" + export CODEX_THREAD_ID=new-thread-444 + AGMSG_FIX_REASSIGN_FROM_HOOK=1 run _fix_codex_thread_reassign + [ "$status" -eq 0 ] + [ "$output" = "ok T cx new-thread-444.$$" ] + grep -Fq "record-session T cx $project CODEX_THREAD_ID=new-thread-444" "$SPY" + refute grep -q '^session-start' "$SPY" + [ "$(agmsg_role_session_uuid T cx)" = "new-thread-444" ] + # Condition missing: a DIFFERENT, still-alive pid holds the old lock -- a # genuinely separate live process, not this session's own. This is exactly # the case the narrow reclaim must never touch: same-pid is its whole From 3fb012a86203f227d67dc986b18c15733e702873 Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 16 Oct 2026 19:05:46 +0900 Subject: [PATCH 46/97] release: 1.6.0-rc.4 (#1640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release of `1.6.0-rc.4` from `integration/agmsgd-beta`. - VERSION bump + synced `package.json` / `.claude-plugin/plugin.json` - CHANGELOG.md deliberately untouched — prerelease notes come from `release.yml`'s own `git-cliff --latest` - Default the two environment reads added by the Codex `/clear` rebind (#1639) so the unguarded environment read gate stays at its baseline Changes since rc.3: - Codex app: stop two false warnings (#1632) - watch.sh stops re-arming once a Claude Code seat's delivery is native, after validating the full destination record (#1633, #1635) - Windows: start agmsgd with Git Bash by full path and listen on a named pipe (#1634) - No "agmsgd stopped" warning while an update is restarting it (#1636) - Codex seat and delivery status wording (#1637, #1638) - Rebind a Codex seat after `/clear` from the SessionStart hook (#1639, fixes #1468) Merging this and pushing `v1.6.0-rc.4` triggers the publish pipeline (npm + GitHub Release). --- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- package.json | 2 +- scripts/drivers/types/codex/_session-start.sh | 4 ++-- scripts/lib/daemon-state.sh | 15 +-------------- scripts/lib/instance-id.sh | 15 +++++++++++++++ scripts/watch.sh | 19 +++++++++++-------- 7 files changed, 32 insertions(+), 27 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5db94ac5d..13313ae75 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agmsg", "description": "Cross-agent messaging via SQLite. Send messages between CLI AI agents. No daemon, no network.", - "version": "1.6.0-rc.3", + "version": "1.6.0-rc.4", "author": { "name": "fujibee" }, diff --git a/VERSION b/VERSION index 7cb25adcb..fee897748 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0-rc.3 +1.6.0-rc.4 diff --git a/package.json b/package.json index fd6b38f0c..9aac7eb17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.6.0-rc.3", + "version": "1.6.0-rc.4", "description": "Cross-agent messaging via SQLite for CLI AI agents (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Antigravity, OpenCode). The npm package is a thin bootstrapper that fetches and runs the canonical bash installer at https://github.com/fujibee/agmsg.", "bin": { "agmsg": "bin/agmsg.js" diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index a012ac400..6a953dae1 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -200,8 +200,8 @@ agmsg_session_start() { if [ "$clear_bound" = 0 ]; then ( export CODEX_THREAD_ID="$thread_id" # shellcheck disable=SC1091 - . "$SKILL_DIR/scripts/lib/self-fix.sh" \ - && cd "$PROJECT" \ + . "${SKILL_DIR:-}/scripts/lib/self-fix.sh" \ + && cd "${PROJECT:-}" \ && AGMSG_FIX_REASSIGN_FROM_HOOK=1 _fix_codex_thread_reassign ) >/dev/null 2>&1 || true fi fi diff --git a/scripts/lib/daemon-state.sh b/scripts/lib/daemon-state.sh index 50cfa1bdf..5b69d647e 100644 --- a/scripts/lib/daemon-state.sh +++ b/scripts/lib/daemon-state.sh @@ -7,19 +7,6 @@ _AGMSG_DAEMON_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # shellcheck disable=SC1091 source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/instance-id.sh" -# Does the recorded pid exist? Answers "gone" only on a clear "no such -# process". A sandbox may refuse the signal even for a live process (not -# permitted), which says the process exists; anything else is "unknown". -_agmsg_daemon_pid_presence() { # - local out - out="$(export LC_ALL=C; kill -0 "$1" 2>&1)" && { echo alive; return 0; } - case "$out" in - *"No such process"*) echo gone ;; - *"not permitted"*) echo alive ;; - *) echo unknown ;; - esac -} - agmsg_daemon_read_state() { AGMSGD_DESIRED=unknown; AGMSGD_HEALTH=unknown; AGMSGD_REASON="install record unavailable" local row state pid boot recorded_start current_boot process_start started_epoch @@ -94,7 +81,7 @@ agmsg_daemon_read_state() { return 0 fi if [ -z "$current_boot" ] || [ -z "$started_epoch" ]; then - case "$(_agmsg_daemon_pid_presence "$pid")" in + case "$(_agmsg_pid_signal_presence "$pid")" in gone) ;; *) AGMSGD_HEALTH=unchecked diff --git a/scripts/lib/instance-id.sh b/scripts/lib/instance-id.sh index 344df3729..e6bd51971 100644 --- a/scripts/lib/instance-id.sh +++ b/scripts/lib/instance-id.sh @@ -106,6 +106,21 @@ _agmsg_pid_valid() { return 0 } +# Does the pid exist, by the signal alone? Kept here because this file owns +# every kill -0. Unlike _agmsg_pid_alive it reports three states, for a caller +# that cannot run ps (an app sandbox denies it). Answers "gone" only on a clear +# "no such process". A sandbox may refuse the signal even for a live process +# (not permitted), which says the process exists; anything else is "unknown". +_agmsg_pid_signal_presence() { # + local out + out="$(export LC_ALL=C; kill -0 "$1" 2>&1)" && { echo alive; return 0; } + case "$out" in + *"No such process"*) echo gone ;; + *"not permitted"*) echo alive ;; + *) echo unknown ;; + esac +} + # Liveness for a pid THIS codebase minted: $! or $$ in one of these shells, or # read back from a pidfile one of them wrote. A pidfile does not launder the pid # space -- the number in it is still whatever the shell that wrote it was given. diff --git a/scripts/watch.sh b/scripts/watch.sh index 9e6b219a5..7b2ca7bae 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -88,15 +88,14 @@ source "$SCRIPT_DIR/lib/resolve-project.sh" # reaching for $TMUX_PANE behind its own back. # shellcheck disable=SC1091 [ -r "$SCRIPT_DIR/lib/terminal-registry.sh" ] && . "$SCRIPT_DIR/lib/terminal-registry.sh" -# Same two libs actas-claim.sh reads the native-delivery record through -# (role-session.sh's messaging_socket field, daemon-state.sh's AGMSGD_HEALTH) — -# see _agmsg_watch_native_delivery_active below. Guarded the same way: an -# install predating native delivery simply lacks these files, and the re-arm -# loop falls back to its original always-renew-or-stop-on-silence behavior. +# Same libs actas-claim.sh reads the native-delivery record through +# (role-session.sh's messaging_socket field here, daemon-state.sh's +# AGMSGD_HEALTH loaded lazily in _agmsg_watch_native_delivery_active below). +# Guarded the same way: an install predating native delivery simply lacks +# these files, and the re-arm loop falls back to its original +# always-renew-or-stop-on-silence behavior. # shellcheck disable=SC1091 [ -r "$SCRIPT_DIR/lib/role-session.sh" ] && . "$SCRIPT_DIR/lib/role-session.sh" -# shellcheck disable=SC1091 -[ -r "$SCRIPT_DIR/lib/daemon-state.sh" ] && . "$SCRIPT_DIR/lib/daemon-state.sh" # Resolve a session id when the launcher could not bake one in (empty first arg). # Grok Build's `monitor` tool runs the watcher with $GROK_SESSION_ID unset, so @@ -1248,7 +1247,11 @@ unset AGMSG_WATCH_ELAPSED_BASE AGMSG_WATCH_DELIVERED_BASE _agmsg_watch_native_delivery_active() { [ "$AGENT_TYPE" = claude-code ] && [ -n "$ACTIVE_NAME" ] || return 1 declare -F agmsg_role_session_uuid >/dev/null 2>&1 || return 1 - declare -F agmsg_daemon_read_state >/dev/null 2>&1 || return 1 + # Loaded only here: a watcher and its children otherwise never pay for it. + # shellcheck disable=SC1091 + declare -F agmsg_daemon_read_state >/dev/null 2>&1 \ + || { [ -r "$SCRIPT_DIR/lib/daemon-state.sh" ] && . "$SCRIPT_DIR/lib/daemon-state.sh"; } \ + || return 1 local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON agmsg_daemon_read_state [ "${AGMSGD_DESIRED:-}" = on ] && [ "${AGMSGD_HEALTH:-}" = ready ] || return 1 From 71f7f8eda32d791440521256959073f26416e4a6 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 18 Oct 2026 02:26:56 +0900 Subject: [PATCH 47/97] Stop queueing nudges for a seat whose recorded socket is gone (#1650) A Claude Code seat that is restarting still names its old messaging socket; each poll queued a nudge for it and expired it at once. A record whose socket does not exist now gets no queue row. [skip ci] --- scripts/daemon/channels/claude-code-queue.mjs | 8 ++++++++ tests/agmsgd_claude_code_queue.test.mjs | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/scripts/daemon/channels/claude-code-queue.mjs b/scripts/daemon/channels/claude-code-queue.mjs index a16dc52c0..4ed735fba 100644 --- a/scripts/daemon/channels/claude-code-queue.mjs +++ b/scripts/daemon/channels/claude-code-queue.mjs @@ -259,6 +259,14 @@ export function createClaudeCodeQueueChannel({ saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: "transcript_path_unresolvable" }, now); return; } + // A record whose socket no longer exists (the seat is restarting and has + // not written its new one yet) has nowhere to be nudged. Make no queue + // row for it: each poll would add one that expires at once. Once the seat + // records a socket that exists, this passes and the nudge goes out. + if (socketAlive(record.messaging_socket).reason === "messaging_socket_missing") { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "unaddressable", reason: "messaging_socket_missing" }, now); + return; + } const nonce = newNonce(); let id; try { diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index 611a5f5d9..aecdf2c74 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -146,6 +146,19 @@ test('a nudge is sent again once the seat record names another session', async ( await channel.stop(); }); +test('a seat whose recorded socket is gone gets no new nudge rows until its record names a socket that exists', async (t) => { + const { db, channel, state } = channelFixture(t); + state.alive = { state: 'unreadable', reason: 'messaging_socket_missing' }; + for (let i = 0; i < 5; i += 1) await channel.pollOnce(); + assert.equal(state.sent, 0); + assert.equal(db.prepare('SELECT COUNT(*) AS n FROM beta_claude_queue').get().n, 0); + state.alive = { state: 'ok' }; + state.socket = '/tmp/cc-socks/2.sock'; + await channel.pollOnce(); + assert.equal(state.sent, 1); + await channel.stop(); +}); + test('a conversation stored under another project directory is found by its session id, only when exactly one matches', (t) => { const config = temporaryDirectory(t); const session = '0d7c2a52-5d4c-4d0f-8c0a-6b6f5f3f9a11'; From 04ef6e190c5f7a2ad2eb004a10b652cbd79fa76b Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 18 Oct 2026 02:27:06 +0900 Subject: [PATCH 48/97] Keep agmsgd running through a busy database and log any other poll failure (#1651) A busy tick is skipped and retried on the next poll; any other unexpected poll error is logged before the process exits. [skip ci] --- scripts/daemon/main.mjs | 32 ++++++++++++++++++++++++++------ tests/agmsgd_main.test.mjs | 15 +++++++++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs index 75f8c64e6..9055720da 100644 --- a/scripts/daemon/main.mjs +++ b/scripts/daemon/main.mjs @@ -96,6 +96,30 @@ export async function pollOnce(db, installRoot, { gen, expectedOpGen, watchState return { action: "continue" }; } +// A transient lock held by another writer: the next poll simply tries again. +export function isBusyError(error) { + return (error?.errcode & 0xff) === 5 || /database is (locked|busy)/i.test(String(error?.message ?? "")); +} + +// One timer tick: the poll plus the channel polls. A busy database skips this +// tick; any other unexpected error is written to agmsgd.log before it +// propagates, so the exit is never silent. +export async function pollCycle(db, installRoot, context, channelHooks) { + try { + const verdict = await pollOnce(db, installRoot, context); + if (verdict.action !== "continue") return verdict; + await pollChannelHooks(channelHooks, (message) => logLine(installRoot, message)); + return verdict; + } catch (error) { + if (isBusyError(error)) { + logLine(installRoot, `poll: database busy, retrying next poll (${error.message})`); + return { action: "continue" }; + } + logLine(installRoot, `poll: unexpected error, exiting: ${error?.stack ?? error}`); + throw error; + } +} + // The one place shutdown happens: mark stopping -> stop // every registered channel -> close the control socket -> record the // normal stop. `reason` becomes daemon_owner.last_end_reason (status.mjs @@ -181,12 +205,8 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe if (stopping || polling) return; polling = true; try { - const verdict = await pollOnce(db, installRoot, { gen, expectedOpGen, watchState }); - if (verdict.action !== "continue") { - await doStop(verdict.reason); - return; - } - await pollChannelHooks(channelHooks, (message) => logLine(installRoot, message)); + const verdict = await pollCycle(db, installRoot, { gen, expectedOpGen, watchState }, channelHooks); + if (verdict.action !== "continue") await doStop(verdict.reason); } finally { polling = false; } diff --git a/tests/agmsgd_main.test.mjs b/tests/agmsgd_main.test.mjs index 48dddd407..e46666b29 100644 --- a/tests/agmsgd_main.test.mjs +++ b/tests/agmsgd_main.test.mjs @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { openInstallDb } from "../scripts/daemon/db.mjs"; import { readOwner } from "../scripts/daemon/owner.mjs"; -import { gracefulStop, pollChannelHooks, pollOnce, prepareClaimWithCodexSchema, startup } from "../scripts/daemon/main.mjs"; +import { gracefulStop, pollChannelHooks, pollCycle, pollOnce, prepareClaimWithCodexSchema, startup } from "../scripts/daemon/main.mjs"; import { captureWatchState } from "../scripts/daemon/lifecycle.mjs"; import { createHash } from "node:crypto"; @@ -88,6 +88,17 @@ test("pollOnce: continues when nothing changed, steps aside on drift, stops on a assert.equal(verdict.reason, "normal"); db.exec("UPDATE daemon_intent SET op_gen = 0"); // restore + // Case: another writer holds the database. The tick is skipped, not fatal; + // any other error is logged before it propagates. + const context = { gen: 1, expectedOpGen: 0, watchState }; + const busy = Object.assign(new Error("database is locked"), { errcode: 5 }); + const locked = { prepare() { throw busy; } }; + assert.deepEqual(await pollCycle(locked, root, context, []), { action: "continue" }); + await assert.rejects(pollCycle({ prepare() { throw new Error("boom"); } }, root, context, []), /boom/); + const log = readFileSync(join(root, "run", "agmsgd.log"), "utf8"); + assert.match(log, /database busy, retrying next poll/); + assert.match(log, /unexpected error, exiting: Error: boom/); + // Case: an in-place edit under scripts/ (the #963 case) with neither // the manifest nor the op_gen touched. writeFileSync(join(root, "scripts", "x.sh"), "tampered\n"); From 9ffdd6d5d8748c7005caf5b283964316f9860299 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 18 Oct 2026 02:30:11 +0900 Subject: [PATCH 49/97] Codex: record the profile the TUI runs in, and name a missing rollout (#1653) [skip ci] --- scripts/daemon/channels/codex-queue-io.mjs | 9 +++ scripts/daemon/channels/codex-queue.mjs | 3 +- scripts/drivers/types/codex/_profile.sh | 65 +++++++++++++++++++ scripts/drivers/types/codex/_session-start.sh | 16 +++-- .../types/codex/codex-record-session.sh | 30 ++++----- scripts/lib/daemon-seats.sh | 2 +- tests/agmsgd_codex_queue.test.mjs | 4 ++ tests/test_codex_resume.bats | 18 +++++ 8 files changed, 121 insertions(+), 26 deletions(-) create mode 100644 scripts/drivers/types/codex/_profile.sh diff --git a/scripts/daemon/channels/codex-queue-io.mjs b/scripts/daemon/channels/codex-queue-io.mjs index 4a4c0315e..c86c3edde 100644 --- a/scripts/daemon/channels/codex-queue-io.mjs +++ b/scripts/daemon/channels/codex-queue-io.mjs @@ -337,6 +337,15 @@ export async function verifyPendingDelivery({ return resolvePendingQueue({ queueObservation, rolloutObservation, ageMs }); } +// A readable profile with no rollout for the thread is a distinct, nameable +// cause (the recorded profile is not the one this conversation runs in), not a +// generic failure to observe it. +export function rolloutStateOf(rollout) { + if (rollout?.state === "absent" || rollout?.state === "present") return "valid"; + if (rollout?.reason === "thread_rollout_missing" || rollout?.reason === "sessions_dir_missing") return "no_rollout"; + return "invalid"; +} + export function classifyCodexSeat({ roleSession, bridgeState, rolloutState }) { if (bridgeState === "running") return { state: "bridged", reason: "bridge_running" }; if (!roleSession || !roleSession.thread || !roleSession.codex_home) { diff --git a/scripts/daemon/channels/codex-queue.mjs b/scripts/daemon/channels/codex-queue.mjs index c16072905..cb85a8585 100644 --- a/scripts/daemon/channels/codex-queue.mjs +++ b/scripts/daemon/channels/codex-queue.mjs @@ -22,6 +22,7 @@ import { readQueuedItem, readRolloutNonce, resolvePendingQueue, + rolloutStateOf, runCodexQueue, } from "./codex-queue-io.mjs"; import { captureProcessGroup, observeProcessGroup } from "./process-group.mjs"; @@ -436,7 +437,7 @@ export function createCodexQueueChannel({ const nonce = newNonce(); const rollout = await readRolloutNonce(record.codex_home, record.thread, nonce); - const classification = classifyCodexSeat({ roleSession: record, bridgeState: "stopped", rolloutState: rollout.state === "absent" || rollout.state === "present" ? "valid" : "invalid" }); + const classification = classifyCodexSeat({ roleSession: record, bridgeState: "stopped", rolloutState: rolloutStateOf(rollout) }); if (classification.state !== "addressable") { saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: classification.state, reason: classification.reason }, now); return; diff --git a/scripts/drivers/types/codex/_profile.sh b/scripts/drivers/types/codex/_profile.sh new file mode 100644 index 000000000..7f4b07d1c --- /dev/null +++ b/scripts/drivers/types/codex/_profile.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# The Codex profile (CODEX_HOME) a conversation really runs in. +# +# Newer Codex runs tool commands in the managed app-server's shell, whose +# environment carries only HOME, LANG, LOGNAME, PATH, SHELL and USER, so a +# CODEX_HOME given to the TUI never reaches `actas`. The SessionStart hook runs +# in the TUI's environment, so it publishes the profile per thread and +# codex-record-session.sh reads it back instead of falling to ~/.codex. +# Sourced with resolve-project.sh already loaded (agmsg_canonical_path, +# agmsg_normalize_project_path). + +# Prints the absolute, canonical profile directory of THIS process's +# environment (CODEX_HOME, else $HOME/.codex). Prints nothing and returns 1 +# when it is not an existing absolute directory. +agmsg_codex_profile_from_env() { + local home="${CODEX_HOME:-${HOME:+$HOME/.codex}}" + agmsg_codex_profile_canonical "$home" +} + +agmsg_codex_profile_canonical() { + local home="${1:-}" + case "$home" in + *[[:cntrl:]]*|'') return 1 ;; + /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; + *) return 1 ;; + esac + [ -d "$home" ] || return 1 + home="$(agmsg_canonical_path "$home")" || return 1 + home="$(agmsg_normalize_project_path "$home")" || return 1 + case "$home" in + *[[:cntrl:]]*|'') return 1 ;; + /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; + *) return 1 ;; + esac + printf '%s\n' "$home" +} + +_agmsg_codex_thread_home_file() { # + case "${2:-}" in + *[!0-9A-Fa-f-]*|'') return 1 ;; + esac + [ -n "${1:-}" ] || return 1 + printf '%s/codex-thread-home.%s\n' "$1" "$2" +} + +# SessionStart side: remember this thread's profile (from the hook's own +# environment). Best effort. +agmsg_codex_thread_home_publish() { # + local file home tmp + file="$(_agmsg_codex_thread_home_file "$1" "$2")" || return 0 + home="$(agmsg_codex_profile_from_env)" || return 0 + mkdir -p "$1" 2>/dev/null || return 0 + tmp="$file.$$" + printf '%s\n' "$home" > "$tmp" 2>/dev/null && mv "$tmp" "$file" 2>/dev/null || rm -f "$tmp" 2>/dev/null + return 0 +} + +# actas side: the profile the hook published for this thread, if still valid. +agmsg_codex_thread_home_lookup() { # + local file home + file="$(_agmsg_codex_thread_home_file "$1" "$2")" || return 1 + [ -f "$file" ] || return 1 + IFS= read -r home < "$file" || [ -n "$home" ] || return 1 + agmsg_codex_profile_canonical "$home" +} diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index 6a953dae1..5cea80539 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -143,12 +143,10 @@ agmsg_codex_refresh_destination() { [ "$matches" = 1 ] || return 1 fi [ -n "$chosen_team" ] && [ -n "$chosen_name" ] || return 1 - local profile="${CODEX_HOME:-${HOME:+$HOME/.codex}}" owner - case "$profile" in /*|[A-Za-z]:/*|[A-Za-z]:\\*) ;; *) return 1 ;; esac - [ -d "$profile" ] || return 1 - profile="$(agmsg_canonical_path "$profile")" || return 1 - profile="$(agmsg_normalize_project_path "$profile")" || return 1 - case "$profile" in *[[:cntrl:]]*|'') return 1 ;; esac + local profile owner + # shellcheck disable=SC1091 + . "${SKILL_DIR:-}/scripts/drivers/types/codex/_profile.sh" + profile="$(agmsg_codex_profile_from_env)" || return 1 owner="$(agmsg_instance_id "$thread" codex 2>/dev/null)" || return 1 agmsg_role_session_record "$chosen_team" "$chosen_name" "$thread" "$project" codex "$owner" "$profile" inactive || return 1 agmsg_role_session_load "$chosen_team" "$chosen_name" @@ -205,6 +203,12 @@ agmsg_session_start() { && AGMSG_FIX_REASSIGN_FROM_HOOK=1 _fix_codex_thread_reassign ) >/dev/null 2>&1 || true fi fi + # The hook runs in the TUI's environment; tool shells may not (see _profile.sh). + if [ "$thread_id" = "${CODEX_THREAD_ID:-}" ] || [ "$thread_id" = "${SESSION_ID:-}" ]; then + # shellcheck disable=SC1091 + . "${SKILL_DIR:-}/scripts/drivers/types/codex/_profile.sh" + agmsg_codex_thread_home_publish "${RUN_DIR:-}" "$thread_id" + fi agmsg_codex_refresh_destination "$thread_id" "$project_phys" "$PAIRS" || true safe_pairs="" while IFS=$'\t' read -r candidate_team candidate_name; do diff --git a/scripts/drivers/types/codex/codex-record-session.sh b/scripts/drivers/types/codex/codex-record-session.sh index 0c473976f..6a8c4b703 100755 --- a/scripts/drivers/types/codex/codex-record-session.sh +++ b/scripts/drivers/types/codex/codex-record-session.sh @@ -169,25 +169,19 @@ fi # a delivery destination, but it does not prevent the session record either: # the record is written without the profile, and discovery falls back to # $HOME/.codex as before. -codex_home="${CODEX_HOME:-${HOME:+$HOME/.codex}}" -case "$codex_home" in - *[[:cntrl:]]*) codex_home="" ;; - /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; - *) codex_home="" ;; -esac -if [ -n "$codex_home" ] && [ -d "$codex_home" ]; then - codex_home="$(agmsg_canonical_path "$codex_home")" || codex_home="" - # Keep the absolute path in the cross-platform form used by Node consumers; - # Git Bash's physical /c/... spelling is normalized to C:/... on Windows. - codex_home="$(agmsg_normalize_project_path "$codex_home")" || codex_home="" - case "$codex_home" in - *[[:cntrl:]]*) codex_home="" ;; - /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; - *) codex_home="" ;; - esac -else - codex_home="" +# CODEX_HOME can be missing here even when the TUI was started with one: newer +# Codex runs tool commands in the app-server's shell, which does not carry it. +# The SessionStart hook runs in the TUI's environment and published the profile +# for this thread, so use that before falling back to $HOME/.codex. +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/_profile.sh" +codex_home="" +if [ -n "${CODEX_HOME:-}" ]; then + codex_home="$(agmsg_codex_profile_from_env)" || codex_home="" +elif [ -n "$thread" ]; then + codex_home="$(agmsg_codex_thread_home_lookup "$SKILL_DIR/run" "$thread")" || codex_home="" fi +[ -n "$codex_home" ] || codex_home="$(agmsg_codex_profile_from_env)" || codex_home="" if [ -z "$thread" ]; then # No app-server to ask, or it could not be reached -- a codex session outside diff --git a/scripts/lib/daemon-seats.sh b/scripts/lib/daemon-seats.sh index 72d38046f..f635913ac 100644 --- a/scripts/lib/daemon-seats.sh +++ b/scripts/lib/daemon-seats.sh @@ -99,7 +99,7 @@ agmsg_daemon_seat_report() { fi case "$cached" in thread_archived*) record_status='conversation archived; resume an active conversation and run actas again' ;; - codex_home_mismatch*) record_status='incorrect CODEX_HOME record; run actas from the correct Codex profile' ;; + codex_home_mismatch*) record_status='the recorded Codex profile has no rollout for this conversation; run actas again from the Codex session you are using' ;; esac label="Codex $team/$agent" if [ "${REDACTED:-0}" = 1 ]; then label='Codex seat (redacted)'; cached=""; fi diff --git a/tests/agmsgd_codex_queue.test.mjs b/tests/agmsgd_codex_queue.test.mjs index 611a94955..efc82084d 100644 --- a/tests/agmsgd_codex_queue.test.mjs +++ b/tests/agmsgd_codex_queue.test.mjs @@ -15,6 +15,7 @@ import { readQueuedItem, readRolloutNonce, resolvePendingQueue, + rolloutStateOf, runCodexQueue, verifyPendingDelivery, } from '../scripts/daemon/channels/codex-queue-io.mjs'; @@ -290,6 +291,9 @@ test('immediate post-receipt check confirms a still-queued item before rollout i }); test('seat classification never treats an uncertain destination or bridge as addressable', () => { + // A profile with no rollout for the thread is named as such, not as an unobservable conversation. + assert.equal(rolloutStateOf({ state: 'unreadable', reason: 'thread_rollout_missing' }), 'no_rollout'); + assert.equal(rolloutStateOf({ state: 'unreadable', reason: 'rollout_read_failed' }), 'invalid'); const record = { thread, codex_home: '/tmp/profile' }; assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped', rolloutState: 'valid' }), { state: 'addressable', reason: '' }); assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'running' }), { state: 'bridged', reason: 'bridge_running' }); diff --git a/tests/test_codex_resume.bats b/tests/test_codex_resume.bats index 21754184a..bbb1a3f65 100644 --- a/tests/test_codex_resume.bats +++ b/tests/test_codex_resume.bats @@ -117,6 +117,24 @@ recorded_uuid() { [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } +@test "codex record: a tool shell without CODEX_HOME records the profile the SessionStart hook saw" { + local proj hook_home thread=11111111-2222-3333-4444-555555555555 expected_home + proj="$(mktemp -d)" + hook_home="$TEST_SKILL_DIR/tui profile" + mkdir -p "$hook_home" "$HOME/.codex" + source "$SCRIPTS/lib/resolve-project.sh" + source "$TYPES/codex/_profile.sh" + # The hook runs in the TUI's environment, which carries CODEX_HOME ... + CODEX_HOME="$hook_home" agmsg_codex_thread_home_publish "$RUN_DIR" "$thread" + # ... the app-server's tool shell, where actas runs, does not. + env -u CODEX_HOME CODEX_THREAD_ID="$thread" \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + source "$SCRIPTS/lib/role-session.sh" + [ "$(recorded_uuid team alice)" = "$thread" ] + expected_home="$(agmsg_normalize_project_path "$(cd "$hook_home" && pwd -P)")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] +} + @test "codex record: unresolved profile omits only the field and preserves legacy rollout discovery" { local proj invalid_home proj="$(mktemp -d)" From c85fd10d20731091ffe89b00484c72f16ea22a32 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 20 Oct 2026 10:20:41 +0900 Subject: [PATCH 50/97] Prove the /clear rebind from the Codex seat's own record (#1659) A Codex seat holds no actas lock, so the lock-based proof used by fix never applies and the SessionStart rebind after /clear stopped at an absent owner. The hook now moves a seat onto the new thread only when the record's owner pid is the Codex process the hook runs under, is alive, and started before the record was written, and exactly one seat of the project qualifies. Selection and write share the role-session writer lock, the thread comes from the environment or hook input (never a rollout guess), and thread-home publication is kept. Records without an owner, other processes and platforms without process start times are left alone. --- scripts/drivers/types/codex/_session-start.sh | 29 ++++--- scripts/lib/role-session.sh | 59 ++++++++++++++ scripts/lib/self-fix.sh | 11 +-- tests/test_delivery.bats | 78 ++++++++++++------- tests/test_self_fix.bats | 15 ---- 5 files changed, 123 insertions(+), 69 deletions(-) diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index 5cea80539..9897f4737 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -155,6 +155,16 @@ agmsg_codex_refresh_destination() { AGMSG_CODEX_REFRESHED_PAIR="$chosen_team"$'\t'"$chosen_name" } +# A Codex seat holds no actas lock, so the only proof that a recorded seat is +# this very process is its record: owner=. naming the Codex pid +# this hook runs under (see agmsg_role_session_rebind_same_process). +agmsg_codex_rebind_after_clear() { + local thread="$1" project="$2" pairs="$3" cur_pid + cur_pid="$(agmsg_agent_pid codex 2>/dev/null || true)" + case "$cur_pid" in ''|*[!0-9]*) return 1 ;; esac + agmsg_role_session_rebind_same_process "$thread" "$project" "$cur_pid" "$pairs" +} + agmsg_codex_unseated_notice() { source "${SKILL_DIR:-}/scripts/lib/boot-command.sh" printf 'AGMSG delivery: this session is not connected to a seat yet. Run %s in Codex to receive messages.\n' "$(agmsg_actas_prompt codex '')" @@ -185,23 +195,10 @@ agmsg_session_start() { project_phys="$(agmsg_canonical_path "$PROJECT" 2>/dev/null || printf '%s' "$PROJECT")" # #1468: /clear starts a new thread inside the same Codex process, so no # seat of this project is recorded on it and the old owner's pid is still - # alive. When nothing is bound to this thread, let the proof-gated rebind - # that `fix` uses move the one seat that proves it is this pane. - # Only an identity the session itself supplied (env or hook input) may drive - # it, never a rollout-inferred one. + # alive. Only an identity the session itself supplied (env or hook input, + # never a rollout guess) may drive the rebind. if [ "$thread_id" = "${CODEX_THREAD_ID:-}" ] || [ "$thread_id" = "${SESSION_ID:-}" ]; then - clear_bound=0 - while IFS=$'\t' read -r clear_team clear_name; do - [ -n "$clear_team" ] || continue - [ "$(agmsg_role_session_uuid "$clear_team" "$clear_name" 2>/dev/null || true)" = "$thread_id" ] && clear_bound=1 - done <<< "$PAIRS" - if [ "$clear_bound" = 0 ]; then - ( export CODEX_THREAD_ID="$thread_id" - # shellcheck disable=SC1091 - . "${SKILL_DIR:-}/scripts/lib/self-fix.sh" \ - && cd "${PROJECT:-}" \ - && AGMSG_FIX_REASSIGN_FROM_HOOK=1 _fix_codex_thread_reassign ) >/dev/null 2>&1 || true - fi + agmsg_codex_rebind_after_clear "$thread_id" "$project_phys" "$PAIRS" || true fi # The hook runs in the TUI's environment; tool shells may not (see _profile.sh). if [ "$thread_id" = "${CODEX_THREAD_ID:-}" ] || [ "$thread_id" = "${SESSION_ID:-}" ]; then diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index 088bb0ad2..d0e502992 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -152,6 +152,65 @@ _agmsg_role_session_write() ( "$@" ) +# Epoch at which started, empty when it cannot be read. +_agmsg_process_start_epoch() { + local lstart + lstart="$(LC_ALL=C ps -p "$1" -o lstart= 2>/dev/null)" || return 0 + [ -n "$lstart" ] || return 0 + if [ "$(uname -s)" = Darwin ]; then + LC_ALL=C date -j -f '%a %b %e %T %Y' "$lstart" +%s 2>/dev/null || true + else + LC_ALL=C date -d "$lstart" +%s 2>/dev/null || true + fi +} + +_agmsg_iso_epoch() { + if [ "$(uname -s)" = Darwin ]; then + LC_ALL=C date -j -u -f '%Y-%m-%dT%H:%M:%SZ' "$1" +%s 2>/dev/null || true + else + LC_ALL=C date -u -d "$1" +%s 2>/dev/null || true + fi +} + +_agmsg_role_session_rebind_same_process_unlocked() { + local thread="$1" project="$2" pid="$3" pairs="$4" team agent owner started updated rec_project + local match_team='' match_agent='' n=0 match_codex_home='' + # Select under the writer lock: a record another writer moved between a + # caller's look and this transaction must not be rewritten from a stale view. + while IFS=$'\t' read -r team agent; do + [ -n "$team" ] && [ -n "$agent" ] || continue + agmsg_role_session_load "$team" "$agent" + [ -n "$AGMSG_ROLE_SESSION_UUID" ] || continue + # A seat is already bound to this thread: nothing to rebind. + [ "$AGMSG_ROLE_SESSION_UUID" != "$thread" ] || return 1 + rec_project="$(agmsg_canonical_path "$AGMSG_ROLE_SESSION_PROJECT" 2>/dev/null || printf '%s' "$AGMSG_ROLE_SESSION_PROJECT")" + [ "$rec_project" = "$project" ] || continue + owner="${AGMSG_ROLE_SESSION_OWNER:-}" + agmsg_instance_is_composite "$owner" && [ "${owner##*.}" = "$pid" ] || continue + [ "$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" type)" = codex ] || continue + _agmsg_pid_alive "$pid" || continue + started="$(_agmsg_process_start_epoch "$pid")" + updated="$(_agmsg_iso_epoch "$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" updated_at)")" + case "$started:$updated" in ''|:*|*:|*[!0-9:]*) continue ;; esac + [ "$started" -le "$updated" ] || continue + n=$((n + 1)); match_team="$team"; match_agent="$agent" + match_codex_home="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" codex_home)" + done <<< "$pairs" + [ "$n" = 1 ] || return 1 + _agmsg_role_session_record_unlocked "$match_team" "$match_agent" "$thread" "$project" codex "$thread.$pid" "$match_codex_home" + agmsg_role_session_load "$match_team" "$match_agent" + [ "$AGMSG_ROLE_SESSION_UUID" = "$thread" ] +} + +# /clear starts a new thread inside the same live Codex process. Move the +# one seat of whose record names that very process (same pid, alive, +# started before the record was written) onto . Selection, check +# and write are one transaction with every other writer. is the +# candidate "teamagent" lines. +agmsg_role_session_rebind_same_process() { # + _agmsg_role_session_write _agmsg_role_session_rebind_same_process_unlocked "$@" +} + agmsg_role_session_record() { [ -n "${1:-}" ] && [ -n "${2:-}" ] && [ -n "${3:-}" ] || return 0 if _agmsg_role_session_write _agmsg_role_session_record_unlocked "$@"; then return 0; fi diff --git a/scripts/lib/self-fix.sh b/scripts/lib/self-fix.sh index b58f6f58d..863229048 100644 --- a/scripts/lib/self-fix.sh +++ b/scripts/lib/self-fix.sh @@ -433,16 +433,7 @@ _fix_codex_thread_reassign() { # the two commands' exit codes -- both routinely exit 0 without having # changed anything. local sync_reason sync_rc=0 - if [ "${AGMSG_FIX_REASSIGN_FROM_HOOK:-}" = 1 ]; then - # Called from the codex SessionStart hook: that hook is itself the - # session-start.sh run the full sync would shell out to, and it hands the - # bridge off right after this returns. Only the record has to move here. - "$SKILL_DIR/scripts/drivers/types/codex/codex-record-session.sh" "$proved_team" "$proved_agent" "$project" >/dev/null 2>&1 || true - sync_reason=role_session_record_still_old - [ "$(agmsg_role_session_uuid "$proved_team" "$proved_agent" 2>/dev/null || true)" = "$CODEX_THREAD_ID" ] || sync_rc=1 - else - sync_reason="$(_fix_codex_thread_sync "$proved_team" "$proved_agent" "$project")" || sync_rc=$? - fi + sync_reason="$(_fix_codex_thread_sync "$proved_team" "$proved_agent" "$project")" || sync_rc=$? if [ "$sync_rc" -ne 0 ]; then printf 'partial\t%s\t%s\t%s\t%s\n' "$proved_team" "$proved_agent" "$new_owner" "$sync_reason" return 2 diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index cba370030..1bf808dfa 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -2914,35 +2914,57 @@ EOF [ ! -f "$log" ] } -@test "session-start.sh for codex runs the /clear rebind only for an identity the session supplied (#1468)" { +@test "session-start.sh for codex rebinds a /clear seat only to the same live process (#1468)" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null - _seed_role_record team alice 11111111-1111-4111-8111-111111111111 "$TEST_PROJECT" codex - local spy="$TEST_SKILL_DIR/rebind.log" - # The proof-gated helper is covered in test_self_fix.bats; here only the - # hook entry is under test, so stand in for it and record how it was called. - cat >"$TEST_SKILL_DIR/scripts/lib/self-fix.sh" <> "$spy" - return 1 -} -STUB - local new_id=22222222-2222-4222-8222-222222222222 - - # Hook input alone (no CODEX_THREAD_ID) names the new thread: rebind runs. - printf '{"session_id":"%s","source":"clear"}' "$new_id" \ - | env -u CODEX_THREAD_ID bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null - [ "$(cat "$spy")" = "called thread=$new_id hook=1" ] - - # No identity from the session (only a rollout could name the thread): never. - : > "$spy" - env -u CODEX_THREAD_ID bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" /dev/null - [ ! -s "$spy" ] - - # A seat already bound to this thread: nothing to rebind. - : > "$spy" - _seed_role_record team alice "$new_id" "$TEST_PROJECT" codex - CODEX_THREAD_ID="$new_id" bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" /dev/null - [ ! -s "$spy" ] + local old=11111111-1111-4111-8111-111111111111 new=22222222-2222-4222-8222-222222222222 + local rec="$TEST_SKILL_DIR/run/role-session.team__alice" + # A Codex seat holds no actas lock: its record alone names the process. + sleep 60 & + local pid=$! + SKILL_DIR="$TEST_SKILL_DIR" bash -c ' + source "$1/lib/role-session.sh" + agmsg_role_session_record team alice "$2" "$3" codex "$2.$4" + ' _ "$SCRIPTS" "$old" "$TEST_PROJECT" "$pid" + local before; before="$(cat "$rec")" + _hook() { printf '{"session_id":"%s","source":"clear"}' "$new" \ + | env -u CODEX_THREAD_ID AGMSG_AGENT_PID="$1" bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null; } + + # A different process (pid reuse, another Codex): untouched. + _hook "$$" + [ "$(cat "$rec")" = "$before" ] + + # The same pid, but the record predates the process: a reused pid, untouched. + sed -i.bak 's/^updated_at=.*/updated_at=2000-01-01T00:00:00Z/' "$rec" + local stale; stale="$(cat "$rec")" + _hook "$pid" + [ "$(cat "$rec")" = "$stale" ] + sed -i.bak "s/^updated_at=.*/updated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)/" "$rec" + + # No identity supplied by the session (only a rollout could name it): untouched. + before="$(cat "$rec")" + env -u CODEX_THREAD_ID AGMSG_AGENT_PID="$pid" bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" /dev/null + [ "$(cat "$rec")" = "$before" ] + + # The record belongs to another project by the time it is examined: untouched. + cp "$rec" "$rec.keep" + sed -i.bak "s#^project=.*#project=$BATS_TEST_TMPDIR/elsewhere#" "$rec" + before="$(cat "$rec")" + _hook "$pid" + [ "$(cat "$rec")" = "$before" ] + mv "$rec.keep" "$rec" + + # Git Bash's ps has no start-time column, so no start evidence exists there + # and the rebind correctly refuses; the success case is POSIX-only. + case "${MSYSTEM:-}" in MINGW*|MSYS*|CLANGARM*) kill "$pid" 2>/dev/null || true; return 0 ;; esac + + # The same live process, hook input names the new thread: rebound, and the + # thread's profile is published alongside. + mkdir -p "$BATS_TEST_TMPDIR/profile" + CODEX_HOME="$BATS_TEST_TMPDIR/profile" _hook "$pid" + kill "$pid" 2>/dev/null || true + grep -qx "session=$new" "$rec" + grep -qx "owner=$new.$pid" "$rec" + [ -f "$TEST_SKILL_DIR/run/codex-thread-home.$new" ] } # #1477: Codex installed after agmsg never gets this install's writable_roots diff --git a/tests/test_self_fix.bats b/tests/test_self_fix.bats index eba5af43e..889267b2f 100644 --- a/tests/test_self_fix.bats +++ b/tests/test_self_fix.bats @@ -496,21 +496,6 @@ FAKEEOF [ "$(agmsg_role_session_uuid T cx)" = "new-thread-222" ] [ "$(cat "$RUN_DIR/codex-bridge.T.cx.thread")" = "new-thread-222" ] - # The SessionStart hook runs the same rebind with the full sync switched - # off: the record moves, and the bridge hand-off is the hook's own next - # step, so session-start.sh must not be run from inside it. - : > "$SPY" - agmsg_role_session_record T cx new-thread-222 "$project" codex "" - printf 'old-thread-111.%s\n' "$$" > "$(actas_lock_path T cx)" - printf '%s\n' "old-thread-111.$$" > "$RUN_DIR/cc-instance.$$" - export CODEX_THREAD_ID=new-thread-444 - AGMSG_FIX_REASSIGN_FROM_HOOK=1 run _fix_codex_thread_reassign - [ "$status" -eq 0 ] - [ "$output" = "ok T cx new-thread-444.$$" ] - grep -Fq "record-session T cx $project CODEX_THREAD_ID=new-thread-444" "$SPY" - refute grep -q '^session-start' "$SPY" - [ "$(agmsg_role_session_uuid T cx)" = "new-thread-444" ] - # Condition missing: a DIFFERENT, still-alive pid holds the old lock -- a # genuinely separate live process, not this session's own. This is exactly # the case the narrow reclaim must never touch: same-pid is its whole From 1a5b659fbc1a0784f4db4a8569893bb76fcc9ff8 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 20 Oct 2026 10:33:37 +0900 Subject: [PATCH 51/97] Confirm a Claude Code notice from the transcript position recorded before it was sent (#1662) The delivery check reads from the transcript length recorded just before the send instead of only the last 512 KiB. [skip ci] --- .../daemon/channels/claude-code-queue-io.mjs | 125 +++++++++++++----- .../channels/claude-code-queue-store.mjs | 8 +- scripts/daemon/channels/claude-code-queue.mjs | 15 ++- tests/agmsgd_claude_code_queue.test.mjs | 14 +- 4 files changed, 123 insertions(+), 39 deletions(-) diff --git a/scripts/daemon/channels/claude-code-queue-io.mjs b/scripts/daemon/channels/claude-code-queue-io.mjs index 83bfa071c..281b8a45c 100644 --- a/scripts/daemon/channels/claude-code-queue-io.mjs +++ b/scripts/daemon/channels/claude-code-queue-io.mjs @@ -24,6 +24,9 @@ const SEND_TIMEOUT_MS = 5_000; // recent (written within the TTL window of a message this same daemon just // sent). 512 KiB comfortably covers many turns' worth of lines. const TRANSCRIPT_TAIL_BYTES = 512 * 1024; +// A pending nudge that recorded the transcript's EOF before it was sent is +// checked from that position onward, in chunks of this size. +const TRANSCRIPT_CHUNK_BYTES = 256 * 1024; // Measured 2026-10-05: Claude Code encodes a project's cwd into its // transcript directory name by replacing every "/" with "-" @@ -160,8 +163,20 @@ function readTail(path, maxBytes) { return buf.toString("utf8"); } -// Reads the delivery outcome straight from the receiving session's own -// transcript. Measured line shapes (2026-10-05 addendum): +// The byte length of the transcript right now, recorded just before a nudge is +// sent so the later check reads only what was written after it. null when the +// file cannot be measured: that nudge falls back to reading the tail. +export function transcriptEndPosition(transcriptPath) { + try { + const stat = statSync(transcriptPath); + return stat.isFile() ? stat.size : null; + } catch { + return null; + } +} + +// Judges one transcript line: "delivered", "held", or "" when it says nothing +// about this nonce. Measured line shapes (2026-10-05 addendum): // - delivered: a `type:"user"` line with `origin.body` containing the // nonce verbatim — proof of delivery (with the mid-turn shape below). // - held/declined: a `type:"system"`, `subtype:"informational"` line whose @@ -172,40 +187,90 @@ function readTail(path, maxBytes) { // - a `type:"queue-operation"`, `operation:"enqueue"` line fires on mere // receipt, held or not — deliberately never read as evidence here (G4: // never mark a seat notified on evidence that could be a hold). -export function observeTranscript(transcriptPath, nonce) { - if (typeof nonce !== "string" || !nonce) return { state: "unreadable", reason: "nonce_missing" }; - let text; +function judgeLine(line, marker) { + if (!line || !line.includes(marker)) return ""; + let row; try { - text = readTail(transcriptPath, TRANSCRIPT_TAIL_BYTES); - } catch (error) { - if (error?.code === "ENOENT") return { state: "unreadable", reason: "transcript_missing" }; - return { state: "unreadable", reason: "transcript_read_failed" }; + row = JSON.parse(line); + } catch { + return ""; + } + if (row?.type === "user" && typeof row?.origin?.body === "string" && row.origin.body.includes(marker)) { + return "delivered"; + } + // A nudge that arrives while the seat is mid-turn is absorbed into that + // turn: the model reads it as a queued_command attachment and no user line + // is ever written for it. Without this the nudge stays pending forever and + // the seat gets no further notices. + if (row?.type === "attachment" && row?.attachment?.type === "queued_command" + && typeof row.attachment.prompt === "string" && row.attachment.prompt.includes(marker)) { + return "delivered"; + } + if (row?.type === "system" && row?.subtype === "informational" && typeof row?.content === "string" && row.content.includes(marker)) { + return "held"; + } + return ""; +} + +// Reads from `start` to EOF in fixed-size chunks, handing each complete line +// to `onLine` (stop early by returning true). A line is decoded only whole, so +// a multi-byte character is never split across chunks. +function scanFrom(path, start, onLine) { + const fd = openSync(path, "r"); + try { + const buf = Buffer.alloc(TRANSCRIPT_CHUNK_BYTES); + let position = start; + let carry = Buffer.alloc(0); + for (;;) { + const read = readSync(fd, buf, 0, buf.length, position); + if (read <= 0) break; + position += read; + let data = Buffer.concat([carry, buf.subarray(0, read)]); + let from = 0; + for (let at = data.indexOf(10, from); at !== -1; at = data.indexOf(10, from)) { + if (onLine(data.subarray(from, at).toString("utf8"))) return; + from = at + 1; + } + carry = data.subarray(from); + } + onLine(carry.toString("utf8")); + } finally { + closeSync(fd); } +} + +// Reads the delivery outcome straight from the receiving session's own +// transcript. With `fromPosition` (the transcript length recorded before the +// nudge was sent) only what was written after it is read, however much the +// conversation has grown since; a transcript shorter than that position was +// rewritten or truncated and is read from the start. Without it, only the +// tail is read. +export function observeTranscript(transcriptPath, nonce, fromPosition = null) { + if (typeof nonce !== "string" || !nonce) return { state: "unreadable", reason: "nonce_missing" }; const marker = `[agmsg:${nonce}]`; let heldSeen = false; - for (const line of text.split("\n")) { - if (!line || !line.includes(marker)) continue; - let row; - try { - row = JSON.parse(line); - } catch { - continue; - } - if (row?.type === "user" && typeof row?.origin?.body === "string" && row.origin.body.includes(marker)) { - return { state: "delivered" }; - } - // A nudge that arrives while the seat is mid-turn is absorbed into that - // turn: the model reads it as a queued_command attachment and no user line - // is ever written for it. Without this the nudge stays pending forever and - // the seat gets no further notices. - if (row?.type === "attachment" && row?.attachment?.type === "queued_command" - && typeof row.attachment.prompt === "string" && row.attachment.prompt.includes(marker)) { - return { state: "delivered" }; - } - if (row?.type === "system" && row?.subtype === "informational" && typeof row?.content === "string" && row.content.includes(marker)) { - heldSeen = true; + let delivered = false; + const onLine = (line) => { + const verdict = judgeLine(line, marker); + if (verdict === "delivered") delivered = true; + else if (verdict === "held") heldSeen = true; + return delivered; + }; + try { + if (Number.isSafeInteger(fromPosition) && fromPosition >= 0) { + const stat = statSync(transcriptPath); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("not_regular_file"); + scanFrom(transcriptPath, fromPosition > stat.size ? 0 : fromPosition, onLine); + } else { + for (const line of readTail(transcriptPath, TRANSCRIPT_TAIL_BYTES).split("\n")) { + if (onLine(line)) break; + } } + } catch (error) { + if (error?.code === "ENOENT") return { state: "unreadable", reason: "transcript_missing" }; + return { state: "unreadable", reason: "transcript_read_failed" }; } + if (delivered) return { state: "delivered" }; if (heldSeen) return { state: "held" }; return { state: "absent" }; } diff --git a/scripts/daemon/channels/claude-code-queue-store.mjs b/scripts/daemon/channels/claude-code-queue-store.mjs index 1e304ae32..0fa4478d6 100644 --- a/scripts/daemon/channels/claude-code-queue-store.mjs +++ b/scripts/daemon/channels/claude-code-queue-store.mjs @@ -17,7 +17,8 @@ CREATE TABLE IF NOT EXISTS beta_claude_queue ( up_to TEXT NOT NULL, nonce TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending', 'confirmed', 'expired')), - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + transcript_pos INTEGER ); CREATE UNIQUE INDEX IF NOT EXISTS beta_claude_queue_one_pending ON beta_claude_queue(seat) WHERE state = 'pending'; @@ -36,6 +37,11 @@ export function ensureClaudeCodeChannelSchema(db) { db.exec("BEGIN IMMEDIATE;"); try { db.exec(CLAUDE_CODE_CHANNEL_SCHEMA); + // A table created before the transcript position was recorded gains the column. + const columns = db.prepare("PRAGMA table_info(beta_claude_queue)").all(); + if (!columns.some((column) => column.name === "transcript_pos")) { + db.exec("ALTER TABLE beta_claude_queue ADD COLUMN transcript_pos INTEGER"); + } db.exec("COMMIT;"); } catch (error) { try { db.exec("ROLLBACK;"); } catch { /* preserve the schema error */ } diff --git a/scripts/daemon/channels/claude-code-queue.mjs b/scripts/daemon/channels/claude-code-queue.mjs index 4ed735fba..0d9321f65 100644 --- a/scripts/daemon/channels/claude-code-queue.mjs +++ b/scripts/daemon/channels/claude-code-queue.mjs @@ -29,6 +29,7 @@ import { inboxNudge, newNonce, observeTranscript, + transcriptEndPosition, resolveTranscriptPath, resolvePendingDelivery, sendClaudeCodeMessage, @@ -45,7 +46,7 @@ function registrationProjects(teamConfig, agent) { function readPending(db, seat) { return db.prepare(` - SELECT id, messaging_socket, transcript_path, up_to, nonce, state, created_at + SELECT id, messaging_socket, transcript_path, transcript_pos, up_to, nonce, state, created_at FROM beta_claude_queue WHERE seat = ? AND state = 'pending' ORDER BY id DESC LIMIT 1 `).get(seat); } @@ -91,7 +92,7 @@ function setQueueState(db, id, state) { }); } -function createPending(db, { seat, messagingSocket, transcriptPath, upTo, nonce, expectedOpGen }, now) { +function createPending(db, { seat, messagingSocket, transcriptPath, transcriptPos, upTo, nonce, expectedOpGen }, now) { let rowId; withImmediateTransaction(db, () => { const intent = db.prepare("SELECT desired, op_gen FROM daemon_intent").get(); @@ -101,9 +102,9 @@ function createPending(db, { seat, messagingSocket, transcriptPath, upTo, nonce, const current = db.prepare("SELECT id FROM beta_claude_queue WHERE seat = ? AND state = 'pending'").get(seat); if (current) throw new Error("seat_already_pending"); const result = db.prepare(` - INSERT INTO beta_claude_queue (seat, messaging_socket, transcript_path, up_to, nonce, state, created_at) - VALUES (?, ?, ?, ?, ?, 'pending', ?) - `).run(seat, messagingSocket, transcriptPath, upTo, nonce, new Date(now()).toISOString()); + INSERT INTO beta_claude_queue (seat, messaging_socket, transcript_path, transcript_pos, up_to, nonce, state, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?) + `).run(seat, messagingSocket, transcriptPath, transcriptPos, upTo, nonce, new Date(now()).toISOString()); rowId = Number(result.lastInsertRowid); }); return rowId; @@ -117,7 +118,7 @@ function storagePaths(installRoot, env) { } function pendingObservation(pending, observe) { - return resolvePendingDelivery({ observation: observe(pending.transcript_path, pending.nonce) }); + return resolvePendingDelivery({ observation: observe(pending.transcript_path, pending.nonce, pending.transcript_pos) }); } export function createClaudeCodeQueueChannel({ @@ -270,7 +271,7 @@ export function createClaudeCodeQueueChannel({ const nonce = newNonce(); let id; try { - id = createPending(db, { seat, messagingSocket: record.messaging_socket, transcriptPath, upTo: snapshot.upTo, nonce, expectedOpGen }, now); + id = createPending(db, { seat, messagingSocket: record.messaging_socket, transcriptPath, transcriptPos: transcriptEndPosition(transcriptPath), upTo: snapshot.upTo, nonce, expectedOpGen }, now); } catch (error) { if (error.message !== "daemon_intent_changed" && error.message !== "seat_already_pending") throw error; return; diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index aecdf2c74..cc1dfc695 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -79,6 +79,18 @@ test('observeTranscript: a queue-operation enqueue line is not delivery evidence attachment: { type: 'queued_command', prompt: `${marker}` }, })}\n`); assert.equal(observeTranscript(transcriptPath, nonce).state, 'delivered'); + + // #1644: the delivered line scrolls out of the 512 KiB tail under later + // output. A position recorded before the send still finds it; a transcript + // that has become shorter than the position is read from the start. + const before = statSync(transcriptPath).size; + const delivered = JSON.stringify({ type: 'user', origin: { kind: 'peer', body: marker } }); + const filler = `${JSON.stringify({ type: 'assistant', text: 'x'.repeat(1000) })}\n`.repeat(800); + writeFileSync(transcriptPath, `${'y'.repeat(before)}\n${delivered}\n${filler}`); + assert.equal(observeTranscript(transcriptPath, nonce).state, 'absent'); + assert.equal(observeTranscript(transcriptPath, nonce, before + 1).state, 'delivered'); + assert.equal(observeTranscript(transcriptPath, nonce, statSync(transcriptPath).size + 10).state, 'delivered'); + assert.equal(observeTranscript(transcriptPath, nonce, statSync(transcriptPath).size).state, 'absent'); }); // No clock decides a pending nudge: whatever the transcript shows (or fails to From 0bb5956beba0d89a1607224d653d9333c80489db Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 20 Oct 2026 14:56:35 +0900 Subject: [PATCH 52/97] Harden the Claude Code transcript position check and heal stuck pending rows (#1665) A position inside a half-written row is skipped, a delivery row must read back with its line boundaries before it confirms, and a pending row with no recorded position is searched from the start. [skip ci] --- .../daemon/channels/claude-code-queue-io.mjs | 104 ++++++++++-------- tests/agmsgd_claude_code_queue.test.mjs | 61 +++++++++- 2 files changed, 117 insertions(+), 48 deletions(-) diff --git a/scripts/daemon/channels/claude-code-queue-io.mjs b/scripts/daemon/channels/claude-code-queue-io.mjs index 281b8a45c..ded9af4d2 100644 --- a/scripts/daemon/channels/claude-code-queue-io.mjs +++ b/scripts/daemon/channels/claude-code-queue-io.mjs @@ -19,13 +19,8 @@ import { isAbsolute } from "node:path"; export { newNonce, inboxNudge } from "./codex-queue-io.mjs"; const SEND_TIMEOUT_MS = 5_000; -// Bound the transcript read to its tail: a long-lived seat's jsonl grows -// without limit, and the delivery record this channel looks for is always -// recent (written within the TTL window of a message this same daemon just -// sent). 512 KiB comfortably covers many turns' worth of lines. -const TRANSCRIPT_TAIL_BYTES = 512 * 1024; -// A pending nudge that recorded the transcript's EOF before it was sent is -// checked from that position onward, in chunks of this size. +// A pending nudge is checked from the transcript position recorded before it +// was sent (the start when none was), in chunks of this size. const TRANSCRIPT_CHUNK_BYTES = 256 * 1024; // Measured 2026-10-05: Claude Code encodes a project's cwd into its @@ -149,23 +144,9 @@ export async function sendClaudeCodeMessage({ }); } -function readTail(path, maxBytes) { - const stat = statSync(path); - if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("not_regular_file"); - if (stat.size <= maxBytes) return readFileSync(path, "utf8"); - const buf = Buffer.alloc(maxBytes); - const fd = openSync(path, "r"); - try { - readSync(fd, buf, 0, maxBytes, stat.size - maxBytes); - } finally { - closeSync(fd); - } - return buf.toString("utf8"); -} - // The byte length of the transcript right now, recorded just before a nudge is // sent so the later check reads only what was written after it. null when the -// file cannot be measured: that nudge falls back to reading the tail. +// file cannot be measured: that nudge is checked from the start. export function transcriptEndPosition(transcriptPath) { try { const stat = statSync(transcriptPath); @@ -213,59 +194,90 @@ function judgeLine(line, marker) { } // Reads from `start` to EOF in fixed-size chunks, handing each complete line -// to `onLine` (stop early by returning true). A line is decoded only whole, so -// a multi-byte character is never split across chunks. -function scanFrom(path, start, onLine) { +// its byte offset and whether a newline ended it to `onLine` (stop early by returning true). A line is +// decoded only whole, so a multi-byte character is never split across chunks. +// A `start` that is not at the beginning of a line (the byte before it is not a +// newline: a row was half written when it was recorded) discards the fragment +// up to the first newline instead of reading it as a line of its own. +function scanFrom(path, start, onLine, io) { const fd = openSync(path, "r"); try { const buf = Buffer.alloc(TRANSCRIPT_CHUNK_BYTES); + let skipping = false; + if (start > 0) { + const prior = Buffer.alloc(1); + skipping = io.readSync(fd, prior, 0, 1, start - 1) === 1 && prior[0] !== 10; + } let position = start; let carry = Buffer.alloc(0); for (;;) { - const read = readSync(fd, buf, 0, buf.length, position); + const read = io.readSync(fd, buf, 0, buf.length, position); if (read <= 0) break; position += read; - let data = Buffer.concat([carry, buf.subarray(0, read)]); + const data = Buffer.concat([carry, buf.subarray(0, read)]); + const base = position - data.length; let from = 0; for (let at = data.indexOf(10, from); at !== -1; at = data.indexOf(10, from)) { - if (onLine(data.subarray(from, at).toString("utf8"))) return; + if (skipping) skipping = false; + else if (onLine(data.subarray(from, at), base + from, fd, true)) return; from = at + 1; } carry = data.subarray(from); } - onLine(carry.toString("utf8")); + if (!skipping && carry.length) onLine(carry, position - carry.length, fd, false); } finally { closeSync(fd); } } // Reads the delivery outcome straight from the receiving session's own -// transcript. With `fromPosition` (the transcript length recorded before the -// nudge was sent) only what was written after it is read, however much the +// transcript. Only what was written after `fromPosition` (the transcript +// length recorded before the nudge was sent) is read, however much the // conversation has grown since; a transcript shorter than that position was -// rewritten or truncated and is read from the start. Without it, only the -// tail is read. -export function observeTranscript(transcriptPath, nonce, fromPosition = null) { +// rewritten or truncated and is read from the start, as is a nudge with no +// recorded position. A line is accepted as delivery only after it reads back +// byte for byte from the same place: a file rewritten during a read must not +// be confirmed from bytes that no version of it ever held. +export function observeTranscript(transcriptPath, nonce, fromPosition = null, io = { readSync }) { if (typeof nonce !== "string" || !nonce) return { state: "unreadable", reason: "nonce_missing" }; const marker = `[agmsg:${nonce}]`; let heldSeen = false; let delivered = false; - const onLine = (line) => { - const verdict = judgeLine(line, marker); - if (verdict === "delivered") delivered = true; + // Reads exactly `length` bytes at `position` (short reads are legal), or + // fewer only at EOF. + const readFully = (fd, buffer, length, position) => { + let got = 0; + while (got < length) { + const read = io.readSync(fd, buffer, got, length - got, position + got); + if (read <= 0) break; + got += read; + } + return got; + }; + // The line reads back unchanged with its own boundaries: the newline before + // it (every line but the first of the file follows one), its bytes in + // bounded chunks, and the newline after it when it had one. + const stable = (fd, bytes, offset, terminated) => { + const edge = Buffer.alloc(1); + if (offset > 0 && (readFully(fd, edge, 1, offset - 1) !== 1 || edge[0] !== 10)) return false; + const chunk = Buffer.alloc(Math.min(TRANSCRIPT_CHUNK_BYTES, bytes.length)); + for (let done = 0; done < bytes.length; done += chunk.length) { + const size = Math.min(chunk.length, bytes.length - done); + if (readFully(fd, chunk, size, offset + done) !== size || !chunk.subarray(0, size).equals(bytes.subarray(done, done + size))) return false; + } + return !terminated || (readFully(fd, edge, 1, offset + bytes.length) === 1 && edge[0] === 10); + }; + const onLine = (bytes, offset, fd, terminated) => { + const verdict = judgeLine(bytes.toString("utf8"), marker); + if (verdict === "delivered" && stable(fd, bytes, offset, terminated)) delivered = true; else if (verdict === "held") heldSeen = true; return delivered; }; try { - if (Number.isSafeInteger(fromPosition) && fromPosition >= 0) { - const stat = statSync(transcriptPath); - if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("not_regular_file"); - scanFrom(transcriptPath, fromPosition > stat.size ? 0 : fromPosition, onLine); - } else { - for (const line of readTail(transcriptPath, TRANSCRIPT_TAIL_BYTES).split("\n")) { - if (onLine(line)) break; - } - } + const stat = statSync(transcriptPath); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("not_regular_file"); + const start = Number.isSafeInteger(fromPosition) && fromPosition >= 0 && fromPosition <= stat.size ? fromPosition : 0; + scanFrom(transcriptPath, start, onLine, io); } catch (error) { if (error?.code === "ENOENT") return { state: "unreadable", reason: "transcript_missing" }; return { state: "unreadable", reason: "transcript_read_failed" }; diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index cc1dfc695..b04f6e15f 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -87,12 +87,69 @@ test('observeTranscript: a queue-operation enqueue line is not delivery evidence const delivered = JSON.stringify({ type: 'user', origin: { kind: 'peer', body: marker } }); const filler = `${JSON.stringify({ type: 'assistant', text: 'x'.repeat(1000) })}\n`.repeat(800); writeFileSync(transcriptPath, `${'y'.repeat(before)}\n${delivered}\n${filler}`); - assert.equal(observeTranscript(transcriptPath, nonce).state, 'absent'); + // No recorded position (a pending row from before positions): searched from the start, however far back. + assert.equal(observeTranscript(transcriptPath, nonce).state, 'delivered'); assert.equal(observeTranscript(transcriptPath, nonce, before + 1).state, 'delivered'); assert.equal(observeTranscript(transcriptPath, nonce, statSync(transcriptPath).size + 10).state, 'delivered'); assert.equal(observeTranscript(transcriptPath, nonce, statSync(transcriptPath).size).state, 'absent'); }); +// A position recorded while a row was half written lands mid-row: the rest of +// that row is a fragment, not a line, so evidence inside it is never read. +test('observeTranscript: a recorded position inside a half-written row discards that fragment', (t) => { + const dir = temporaryDirectory(t); + const transcriptPath = path.join(dir, 'session.jsonl'); + const nonce = 'mid-row-nonce'; + const row = JSON.stringify({ type: 'user', origin: { kind: 'peer', body: `[agmsg:${nonce}] x` } }); + writeFileSync(transcriptPath, `unfinished-prefix${row}\n`); + assert.equal(observeTranscript(transcriptPath, nonce, 'unfinished-prefix'.length).state, 'absent'); + writeFileSync(transcriptPath, `unfinished-prefix${row}\n${row}\n`); + assert.equal(observeTranscript(transcriptPath, nonce, 'unfinished-prefix'.length).state, 'delivered'); +}); + +// The file is rewritten while a read is in flight: the first read sees a +// delivery row that no complete version of the file ever held. The row must +// read back unchanged before it counts. +test('observeTranscript: a delivery row that changes between two reads is not confirmed', (t) => { + const dir = temporaryDirectory(t); + const transcriptPath = path.join(dir, 'session.jsonl'); + const nonce = 'rewrite-nonce'; + const row = JSON.stringify({ type: 'user', origin: { kind: 'peer', body: `[agmsg:${nonce}]` } }); + writeFileSync(transcriptPath, `${row}\n`); + let calls = 0; + const io = { + readSync(fd, buffer, offset, length, position) { + const read = readSync(fd, buffer, offset, length, position); + if (++calls === 1) writeFileSync(transcriptPath, `${JSON.stringify({ type: 'tool', origin: { body: `[agmsg:${nonce}]` } })}\n`); + return read; + }, + }; + assert.equal(observeTranscript(transcriptPath, nonce, 0, io).state, 'absent'); + assert.equal(observeTranscript(transcriptPath, nonce, 0).state, 'absent'); +}); + +// The line boundary is part of what must read back unchanged, and a short +// read must not make a stable row look changed. +test('observeTranscript: a rewritten line boundary is not confirmed, and short reads still confirm a stable row', (t) => { + const dir = temporaryDirectory(t); + const transcriptPath = path.join(dir, 'session.jsonl'); + const nonce = 'boundary-nonce'; + const row = JSON.stringify({ type: 'user', origin: { kind: 'peer', body: `[agmsg:${nonce}]` } }); + writeFileSync(transcriptPath, `{}\n${row}\n`); + let calls = 0; + const rewrite = { + readSync(fd, buffer, offset, length, position) { + const read = readSync(fd, buffer, offset, length, position); + if (++calls === 2) writeFileSync(transcriptPath, `bad${row}\n`); + return read; + }, + }; + assert.equal(observeTranscript(transcriptPath, nonce, 3, rewrite).state, 'absent'); + writeFileSync(transcriptPath, `{}\n${row}\n`); + const short = { readSync: (fd, buffer, offset, length, position) => readSync(fd, buffer, offset, Math.min(length, 14), position) }; + assert.equal(observeTranscript(transcriptPath, nonce, 3, short).state, 'delivered'); +}); + // No clock decides a pending nudge: whatever the transcript shows (or fails to // show), it stays pending until the channel itself sees the socket or the // seat's record change. From 60b2921e29c4608c155eb2cee69fdb146ec947e2 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 20 Oct 2026 15:00:48 +0900 Subject: [PATCH 53/97] Send Claude Code nudges with the session token and no claimed permission mode (#1666) Nudges were labelled from-mode=bypass, which a prompting (auto) session holds. They now open with the session token as an auth line and claim no mode; a held nudge is recognised from the bracketless preview, recorded as peer_held, and keeps that socket on the Monitor path. [skip ci] --- scripts/actas-claim.sh | 4 +- .../daemon/channels/claude-code-queue-io.mjs | 17 +++++-- .../channels/claude-code-queue-store.mjs | 1 + scripts/daemon/channels/claude-code-queue.mjs | 17 +++++-- .../types/claude-code/_session-start.sh | 6 ++- scripts/lib/daemon-state.sh | 14 ++++++ scripts/lib/role-session.sh | 18 +++++--- scripts/watch.sh | 2 + tests/agmsgd_claude_code_queue.test.mjs | 44 ++++++++++++++++++- tests/test_agmsgd_switch.bats | 14 ++++++ tests/test_role_session.bats | 9 ++++ 11 files changed, 130 insertions(+), 16 deletions(-) diff --git a/scripts/actas-claim.sh b/scripts/actas-claim.sh index b633df288..ecb365910 100755 --- a/scripts/actas-claim.sh +++ b/scripts/actas-claim.sh @@ -160,7 +160,9 @@ if [ "$TYPE" = claude-code ] && [ -n "${SKILL_DIR:-}" ] \ NATIVE_DELIVERY=1 while IFS= read -r team; do [ -z "$team" ] && continue - agmsg_role_session_publish_messaging "$team" "$NAME" "${_native_target%%$'\t'*}" "${_native_target#*$'\t'}" "$BARE_SID" || NATIVE_DELIVERY=0 + agmsg_role_session_publish_messaging "$team" "$NAME" "${_native_target%%$'\t'*}" "${_native_target#*$'\t'}" "$BARE_SID" "${CLAUDE_CODE_MESSAGING_TOKEN:-}" || NATIVE_DELIVERY=0 + # This Claude Code holds agmsgd's nudges: keep the Monitor path. + ! agmsg_daemon_claude_seat_held "$team" "$NAME" "${_native_target%%$'\t'*}" || NATIVE_DELIVERY=0 done <<< "$TEAMS" fi fi diff --git a/scripts/daemon/channels/claude-code-queue-io.mjs b/scripts/daemon/channels/claude-code-queue-io.mjs index ded9af4d2..99f706816 100644 --- a/scripts/daemon/channels/claude-code-queue-io.mjs +++ b/scripts/daemon/channels/claude-code-queue-io.mjs @@ -99,6 +99,7 @@ export async function sendClaudeCodeMessage({ socketPath, nonce, body, + token = "", timeoutMs = SEND_TIMEOUT_MS, checkOwnership = checkSocketOwnership, connect = createConnection, @@ -112,12 +113,17 @@ export async function sendClaudeCodeMessage({ type: "user", message: { role: "user", - content: `\n${body}\n`, + content: `\n${body}\n`, }, priority: "next", from: "agmsgd", }; - const line = `${JSON.stringify(payload)}\n`; + // The session's own inbox token goes first, as its documentation describes + // ({"type":"auth","token":...} as the first line of the connection), so the + // message is judged as one posted to its own session. The sender claims no + // permission mode: it is not a session and has none. + const auth = token ? `${JSON.stringify({ type: "auth", token })}\n` : ""; + const line = `${auth}${JSON.stringify(payload)}\n`; return new Promise((resolve) => { let settled = false; @@ -169,7 +175,10 @@ export function transcriptEndPosition(transcriptPath) { // receipt, held or not — deliberately never read as evidence here (G4: // never mark a seat notified on evidence that could be a hold). function judgeLine(line, marker) { - if (!line || !line.includes(marker)) return ""; + // A held message is shown as a preview in which Claude Code drops the + // brackets: «agmsg: …». Only delivery needs the bracketed form. + const heldMarker = marker.slice(1, -1); + if (!line || !line.includes(heldMarker)) return ""; let row; try { row = JSON.parse(line); @@ -187,7 +196,7 @@ function judgeLine(line, marker) { && typeof row.attachment.prompt === "string" && row.attachment.prompt.includes(marker)) { return "delivered"; } - if (row?.type === "system" && row?.subtype === "informational" && typeof row?.content === "string" && row.content.includes(marker)) { + if (row?.type === "system" && row?.subtype === "informational" && typeof row?.content === "string" && row.content.includes(heldMarker)) { return "held"; } return ""; diff --git a/scripts/daemon/channels/claude-code-queue-store.mjs b/scripts/daemon/channels/claude-code-queue-store.mjs index 0fa4478d6..62fa9a71f 100644 --- a/scripts/daemon/channels/claude-code-queue-store.mjs +++ b/scripts/daemon/channels/claude-code-queue-store.mjs @@ -82,6 +82,7 @@ export function readClaudeRoleSession(runDir, team, agent) { session: fields.session ?? "", messaging_socket: fields.messaging_socket ?? "", claude_config_dir: fields.claude_config_dir ?? "", + messaging_token: fields.messaging_token ?? "", }, }; } catch { diff --git a/scripts/daemon/channels/claude-code-queue.mjs b/scripts/daemon/channels/claude-code-queue.mjs index 0d9321f65..cd1a38570 100644 --- a/scripts/daemon/channels/claude-code-queue.mjs +++ b/scripts/daemon/channels/claude-code-queue.mjs @@ -37,6 +37,17 @@ import { const SEND_TIMEOUT_MS = 5_000; +// What the seat's Claude Code did with a nudge it set aside. It is not +// "addressable": the message is not reaching the model, so the seat must be +// served some other way (watch.sh checks for this exact reason prefix). +export const PEER_HELD_REASON = "peer_held: the seat's Claude Code is holding messages from agmsgd (see its crossSessionInbound setting and permission mode)"; + +function observedSeat(reason) { + return reason === "peer_held" + ? { state: "blocked", reason: PEER_HELD_REASON } + : { state: "addressable", reason }; +} + function registrationProjects(teamConfig, agent) { const registrations = teamConfig?.agents?.[agent]?.registrations; return Array.isArray(registrations) @@ -179,7 +190,7 @@ export function createClaudeCodeQueueChannel({ record.messaging_socket !== pending.messaging_socket || (transcriptNow && transcriptNow !== pending.transcript_path)); if (!socketGone && !recordMoved) { - saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "addressable", reason: socketCheck.state === "ok" ? observation.reason : socketCheck.reason }, now); + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, ...observedSeat(socketCheck.state === "ok" ? observation.reason : socketCheck.reason) }, now); return; } setQueueState(db, pending.id, "expired"); @@ -277,7 +288,7 @@ export function createClaudeCodeQueueChannel({ return; } - const result = await send({ socketPath: record.messaging_socket, nonce, body: inboxNudge(nonce), timeoutMs }); + const result = await send({ socketPath: record.messaging_socket, nonce, body: inboxNudge(nonce), token: record.messaging_token, timeoutMs }); if (result.state !== "sent") { setQueueState(db, id, "expired"); saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `send_failed:${result.reason}` }, now); @@ -289,7 +300,7 @@ export function createClaudeCodeQueueChannel({ saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "addressable" }, now); return; } - saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "addressable", reason: firstCheck.reason }, now); + saveSeat(db, { seat, messagingSocket: record.messaging_socket, ...observedSeat(firstCheck.reason) }, now); } async function pollOnce() { diff --git a/scripts/drivers/types/claude-code/_session-start.sh b/scripts/drivers/types/claude-code/_session-start.sh index 77d35709a..aa82b49cf 100644 --- a/scripts/drivers/types/claude-code/_session-start.sh +++ b/scripts/drivers/types/claude-code/_session-start.sh @@ -140,7 +140,11 @@ EOF agmsg_role_session_record "$pair_team" "$pair_agent" "$bare_sid" \ "$(agmsg_canonical_path "${PROJECT:-}" 2>/dev/null || printf '%s' "${PROJECT:-}")" "${TYPE:-}" "$my_instance" fi - if agmsg_role_session_publish_messaging "$pair_team" "$pair_agent" "$socket" "$config_dir" "$bare_sid"; then + if agmsg_role_session_publish_messaging "$pair_team" "$pair_agent" "$socket" "$config_dir" "$bare_sid" "${CLAUDE_CODE_MESSAGING_TOKEN:-}"; then + if agmsg_daemon_claude_seat_held "$pair_team" "$pair_agent" "$socket"; then + echo "AGMSG delivery: agmsgd's notices are being held by this Claude Code (its crossSessionInbound setting or permission mode), so the native channel is not used. Next: arm the Monitor below." + return 0 + fi cat < + local t="${1//\'/\'\'}" a="${2//\'/\'\'}" sock="${3//\'/\'\'}" n + [ -f "$_AGMSG_DAEMON_ROOT/run/install.db" ] || return 1 + n="$(sqlite3 -readonly -cmd '.timeout 100' "$_AGMSG_DAEMON_ROOT/run/install.db" " + SELECT count(*) FROM beta_claude_seat WHERE seat = json_array('$t','$a') + AND state = 'blocked' AND reason LIKE 'peer_held%' AND messaging_socket = '$sock'; + " 2>/dev/null | tr -d '\r')" || return 1 + [ "$n" = 1 ] +} + agmsg_daemon_recovery_text() { if [ "$AGMSGD_HEALTH" = unchecked ]; then printf 'agmsgd (Codex notices) could not be checked from this environment (%s). It may be running; nothing needs to be done unless notices stop arriving.\n' "$AGMSGD_REASON" diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index d0e502992..99713d067 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -312,8 +312,12 @@ agmsg_role_session_set_messaging() { _agmsg_role_session_write _agmsg_role_session_set_messaging_unlocked "$@" || true } -_agmsg_role_session_set_messaging_unlocked() { # [] - local team="$1" agent="$2" socket="$3" config_dir="$4" bare_sid="${5:-}" +_agmsg_role_session_set_messaging_unlocked() { # [ []] + local team="$1" agent="$2" socket="$3" config_dir="$4" bare_sid="${5:-}" token="${6:-}" + # The token is a credential for posting to this session's own inbox: kept + # only when it is a plain word, and the record (mktemp: owner-only) is never + # made readable by anyone else. + case "$token" in ''|*[!A-Za-z0-9._-]*) token="" ;; esac [ -n "$team" ] && [ -n "$agent" ] || return 0 local path dir tmp line _agmsg_role_session_path_into "$team" "$agent" @@ -324,7 +328,7 @@ _agmsg_role_session_set_messaging_unlocked() { # "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 0; } + chmod 600 "$tmp" 2>/dev/null || true mv -f "$tmp" "$path" 2>/dev/null || rm -f "$tmp" 2>/dev/null return 0 } @@ -606,9 +612,9 @@ agmsg_claude_code_native_target() { # Publish / into /'s record for , # then read it back: the same check the daemon applies before it will address # the seat, so "the record took" is verified, not assumed. rc 0 only when it did. -agmsg_role_session_publish_messaging() { # - local team="$1" agent="$2" socket="$3" config_dir="$4" bare_sid="$5" - agmsg_role_session_set_messaging "$team" "$agent" "$socket" "$config_dir" "$bare_sid" +agmsg_role_session_publish_messaging() { # [] + local team="$1" agent="$2" socket="$3" config_dir="$4" bare_sid="$5" token="${6:-}" + agmsg_role_session_set_messaging "$team" "$agent" "$socket" "$config_dir" "$bare_sid" "$token" [ "$(agmsg_role_session_get "$team" "$agent" messaging_socket 2>/dev/null || true)" = "$socket" ] \ && [ "$(agmsg_role_session_get "$team" "$agent" claude_config_dir 2>/dev/null || true)" = "$config_dir" ] \ && [ "$(agmsg_role_session_uuid "$team" "$agent" 2>/dev/null || true)" = "$bare_sid" ] diff --git a/scripts/watch.sh b/scripts/watch.sh index 7b2ca7bae..ea14588b2 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -1277,6 +1277,8 @@ _agmsg_watch_native_delivery_active() { [ "$(agmsg_role_session_get "$_team" "$_agent" type 2>/dev/null)" = claude-code ] || return 1 [ -n "$(agmsg_role_session_get "$_team" "$_agent" messaging_socket 2>/dev/null)" ] || return 1 [ -n "$(agmsg_role_session_get "$_team" "$_agent" claude_config_dir 2>/dev/null)" ] || return 1 + # A Claude Code that holds agmsgd's nudges is not served by it. + ! agmsg_daemon_claude_seat_held "$_team" "$_agent" "$(agmsg_role_session_get "$_team" "$_agent" messaging_socket 2>/dev/null)" || return 1 [ "$(agmsg_role_session_get "$_team" "$_agent" project 2>/dev/null)" = "$_canon_project" ] || return 1 done <<< "$PAIRS" [ "$_any" -eq 1 ] diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index b04f6e15f..8ca391611 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -4,8 +4,10 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { createServer } from 'node:net'; import { observeTranscript, + sendClaudeCodeMessage, resolveTranscriptPath, resolvePendingDelivery, } from '../scripts/daemon/channels/claude-code-queue-io.mjs'; @@ -183,7 +185,7 @@ function channelFixture(t) { openStore: () => ({ close() {} }), readSnapshot: () => ({ state: 'ok', unread: true, upTo: '{}' }), send: async () => { state.sent += 1; return { state: 'sent' }; }, - observe: () => ({ state: 'absent' }), + observe: () => (state.hold ? { state: 'held' } : { state: 'absent' }), socketAlive: () => state.alive, hostPlatform: 'linux', }); @@ -228,6 +230,46 @@ test('a seat whose recorded socket is gone gets no new nudge rows until its reco await channel.stop(); }); +test('a nudge opens with the session token as an auth line and claims no permission mode', async (t) => { + const dir = temporaryDirectory(t); + const socketPath = path.join(dir, 's.sock'); + const received = new Promise((resolve) => { + const server = createServer((connection) => { + let data = ''; + connection.on('data', (chunk) => { data += chunk; }); + connection.on('end', () => { server.close(); resolve(data); }); + }); + server.listen(socketPath); + t.after(() => server.close()); + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + const result = await sendClaudeCodeMessage({ socketPath, nonce: 'n1', body: '[agmsg:n1] hello', token: 'tok-1', checkOwnership: () => ({ state: 'ok' }) }); + assert.equal(result.state, 'sent'); + const lines = (await received).trim().split('\n'); + assert.deepEqual(JSON.parse(lines[0]), { type: 'auth', token: 'tok-1' }); + assert.ok(!lines[1].includes('from-mode')); +}); + +test('a nudge the seat holds is seen from the preview that drops the brackets, and the seat is not called addressable', async (t) => { + const dir = temporaryDirectory(t); + const transcriptPath = path.join(dir, 'session.jsonl'); + writeFileSync(transcriptPath, `${JSON.stringify({ + type: 'system', + subtype: 'informational', + content: 'Held peer message \u2014 from an unidentified session; preview: \u00abagmsg:held-nonce New messages are waiting.\u00bb', + })}\n`); + assert.equal(observeTranscript(transcriptPath, 'held-nonce').state, 'held'); + + const { db, channel, state } = channelFixture(t); + await channel.pollOnce(); + state.hold = true; + await channel.pollOnce(); + const seat = db.prepare('SELECT state, reason FROM beta_claude_seat').get(); + assert.equal(seat.state, 'blocked'); + assert.match(seat.reason, /^peer_held/); + await channel.stop(); +}); + test('a conversation stored under another project directory is found by its session id, only when exactly one matches', (t) => { const config = temporaryDirectory(t); const session = '0d7c2a52-5d4c-4d0f-8c0a-6b6f5f3f9a11'; diff --git a/tests/test_agmsgd_switch.bats b/tests/test_agmsgd_switch.bats index b5f3514a9..b8f306f6e 100644 --- a/tests/test_agmsgd_switch.bats +++ b/tests/test_agmsgd_switch.bats @@ -315,3 +315,17 @@ WRAPPER assert_contains 'unread messages are preserved' [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT desired FROM daemon_intent;')" = off ] } + +@test "a seat whose Claude Code holds agmsgd's nudges is not counted as natively delivered, for that socket only" { + sqlite3 "$TEST_SKILL_DIR/run/install.db" <<'SQL' +CREATE TABLE beta_claude_seat (seat TEXT PRIMARY KEY, messaging_socket TEXT, state TEXT NOT NULL, reason TEXT NOT NULL, checked_at TEXT NOT NULL); +INSERT INTO beta_claude_seat VALUES ('["demo","alice"]', '/tmp/cc-socks/1.sock', 'blocked', 'peer_held: holding messages', '2026-10-20'); +SQL + run bash -c ". '$SCRIPTS/lib/daemon-state.sh'; agmsg_daemon_claude_seat_held demo alice /tmp/cc-socks/1.sock" + [ "$status" -eq 0 ] + run bash -c ". '$SCRIPTS/lib/daemon-state.sh'; agmsg_daemon_claude_seat_held demo alice /tmp/cc-socks/2.sock" + [ "$status" -ne 0 ] + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE beta_claude_seat SET state='addressable', reason='';" + run bash -c ". '$SCRIPTS/lib/daemon-state.sh'; agmsg_daemon_claude_seat_held demo alice /tmp/cc-socks/1.sock" + [ "$status" -ne 0 ] +} diff --git a/tests/test_role_session.bats b/tests/test_role_session.bats index cf14fa598..1f8de2c0c 100644 --- a/tests/test_role_session.bats +++ b/tests/test_role_session.bats @@ -301,4 +301,13 @@ fake_session() { agmsg_role_session_set_messaging T alice /tmp/cc-socks/222.sock /home/x/.claude sid-2 [ "$(agmsg_role_session_uuid T alice)" = sid-2 ] [ "$(agmsg_role_session_get T alice project)" = /p/q ] + + # The session's inbox token is kept for the daemon, in a record only the + # owner can read, and a restart without one does not leave the old one. + agmsg_role_session_set_messaging T alice /tmp/cc-socks/333.sock /home/x/.claude sid-2 tok-A_1.b + [ "$(agmsg_role_session_get T alice messaging_token)" = tok-A_1.b ] + [ "$(find "$RUN_DIR" -name "*alice*" | wc -l | tr -d ' ')" -ge 1 ] + [ "$(find "$RUN_DIR" -name "*alice*" -perm -077 | wc -l | tr -d ' ')" = 0 ] + agmsg_role_session_set_messaging T alice /tmp/cc-socks/444.sock /home/x/.claude sid-2 + [ -z "$(agmsg_role_session_get T alice messaging_token)" ] } From bbb904d29f72cf009d11c29462ee56e52434d04b Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 20 Oct 2026 15:09:05 +0900 Subject: [PATCH 54/97] release: 1.6.0-rc.5 (#1667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release of `1.6.0-rc.5` from `integration/agmsgd-beta`. - VERSION bump + synced `package.json` / `.claude-plugin/plugin.json` - CHANGELOG.md deliberately untouched — prerelease notes come from `release.yml`'s own `git-cliff --latest` Changes since rc.4: - Claude Code notices: authenticate with the session's messaging token and stop claiming a permission mode, so sessions in `auto` and similar modes receive them; detect a held notice and fall back to the Monitor (#1666) - Confirm a Claude Code notice from the transcript position recorded before it was sent, so a long session no longer stalls later notices; pending rows left by rc.4 are recovered (#1662, #1665, fixes #1644) - Keep agmsgd running through a busy database and log any other poll failure (#1651) - Stop queueing notices for a seat whose recorded socket is gone (#1650) - Codex: record the profile the TUI runs in, and name a missing rollout (#1653) - Rebind a Codex seat after `/clear`, proven from the seat's own record (#1659, fixes #1468) Merging this and pushing `v1.6.0-rc.5` triggers the publish pipeline (npm + GitHub Release). --- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 13313ae75..9871759b2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agmsg", "description": "Cross-agent messaging via SQLite. Send messages between CLI AI agents. No daemon, no network.", - "version": "1.6.0-rc.4", + "version": "1.6.0-rc.5", "author": { "name": "fujibee" }, diff --git a/VERSION b/VERSION index fee897748..5145ef537 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0-rc.4 +1.6.0-rc.5 diff --git a/package.json b/package.json index 9aac7eb17..59790595b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.6.0-rc.4", + "version": "1.6.0-rc.5", "description": "Cross-agent messaging via SQLite for CLI AI agents (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Antigravity, OpenCode). The npm package is a thin bootstrapper that fetches and runs the canonical bash installer at https://github.com/fujibee/agmsg.", "bin": { "agmsg": "bin/agmsg.js" From 5c6a31ed6cab480e6fd2a6258c702b6f64fe8303 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 03:59:04 +0900 Subject: [PATCH 55/97] [skip ci] daemon: strip CRLF from sqlite3's answers in the enable/start path (#1668) * [skip ci] daemon: strip CRLF from sqlite3's answers in the enable/start path Windows' sqlite3.exe ends every line of its output with \r\n, not \n (scripts/lib/storage.sh's agmsg_sqlite() already documents and strips this for the storage layer). daemon.sh's own install-operation-lock coprocess, and agmsgd-launch.sh's direct sqlite3 calls, compare that output literally and never go through agmsg_sqlite, so on Windows every one of those comparisons silently failed: - _with_op_lock_run's coproc exchange (the lock acquisition itself) treated every reply ("LOCKED", the generation marker/row, "APPLIED") as a mismatch, so `agmsg daemon enable` always failed with "could not acquire the install operation lock". - Every other literal sqlite3 command-substitution comparison in daemon.sh (state, desired, op_gen, socket, ...) had the same gap -- routed those through agmsg_sqlite(), which already strips the \r\n consistently, instead of duplicating the fix at each call site. - agmsgd-launch.sh deliberately sources no lib (its bytes are pinned by BOOTSTRAP_VERSION), so its own sqlite3 reads (desired, op_gen, node_path, the bootstrap-version check) strip the trailing \r directly with the same `${var%$'\r'}` idiom daemon.sh's own _lock_query already used. Added a test that reproduces this with a PATH-shimmed sqlite3 stub appending \r before every line (the same technique test_sqlite_crlf.bats already uses for storage.sh), combined with the existing fake-Windows Task Scheduler fixture so `agmsg daemon enable` actually exercises the real coproc branch. Confirmed red against the pre-fix code (temporarily set aside via a throwaway git stash, never committed) and green after. * [skip ci] daemon: capture before splitting, so IFS never reaches agmsg_sqlite (#1668 review) _install_generation and the disable path's "an older record" check both wrote `IFS='|' read -r ... < <(agmsg_sqlite ...)`. A temporary assignment on a simple command's line is visible to every word expansion on that same line -- including a process substitution's own subshell -- on some bash builds (reproduced on this machine's /bin/bash 3.2.57; not on the newer bash first on PATH here, which is why the existing CRLF test did not already catch it). storage.sh's agmsg_sqlite() relies on the shell's default IFS to word-split $_AGMSG_ESCAPE_FLAG ('-escape off') into two argv for sqlite3; under IFS='|' that stayed one argument, and `sqlite3 '-escape off' -cmd ...` failed outright on any sqlite3 build that supports -escape (3.50+). _install_generation's manifest read is called before every operation lock acquisition, so this failed enable before it ever reached the lock. Fixed both call sites by capturing the command substitution into a plain variable on its own line first, then splitting that already- captured string with `IFS='|' read ... <<<"$var"` -- nothing left for the field-splitting assignment to reach into a subshell. Added a test that reproduces this directly against the real /bin/bash when its process substitution is confirmed (by a small in-test probe) to still inherit IFS this way; skipped where it does not, including when there is no /bin/bash to test against. Confirmed red against a scratch copy of the pre-fix file (never git stash -- shared across every worktree on this machine) and green after restoring the fix. --- scripts/daemon.sh | 71 +++++++++++++++--------- scripts/daemon/agmsgd-launch.sh | 8 ++- tests/test_agmsgd_daemon_sh.bats | 95 ++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 28 deletions(-) diff --git a/scripts/daemon.sh b/scripts/daemon.sh index f5c02294e..63e83c2ab 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -83,10 +83,21 @@ _ensure_install_db_schema() { # PR 2 says it lives while still writing to the file that actually holds # the data, in one real cross-file transaction. _install_generation() { - local install_id manifest_path_sql manifest_id manifest_gen - install_id="$(sqlite3 "$INSTALL_DB" "SELECT install_id FROM meta;" 2>/dev/null)" || return 1 + local install_id manifest_path_sql manifest_id manifest_gen manifest_pair + install_id="$(agmsg_sqlite "$INSTALL_DB" "SELECT install_id FROM meta;" 2>/dev/null)" || return 1 manifest_path_sql="$(agmsg_sql_readfile_path "$SKILL_DIR/run/install-manifest.json")" - IFS='|' read -r manifest_id manifest_gen < <(sqlite3 :memory: "SELECT json_extract(CAST(readfile('$manifest_path_sql') AS TEXT), '\$.install_id') || '|' || json_extract(CAST(readfile('$manifest_path_sql') AS TEXT), '\$.gen');" 2>/dev/null) || return 1 + # Captured on its own line, THEN split -- not `IFS='|' read ... < <(agmsg_sqlite ...)` + # (review on #1668/#1631): a temporary assignment on a simple command's own + # line is visible to every word expansion on that same line, including a + # process substitution's subshell, so IFS='|' was still in effect inside + # agmsg_sqlite() there. storage.sh's agmsg_sqlite() relies on the DEFAULT + # IFS to word-split $_AGMSG_ESCAPE_FLAG ('-escape off') into two argv for + # sqlite3; under IFS='|' that string stayed one argument, and `sqlite3 + # '-escape off' -cmd ...` failed outright on any sqlite3 build that + # supports -escape (3.50+) -- enable never got past this read on such a + # build, let alone to lock acquisition. + manifest_pair="$(agmsg_sqlite :memory: "SELECT json_extract(CAST(readfile('$manifest_path_sql') AS TEXT), '\$.install_id') || '|' || json_extract(CAST(readfile('$manifest_path_sql') AS TEXT), '\$.gen');" 2>/dev/null)" || return 1 + IFS='|' read -r manifest_id manifest_gen <<<"$manifest_pair" [ -n "$install_id" ] && [ "$manifest_id" = "$install_id" ] && [ -n "$manifest_gen" ] || return 1 printf '%s:%s' "$install_id" "$manifest_gen" } @@ -138,7 +149,7 @@ _with_op_lock_run() { fi attach_path="$(agmsg_sql_readfile_path "$INSTALL_DB")" printf '%s\n' '.bail on' "ATTACH DATABASE '$attach_path' AS installdb;" 'BEGIN EXCLUSIVE;' '.print LOCKED' >&8 - if ! IFS= read -r -t 10 ready <&9 || [ "$ready" != "LOCKED" ]; then + if ! IFS= read -r -t 10 ready <&9 || [ "${ready%$'\r'}" != "LOCKED" ]; then printf 'ROLLBACK;\n.quit\n' >&8 2>/dev/null || true exec 8>&- exec 9<&- @@ -153,8 +164,8 @@ _with_op_lock_run() { printf '%s\n' "SELECT CASE WHEN json_extract(CAST(readfile('$manifest_sql') AS TEXT), '\$.install_id') = (SELECT install_id FROM installdb.meta) THEN (SELECT install_id FROM installdb.meta) || ':' || json_extract(CAST(readfile('$manifest_sql') AS TEXT), '\$.gen') ELSE '' END;" '.print GENERATION_END' >&8 IFS= read -r -t 10 generation_row <&9 || generation_row="" IFS= read -r -t 10 generation_marker <&9 || generation_marker="" - current_generation="$generation_row" - if [ "$generation_marker" != "GENERATION_END" ] || [ "$current_generation" != "$expected_generation" ]; then + current_generation="${generation_row%$'\r'}" + if [ "${generation_marker%$'\r'}" != "GENERATION_END" ] || [ "$current_generation" != "$expected_generation" ]; then printf 'ROLLBACK;\n.quit\n' >&8 exec 8>&- exec 9<&- @@ -169,6 +180,7 @@ _with_op_lock_run() { local has_codex_path printf '%s\n' "SELECT count(*) FROM pragma_table_info('meta', 'installdb') WHERE name='codex_path';" >&8 IFS= read -r -t 10 has_codex_path <&9 || has_codex_path="" + has_codex_path="${has_codex_path%$'\r'}" if [ "$has_codex_path" = 0 ]; then printf '%s\n' 'ALTER TABLE installdb.meta ADD COLUMN codex_path TEXT;' >&8 elif [ "$has_codex_path" != 1 ]; then @@ -182,7 +194,7 @@ _with_op_lock_run() { return 1 fi printf '%s\n.print APPLIED\n' "$statements" >&8 - if ! IFS= read -r -t 10 applied <&9 || [ "$applied" != "APPLIED" ]; then + if ! IFS= read -r -t 10 applied <&9 || [ "${applied%$'\r'}" != "APPLIED" ]; then printf 'ROLLBACK;\n.quit\n' >&8 2>/dev/null || true exec 8>&- exec 9<&- @@ -219,7 +231,7 @@ _with_op_lock_run() { _unit_id() { local install_id="${AGMSGD_LOCK_INSTALL_ID:-}" if [ -z "$install_id" ]; then - install_id="$(sqlite3 "$INSTALL_DB" "SELECT install_id FROM meta;" 2>/dev/null)" + install_id="$(agmsg_sqlite "$INSTALL_DB" "SELECT install_id FROM meta;" 2>/dev/null)" fi # shasum is absent from Git Bash; agmsg_sha256 falls back to sha256sum. printf '%s:%s' "$SKILL_DIR" "$install_id" | agmsg_sha256 | cut -c1-12 @@ -510,8 +522,8 @@ _wait_for_ready() { local target_op_gen="$1" waited=0 while [ "$waited" -lt 100 ]; do local state op_gen - state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" - op_gen="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;" 2>/dev/null || true)" + state="$(agmsg_sqlite "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + op_gen="$(agmsg_sqlite "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;" 2>/dev/null || true)" if [ "$state" = "ready" ] && [ "$op_gen" = "$target_op_gen" ]; then return 0 fi @@ -535,7 +547,7 @@ cmd_start() { esac _ensure_install_db_schema local state - state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;")" + state="$(agmsg_sqlite "$INSTALL_DB" "SELECT state FROM daemon_owner;")" if [ "$state" = "ready" ] && node "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" >/dev/null 2>&1; then echo "agmsg daemon start: already running" agmsg_daemon_seat_report start @@ -548,7 +560,7 @@ cmd_start() { # one. Find it here the way enable does, and stop before touching the # intent when there is none. local recorded_node found_node found_version record_node_sql="" - recorded_node="$(sqlite3 "$INSTALL_DB" "SELECT node_path FROM meta;" 2>/dev/null || true)" + recorded_node="$(agmsg_sqlite "$INSTALL_DB" "SELECT node_path FROM meta;" 2>/dev/null || true)" if [ -z "$recorded_node" ] || [ ! -x "$recorded_node" ]; then found_node="$(_resolve_node node)" || { echo "agmsg daemon start: no usable Node found (need >= 22.13.0 with node:sqlite), so agmsgd cannot start. Nothing was changed. Install Node from https://nodejs.org/en/download, then run agmsg daemon start." >&2 @@ -571,9 +583,9 @@ cmd_start() { local start_where="" [ -z "$only_gen" ] || start_where=" WHERE desired = 'on' AND op_gen = $only_gen" _with_op_lock "$record_node_sql $codex_fill UPDATE daemon_intent SET desired = 'on', op_gen = op_gen + 1, set_by = 'start', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')$start_where;" - new_op_gen="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;")" + new_op_gen="$(agmsg_sqlite "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;")" # The guarded UPDATE applied only if it left "on" at op_gen+1 written by start. - if [ -n "$only_gen" ] && [ "$(sqlite3 "$INSTALL_DB" "SELECT desired || '|' || op_gen || '|' || set_by FROM daemon_intent;")" != "on|$((only_gen + 1))|start" ]; then + if [ -n "$only_gen" ] && [ "$(agmsg_sqlite "$INSTALL_DB" "SELECT desired || '|' || op_gen || '|' || set_by FROM daemon_intent;")" != "on|$((only_gen + 1))|start" ]; then echo "agmsg daemon start: agmsgd was turned off or changed while waiting; left as it is now" return 0 fi @@ -629,19 +641,19 @@ cmd_stop() { _ensure_install_db_schema # Stop takes no operation lock. local desired - desired="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || true)" + desired="$(agmsg_sqlite "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || true)" if [ "$desired" != "on" ]; then echo "agmsg daemon stop: already stopped" return 0 fi - sqlite3 "$INSTALL_DB" "UPDATE daemon_intent SET desired = 'off', op_gen = op_gen + 1, set_by = 'stop', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" + agmsg_sqlite "$INSTALL_DB" "UPDATE daemon_intent SET desired = 'off', op_gen = op_gen + 1, set_by = 'stop', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" local socket - socket="$(sqlite3 "$INSTALL_DB" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null || true)" + socket="$(agmsg_sqlite "$INSTALL_DB" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null || true)" _request_stop_over_socket "$socket" local waited=0 while [ "$waited" -lt 100 ]; do local state - state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + state="$(agmsg_sqlite "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" [ "$state" = "none" ] && { echo "agmsg daemon stop: stopped"; return 0; } waited=$((waited + 1)) sleep 0.1 @@ -664,7 +676,7 @@ cmd_status() { # Only a database that answers and has no daemon_owner table is "not in use # yet"; a query that fails (corrupt, locked) falls through to the normal # read below and is reported as unknown. - if owner_tables="$(sqlite3 "$INSTALL_DB" "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='daemon_owner';" 2>/dev/null)" && [ "$owner_tables" = 0 ] && [ "$json" != true ]; then + if owner_tables="$(agmsg_sqlite "$INSTALL_DB" "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='daemon_owner';" 2>/dev/null)" && [ "$owner_tables" = 0 ] && [ "$json" != true ]; then echo "agmsg daemon status: agmsgd is not in use yet (turn it on with 'agmsg daemon enable')" return 0 fi @@ -694,14 +706,14 @@ cmd_status() { health_rc=1 fi if [ "$json" = true ]; then - sqlite3 "$INSTALL_DB" "SELECT json_object('state',state,'intent',(SELECT desired FROM daemon_intent),'health','${AGMSGD_HEALTH:-unknown}','detail','no usable Node') FROM daemon_owner;" + agmsg_sqlite "$INSTALL_DB" "SELECT json_object('state',state,'intent',(SELECT desired FROM daemon_intent),'health','${AGMSGD_HEALTH:-unknown}','detail','no usable Node') FROM daemon_owner;" return "$health_rc" fi # K14 fallback: no usable Node at all -- a minimal, honest read straight # from install.db rather than the fuller status.mjs decision text. local state desired - state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || echo "unknown")" - desired="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || echo "unknown")" + state="$(agmsg_sqlite "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || echo "unknown")" + desired="$(agmsg_sqlite "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || echo "unknown")" echo "agmsg daemon status: state=$state intent=$desired (no usable Node -- a fuller check needs 'agmsg daemon enable')" agmsg_daemon_seat_report status _path_hint enable @@ -745,7 +757,7 @@ cmd_enable() { _wait_owner_none() { local waited=0 state while [ "$waited" -lt 100 ]; do - state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + state="$(agmsg_sqlite "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" [ "$state" = "none" ] && break waited=$((waited + 1)) sleep 0.1 @@ -803,7 +815,7 @@ cmd_disable() { local DISABLE_PREV_INTENT="" DISABLE_GEN="" DISABLE_SUPERSEDED=0 _with_op_lock "" _disable_commit_intent || return 1 local socket state - socket="$(sqlite3 "$INSTALL_DB" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null || true)" + socket="$(agmsg_sqlite "$INSTALL_DB" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null || true)" _request_stop_over_socket "$socket" state="$(_wait_owner_none)" if ! _with_op_lock "" _disable_unregister_if_current; then @@ -822,8 +834,13 @@ cmd_disable() { # boot. Disable is the deliberate way out of it: the daemon was asked to # stop above, so drop the unverifiable record instead of leaving it to # block every later start. - local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON seen_gen seen_pid seen_boot - IFS='|' read -r seen_gen seen_pid seen_boot < <(sqlite3 "$INSTALL_DB" "SELECT gen, COALESCE(executor_pid,0), COALESCE(executor_boot_id,'') FROM daemon_owner;" 2>/dev/null || true) + local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON seen_gen seen_pid seen_boot seen_triple + # Captured first, split after (same reason as _install_generation above, + # #1668 review): IFS='|' on this line would otherwise still be in effect + # inside agmsg_sqlite()'s own process-substitution subshell and break its + # `-escape off` argv splitting. + seen_triple="$(agmsg_sqlite "$INSTALL_DB" "SELECT gen, COALESCE(executor_pid,0), COALESCE(executor_boot_id,'') FROM daemon_owner;" 2>/dev/null || true)" + IFS='|' read -r seen_gen seen_pid seen_boot <<<"$seen_triple" agmsg_daemon_read_state # Only the exact record that was just judged unverifiable may be dropped: # a daemon started since then has a different gen, and is left alone. @@ -832,7 +849,7 @@ cmd_disable() { *) if [ "${AGMSGD_HEALTH:-}" = unverifiable ]; then _with_op_lock "UPDATE daemon_owner SET state = 'none', socket = NULL, last_end_reason = 'record from an older version discarded by disable', last_end_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE gen = $seen_gen AND executor_pid = $seen_pid AND executor_boot_id = '$seen_boot' AND state = 'ready';" - state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + state="$(agmsg_sqlite "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" fi ;; esac fi diff --git a/scripts/daemon/agmsgd-launch.sh b/scripts/daemon/agmsgd-launch.sh index c322cd36e..194a8c008 100755 --- a/scripts/daemon/agmsgd-launch.sh +++ b/scripts/daemon/agmsgd-launch.sh @@ -65,7 +65,10 @@ _agmsgd_launch_check_bootstrap_version() { WITH raw(json) AS (SELECT CAST(readfile('$(printf '%s' "$manifest_path" | sed "s/'/''/g")') AS TEXT)) SELECT json_extract(json, '\$.bootstrap_version') FROM raw; " 2>/dev/null)" - [ "$recorded" = "$BOOTSTRAP_VERSION" ] + # Windows' sqlite3.exe ends its output \r\n, not \n (scripts/lib/storage.sh's + # agmsg_sqlite() has the measurement); this file sources no lib and talks to + # sqlite3 directly, so every literal comparison below strips it itself. + [ "${recorded%$'\r'}" = "$BOOTSTRAP_VERSION" ] } # 1. install.db must be readable, or we must not assume `on`. @@ -77,12 +80,14 @@ DESIRED="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/nul _agmsgd_launch_record_attempt "install.db could not be read" exit 0 } +DESIRED="${DESIRED%$'\r'}" # 2. Not `on` -> nothing to do, silently. if [ "$DESIRED" != "on" ]; then exit 0 fi OP_GEN="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;" 2>/dev/null)" +OP_GEN="${OP_GEN%$'\r'}" # 3. Completion record state. if [ -f "$MANIFEST" ]; then @@ -108,6 +113,7 @@ _agmsgd_launch_check_bootstrap_version "$MANIFEST" || { # execution (version + node:sqlite loads), not by trusting the recorded # path/version strings. NODE_PATH="$(sqlite3 "$INSTALL_DB" "SELECT node_path FROM meta;" 2>/dev/null)" +NODE_PATH="${NODE_PATH%$'\r'}" if [ -z "$NODE_PATH" ] || [ ! -x "$NODE_PATH" ]; then _agmsgd_launch_record_attempt "no usable Node recorded (run 'agmsg daemon enable' again)" exit 0 diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index a8050e8a1..4562d4ccf 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -546,6 +546,101 @@ EOF [ ! -f "$TEST_SKILL_DIR/run/agmsgd-$(_unit_id).xml" ] } +# A wrapper named `sqlite3`, placed ahead of the real one on PATH, that +# behaves exactly like the real binary except every line of its stdout gets a +# synthetic \r appended right before the \n -- simulating the \r\n Windows' +# sqlite3.exe emits at each row boundary (measured: 3.53.4). Same technique as +# test_sqlite_crlf.bats's `_stub_sqlite3_crlf`, duplicated locally rather than +# shared: bats only loads `test_helper` automatically, not another test file. +# +# The real binary's path is resolved HERE, before this directory is ever +# prepended to PATH, so the wrapper's exec target does not resolve back to +# itself once PATH includes this directory. +_stub_sqlite3_crlf() { + local dir="$TEST_SKILL_DIR/crlfbin" real + real="$(command -v sqlite3)" + mkdir -p "$dir" + cat > "$dir/sqlite3" < "$AGMSGD_FAKE_GIT_ROOT/bin/bash.exe" + + # crlf_stub ahead of fake-windows ahead of the real PATH: `_os` still sees + # the fake-windows `uname` (routing _with_op_lock_run into the coproc + # branch #1631/#1667 added for native Windows sqlite3), while every + # `sqlite3` call -- including the coproc's own replies on fd 9 -- goes + # through the CRLF stub instead of the real, LF-only Linux binary. + PATH="$crlf_stub:$TEST_SKILL_DIR/fake-windows:$PATH" AGMSGD_SCHTASKS="$TEST_SKILL_DIR/fake-windows/schtasks" AGMSGD_FAKE_TASK_XML="$capture" AGMSGD_FAKE_TASK_MARKER="$marker" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ + _run_with_deadline 15 bash "$DAEMON" enable + [ "$status" -eq 0 ] + ! echo "$output" | grep -q "could not acquire the install operation lock" || false + [ -f "$marker" ] + [ "$(PATH="$crlf_stub:$PATH" sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT desired FROM daemon_intent;" | tr -d '\r')" = "on" ] + + PATH="$crlf_stub:$TEST_SKILL_DIR/fake-windows:$PATH" AGMSGD_SCHTASKS="$TEST_SKILL_DIR/fake-windows/schtasks" AGMSGD_FAKE_TASK_MARKER="$marker" \ + _run_with_deadline 15 bash "$DAEMON" disable + [ "$status" -eq 0 ] + [ ! -f "$marker" ] +} + +# `/bin/bash` on this runner's macOS (Apple's stock, 3.2.57) still inherits a +# `read`'s temporary `IFS='|'` into a process substitution's subshell, while +# the bash on PATH here (homebrew, 5.x) does not -- the exact fork review +# flagged on #1668 (_install_generation's `IFS='|' read ... < <(agmsg_sqlite +# ...)`). daemon.sh's `#!/usr/bin/env bash` resolves to whichever bash is +# first on PATH, so this only reproduces under the shell that actually has +# the old behavior; skipped where /bin/bash is unavailable or already fixed +# upstream (a future macOS could ship a non-vulnerable /bin/bash). +@test "daemon.sh enable: _install_generation's manifest read survives a bash whose process substitution inherits IFS" { + [ "$(uname -s)" = "Darwin" ] || skip "this test's fake stands in for launchd specifically" + [ -x /bin/bash ] || skip "no /bin/bash on this runner to exercise the old IFS-inheriting behavior" + /bin/bash -c ' + f() { case "$IFS" in *"|"*) echo inherited ;; *) echo not-inherited ;; esac; } + IFS="|" read -r seen < <(f) + [ "$seen" = inherited ] + ' || skip "/bin/bash here does not inherit IFS into process substitution; nothing to reproduce" + _seed_install_db + _write_completion_record + local fake_launchctl marker events + fake_launchctl="$(_fake_launchctl)" + marker="$TEST_SKILL_DIR/fake-launchd/registered" + events="$TEST_SKILL_DIR/fake-launchd/events" + + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ + run /bin/bash "$DAEMON" enable + [ "$status" -eq 0 ] + ! echo "$output" | grep -qi "could not read this install's record" || false + [ -f "$marker" ] + + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" \ + run /bin/bash "$DAEMON" disable + [ "$status" -eq 0 ] + [ ! -f "$marker" ] +} + @test "daemon.sh enable/disable: without a systemd user manager enable explains and changes nothing, disable still works" { _seed_install_db _write_completion_record From c6a10eed9ebbbed9ba79e93367143bcc9444fb24 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 05:47:15 +0900 Subject: [PATCH 56/97] [skip ci] install manifest: don't reject a Windows-shaped TMPDIR probe path (#1670) _agmsg_install_manifest_hash_batch rejected its own mktemp probe if the path contained any character outside A-Za-z0-9._/- , including the colon in a drive-letter TMPDIR (e.g. a Claude-Code-launched Git Bash's real "C:/Users/.../Temp"). That check was never load-bearing for the probe: unlike the file names batched alongside it, the probe path is only ever used via I/O redirection, never passed to the hashing tool as an argument whose own output line would need to be parsed back unambiguously. Dropping it lets install.sh batch-hash normally instead of failing to write the completion record. --- scripts/lib/install-manifest.sh | 9 ++++++++- tests/test_install.bats | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/lib/install-manifest.sh b/scripts/lib/install-manifest.sh index f41afef88..4e1f3e93b 100644 --- a/scripts/lib/install-manifest.sh +++ b/scripts/lib/install-manifest.sh @@ -71,8 +71,15 @@ _agmsg_install_manifest_hash_batch() { # ... return 1 fi + # mktemp's own result is trusted as-is: unlike $line/$rel above, this path is + # never fed to the hashing tool as an argument -- it only ever reaches + # "$probe" via I/O redirection, never through the tool's own output lines -- + # so no character in it can make $output ambiguous to parse below. $TMPDIR + # under a Node-launched shell on Windows (e.g. Claude Code) is commonly an + # absolute drive-letter path with a colon ("C:/Users/.../Temp"); rejecting + # that here silently dropped every such install onto the agmsg_sha256 + # fallback, for a character class this path's use never needed. probe="$(mktemp)" || return 1 - case "$probe" in *[!A-Za-z0-9._/-]*) rm -f "$probe"; return 1 ;; esac if ! printf '%s' probe > "$probe"; then rm -f "$probe" return 1 diff --git a/tests/test_install.bats b/tests/test_install.bats index abdb0b78a..20fc429de 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -2321,6 +2321,32 @@ CYG [ ! -e "$bad_manifest" ] } +@test "install manifest batch-hashes under a Windows-shaped TMPDIR (drive letter, colon) (#1670)" { + # Bare `mktemp` on macOS prefers _CS_DARWIN_USER_TEMP_DIR over $TMPDIR, so + # setting TMPDIR alone can't reproduce this here -- stub mktemp itself to + # force the probe into a directory named like a Windows drive-letter path + # (e.g. a Claude-Code-launched Git Bash's real $TMPDIR, "C:/Users/.../Temp"). + local scripts="$FAKE_HOME/win-tmpdir-scripts" manifest="$FAKE_HOME/win-tmpdir-manifest.json" + local fake_bin="$FAKE_HOME/fake-bin" winlike="$FAKE_HOME/C:/Users/win/AppData/Local/Temp" + mkdir -p "$scripts" "$fake_bin" "$winlike" + printf 'safe file\n' > "$scripts/safe.sh" + local real_mktemp + real_mktemp="$(command -v mktemp)" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + "if [ \"\$#\" -eq 0 ]; then exec \"$real_mktemp\" -p \"$winlike\"; fi" \ + "exec \"$real_mktemp\" \"\$@\"" > "$fake_bin/mktemp" + chmod +x "$fake_bin/mktemp" + + run env PATH="$fake_bin:$PATH" bash -c '. "$1/scripts/lib/install-manifest.sh"; agmsg_install_manifest_write "$2" "$3" 1.5.1 test-install 1 1' \ + _ "$REPO_ROOT" "$scripts" "$manifest" + [ "$status" -eq 0 ] + local recorded_digest actual_digest + recorded_digest="$(sqlite3 :memory: "SELECT json_extract(value, '\$.digest') FROM json_each(json_extract(readfile('$(rf "$manifest")'), '\$.files')) WHERE json_extract(value, '\$.path')='scripts/safe.sh';")" + actual_digest="$(bash -c 'source "$1/scripts/lib/hash.sh"; agmsg_sha256 < "$2"' _ "$REPO_ROOT" "$scripts/safe.sh")" + [ "$recorded_digest" = "$actual_digest" ] +} + @test "install holds the operation lock while rendering the shared skill" { local bin="$FAKE_HOME/bin" entered="$FAKE_HOME/render-entered" release="$FAKE_HOME/render-release" local awk_real install_pid install_rc blocked=0 i From bbac0d6860ae4d5ba963a4a8b70ccb0e1b053c6f Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 08:01:12 +0900 Subject: [PATCH 57/97] Name bypass mode in nudges to bypass-mode Claude Code seats, retry a held nudge once the other way (#1671) * Name bypass mode in nudges to bypass-mode Claude Code seats, and send a held nudge once the other way [skip ci] A bypass-mode session holds a nudge that names no sender mode, and the session token does not change that when the sender's process is readable. The nudge now carries from-mode=bypass when the seat's conversation last recorded permissionMode bypassPermissions, and no claim for any other mode or when it cannot be read. A nudge the seat sets aside is sent again once the other way (with or without the claim), so a seat switched between modes mid-session is still reached; after both ways were tried the nudge stays pending and the seat is recorded as peer_held. * Keep the ways a held nudge was sent on its queue row so a daemon restart adds no sends [skip ci] --- .../daemon/channels/claude-code-queue-io.mjs | 42 ++++++++++- .../channels/claude-code-queue-store.mjs | 6 +- scripts/daemon/channels/claude-code-queue.mjs | 48 +++++++++--- tests/agmsgd_claude_code_queue.test.mjs | 74 ++++++++++++++++++- 4 files changed, 150 insertions(+), 20 deletions(-) diff --git a/scripts/daemon/channels/claude-code-queue-io.mjs b/scripts/daemon/channels/claude-code-queue-io.mjs index 99f706816..fc1c45f70 100644 --- a/scripts/daemon/channels/claude-code-queue-io.mjs +++ b/scripts/daemon/channels/claude-code-queue-io.mjs @@ -100,6 +100,7 @@ export async function sendClaudeCodeMessage({ nonce, body, token = "", + fromMode = "", timeoutMs = SEND_TIMEOUT_MS, checkOwnership = checkSocketOwnership, connect = createConnection, @@ -113,15 +114,17 @@ export async function sendClaudeCodeMessage({ type: "user", message: { role: "user", - content: `\n${body}\n`, + content: `\n${body}\n`, }, priority: "next", from: "agmsgd", }; // The session's own inbox token goes first, as its documentation describes // ({"type":"auth","token":...} as the first line of the connection), so the - // message is judged as one posted to its own session. The sender claims no - // permission mode: it is not a session and has none. + // message is judged as one posted to its own session. A permission mode is + // claimed only when the caller read the receiving seat to be in bypass mode + // (a bypass receiver holds anything that names no mode, and a prompting one + // holds a mode that is not its own). const auth = token ? `${JSON.stringify({ type: "auth", token })}\n` : ""; const line = `${auth}${JSON.stringify(payload)}\n`; @@ -150,6 +153,39 @@ export async function sendClaudeCodeMessage({ }); } +// The permission mode the seat's conversation last recorded: Claude Code +// writes "permissionMode" on every user row. Only the tail is read; "" when the +// transcript is unreadable or carries none. +export function latestPermissionMode(transcriptPath, tailBytes = 1024 * 1024, io = { readSync }) { + let fd; + try { + const stat = statSync(transcriptPath); + if (!stat.isFile()) return ""; + fd = openSync(transcriptPath, "r"); + const start = Math.max(0, stat.size - tailBytes); + const buf = Buffer.alloc(stat.size - start); + let got = 0; + while (got < buf.length) { + const read = io.readSync(fd, buf, got, buf.length - got, start + got); + if (read <= 0) break; + got += read; + } + const lines = buf.subarray(0, got).toString("utf8").split("\n"); + for (let i = lines.length - 1; i >= 0; i -= 1) { + if (!lines[i].includes('"permissionMode"')) continue; + try { + const mode = JSON.parse(lines[i])?.permissionMode; + if (typeof mode === "string" && mode) return mode; + } catch { /* a cut first line or a half-written last one */ } + } + return ""; + } catch { + return ""; + } finally { + if (fd !== undefined) { try { closeSync(fd); } catch { /* read only */ } } + } +} + // The byte length of the transcript right now, recorded just before a nudge is // sent so the later check reads only what was written after it. null when the // file cannot be measured: that nudge is checked from the start. diff --git a/scripts/daemon/channels/claude-code-queue-store.mjs b/scripts/daemon/channels/claude-code-queue-store.mjs index 62fa9a71f..61de0f130 100644 --- a/scripts/daemon/channels/claude-code-queue-store.mjs +++ b/scripts/daemon/channels/claude-code-queue-store.mjs @@ -18,7 +18,8 @@ CREATE TABLE IF NOT EXISTS beta_claude_queue ( nonce TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending', 'confirmed', 'expired')), created_at TEXT NOT NULL, - transcript_pos INTEGER + transcript_pos INTEGER, + tried_modes TEXT NOT NULL DEFAULT '' ); CREATE UNIQUE INDEX IF NOT EXISTS beta_claude_queue_one_pending ON beta_claude_queue(seat) WHERE state = 'pending'; @@ -42,6 +43,9 @@ export function ensureClaudeCodeChannelSchema(db) { if (!columns.some((column) => column.name === "transcript_pos")) { db.exec("ALTER TABLE beta_claude_queue ADD COLUMN transcript_pos INTEGER"); } + if (!columns.some((column) => column.name === "tried_modes")) { + db.exec("ALTER TABLE beta_claude_queue ADD COLUMN tried_modes TEXT NOT NULL DEFAULT ''"); + } db.exec("COMMIT;"); } catch (error) { try { db.exec("ROLLBACK;"); } catch { /* preserve the schema error */ } diff --git a/scripts/daemon/channels/claude-code-queue.mjs b/scripts/daemon/channels/claude-code-queue.mjs index cd1a38570..c964d5738 100644 --- a/scripts/daemon/channels/claude-code-queue.mjs +++ b/scripts/daemon/channels/claude-code-queue.mjs @@ -33,6 +33,7 @@ import { resolveTranscriptPath, resolvePendingDelivery, sendClaudeCodeMessage, + latestPermissionMode, } from "./claude-code-queue-io.mjs"; const SEND_TIMEOUT_MS = 5_000; @@ -57,7 +58,7 @@ function registrationProjects(teamConfig, agent) { function readPending(db, seat) { return db.prepare(` - SELECT id, messaging_socket, transcript_path, transcript_pos, up_to, nonce, state, created_at + SELECT id, messaging_socket, transcript_path, transcript_pos, up_to, nonce, state, created_at, tried_modes FROM beta_claude_queue WHERE seat = ? AND state = 'pending' ORDER BY id DESC LIMIT 1 `).get(seat); } @@ -103,7 +104,7 @@ function setQueueState(db, id, state) { }); } -function createPending(db, { seat, messagingSocket, transcriptPath, transcriptPos, upTo, nonce, expectedOpGen }, now) { +function createPending(db, { seat, messagingSocket, transcriptPath, transcriptPos, upTo, nonce, triedModes, expectedOpGen }, now) { let rowId; withImmediateTransaction(db, () => { const intent = db.prepare("SELECT desired, op_gen FROM daemon_intent").get(); @@ -113,9 +114,9 @@ function createPending(db, { seat, messagingSocket, transcriptPath, transcriptPo const current = db.prepare("SELECT id FROM beta_claude_queue WHERE seat = ? AND state = 'pending'").get(seat); if (current) throw new Error("seat_already_pending"); const result = db.prepare(` - INSERT INTO beta_claude_queue (seat, messaging_socket, transcript_path, transcript_pos, up_to, nonce, state, created_at) - VALUES (?, ?, ?, ?, ?, ?, 'pending', ?) - `).run(seat, messagingSocket, transcriptPath, transcriptPos, upTo, nonce, new Date(now()).toISOString()); + INSERT INTO beta_claude_queue (seat, messaging_socket, transcript_path, transcript_pos, up_to, nonce, state, created_at, tried_modes) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) + `).run(seat, messagingSocket, transcriptPath, transcriptPos, upTo, nonce, new Date(now()).toISOString(), triedModes.join(",")); rowId = Number(result.lastInsertRowid); }); return rowId; @@ -149,6 +150,7 @@ export function createClaudeCodeQueueChannel({ observe = observeTranscript, socketAlive = checkSocketOwnership, hostPlatform = process.platform, + readMode = latestPermissionMode, }) { let stopped = false; let activePoll = null; @@ -157,10 +159,13 @@ export function createClaudeCodeQueueChannel({ const { team, agent, teamConfig, registered = true } = registration; const seat = seatKey(team, agent); const pending = readPending(db, seat); + let retryMode = ""; + let carriedModes = []; // At most one nudge is outstanding per seat, and an outstanding one is not // re-sent on a timer: Claude Code holds a message received mid-work until // the work pauses, so a long wait is normal, not a loss. It is re-sent only - // when the thing it was sent to is gone (see below). + // when the thing it was sent to is gone, or when the seat set it aside and + // the other way of sending has not been tried (see below). if (pending) { const observation = pendingObservation(pending, observe); if (observation.state === "confirmed") { @@ -189,11 +194,27 @@ export function createClaudeCodeQueueChannel({ const recordMoved = record && ( record.messaging_socket !== pending.messaging_socket || (transcriptNow && transcriptNow !== pending.transcript_path)); - if (!socketGone && !recordMoved) { - saveSeat(db, { seat, messagingSocket: pending.messaging_socket, ...observedSeat(socketCheck.state === "ok" ? observation.reason : socketCheck.reason) }, now); - return; + if (!socketGone && !recordMoved && observation.reason === "peer_held" && socketCheck.state === "ok") { + // The seat's Claude Code set the nudge aside. Whatever mode it was sent + // under is not accepted there (a bypass seat wants its mode named, a + // prompting one wants none), so it is sent once the other way. + // The ways tried are kept on the nudge's own row, so a daemon restart + // does not give a held nudge a fresh budget. + const tried = pending.tried_modes ? pending.tried_modes.split(",") : []; + const other = tried[tried.length - 1] === "bypass" ? "plain" : "bypass"; + if (!tried.includes(other)) { + retryMode = other; + carriedModes = tried; + setQueueState(db, pending.id, "expired"); + } + } + if (retryMode === "") { + if (!socketGone && !recordMoved) { + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, ...observedSeat(socketCheck.state === "ok" ? observation.reason : socketCheck.reason) }, now); + return; + } + setQueueState(db, pending.id, "expired"); } - setQueueState(db, pending.id, "expired"); } if (!registered) return; if (hostPlatform === "win32") { @@ -279,16 +300,19 @@ export function createClaudeCodeQueueChannel({ saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "unaddressable", reason: "messaging_socket_missing" }, now); return; } + // Say bypass only for a seat whose conversation is in bypass mode (or, after + // a hold, once as the one other try); every other seat gets no claim. + const mode = retryMode || (readMode(transcriptPath) === "bypassPermissions" ? "bypass" : "plain"); const nonce = newNonce(); let id; try { - id = createPending(db, { seat, messagingSocket: record.messaging_socket, transcriptPath, transcriptPos: transcriptEndPosition(transcriptPath), upTo: snapshot.upTo, nonce, expectedOpGen }, now); + id = createPending(db, { seat, messagingSocket: record.messaging_socket, transcriptPath, transcriptPos: transcriptEndPosition(transcriptPath), upTo: snapshot.upTo, nonce, triedModes: [...carriedModes, mode], expectedOpGen }, now); } catch (error) { if (error.message !== "daemon_intent_changed" && error.message !== "seat_already_pending") throw error; return; } - const result = await send({ socketPath: record.messaging_socket, nonce, body: inboxNudge(nonce), token: record.messaging_token, timeoutMs }); + const result = await send({ socketPath: record.messaging_socket, nonce, body: inboxNudge(nonce), token: record.messaging_token, fromMode: mode === "bypass" ? "bypass" : "", timeoutMs }); if (result.state !== "sent") { setQueueState(db, id, "expired"); saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `send_failed:${result.reason}` }, now); diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index 8ca391611..65c42c645 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -10,6 +10,7 @@ import { sendClaudeCodeMessage, resolveTranscriptPath, resolvePendingDelivery, + latestPermissionMode, } from '../scripts/daemon/channels/claude-code-queue-io.mjs'; import { createClaudeCodeQueueChannel } from '../scripts/daemon/channels/claude-code-queue.mjs'; import { ensureClaudeCodeChannelSchema } from '../scripts/daemon/channels/claude-code-queue-store.mjs'; @@ -175,7 +176,7 @@ function channelFixture(t) { db.exec("UPDATE daemon_intent SET desired='on', op_gen=1"); t.after(() => db.close()); const state = { session: 'sess-1', socket: '/tmp/cc-socks/1.sock', sent: 0, hours: 0, alive: { state: 'ok' } }; - const channel = createClaudeCodeQueueChannel({ + const options = { db, installRoot: root, expectedOpGen: 1, env: { AGMSG_STORAGE_PATH: root }, now: () => Date.UTC(2026, 9, 14) + state.hours * 3_600_000, log: () => {}, @@ -184,11 +185,14 @@ function channelFixture(t) { readDriver: () => ({ state: 'ok', driver: 'sqlite' }), openStore: () => ({ close() {} }), readSnapshot: () => ({ state: 'ok', unread: true, upTo: '{}' }), - send: async () => { state.sent += 1; return { state: 'sent' }; }, + send: async (args) => { state.sent += 1; (state.modes ??= []).push(args.fromMode); return { state: 'sent' }; }, + readMode: () => state.mode ?? '', observe: () => (state.hold ? { state: 'held' } : { state: 'absent' }), socketAlive: () => state.alive, hostPlatform: 'linux', - }); + }; + state.options = options; + const channel = createClaudeCodeQueueChannel(options); return { db, channel, state }; } @@ -230,7 +234,7 @@ test('a seat whose recorded socket is gone gets no new nudge rows until its reco await channel.stop(); }); -test('a nudge opens with the session token as an auth line and claims no permission mode', async (t) => { +test('a nudge opens with the session token as an auth line and claims no permission mode unless asked to', async (t) => { const dir = temporaryDirectory(t); const socketPath = path.join(dir, 's.sock'); const received = new Promise((resolve) => { @@ -250,6 +254,68 @@ test('a nudge opens with the session token as an auth line and claims no permiss assert.ok(!lines[1].includes('from-mode')); }); +test('a bypass seat is told from-mode=bypass; any other mode, or none readable, claims nothing', async (t) => { + const dir = temporaryDirectory(t); + const transcript = path.join(dir, 's.jsonl'); + const row = (mode) => `${JSON.stringify({ type: 'user', permissionMode: mode, message: { role: 'user', content: 'hi' } })}\n`; + assert.equal(latestPermissionMode(path.join(dir, 'none.jsonl')), ''); + writeFileSync(transcript, `${JSON.stringify({ type: 'assistant' })}\n`); + assert.equal(latestPermissionMode(transcript), ''); + writeFileSync(transcript, row('bypassPermissions') + row('auto') + `${JSON.stringify({ type: 'assistant' })}\n`); + assert.equal(latestPermissionMode(transcript), 'auto', 'the latest row decides'); + writeFileSync(transcript, row('auto') + row('bypassPermissions')); + assert.equal(latestPermissionMode(transcript), 'bypassPermissions'); + + for (const [mode, expected] of [['bypassPermissions', 'bypass'], ['auto', ''], ['default', ''], ['acceptEdits', ''], ['dontAsk', ''], ['plan', ''], ['', '']]) { + const { channel, state } = channelFixture(t); + state.mode = mode; + await channel.pollOnce(); + assert.deepEqual(state.modes, [expected], mode || 'unreadable'); + await channel.stop(); + } + + const socketPath = path.join(dir, 'b.sock'); + const received = new Promise((resolve) => { + const server = createServer((connection) => { + let data = ''; + connection.on('data', (chunk) => { data += chunk; }); + connection.on('end', () => { server.close(); resolve(data); }); + }); + server.listen(socketPath); + t.after(() => server.close()); + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + await sendClaudeCodeMessage({ socketPath, nonce: 'n2', body: '[agmsg:n2] hi', fromMode: 'bypass', checkOwnership: () => ({ state: 'ok' }) }); + assert.match(JSON.parse((await received).trim()).message.content, /^/); +}); + +test('a held nudge is sent once the other way (claim, or no claim) and then left alone', async (t) => { + // Not in bypass by its transcript, held anyway (it was switched): claim bypass. + let { db, channel, state } = channelFixture(t); + await channel.pollOnce(); + state.hold = true; + for (let i = 0; i < 4; i += 1) await channel.pollOnce(); + assert.deepEqual(state.modes, ['', 'bypass'], 'each way once, then no more'); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM beta_claude_queue WHERE state='pending'").get().n, 1); + assert.match(db.prepare('SELECT reason FROM beta_claude_seat').get().reason, /^peer_held/); + await channel.stop(); + // A restarted daemon (a new channel on the same database) does not give the held nudge a new budget. + const again = createClaudeCodeQueueChannel({ ...state.options }); + for (let i = 0; i < 3; i += 1) await again.pollOnce(); + assert.deepEqual(state.modes, ['', 'bypass'], 'a restart adds no send'); + await again.stop(); + + // A bypass seat that holds it anyway (switched to a prompting mode): no claim next. + ({ db, channel, state } = channelFixture(t)); + state.mode = 'bypassPermissions'; + await channel.pollOnce(); + state.hold = true; + for (let i = 0; i < 4; i += 1) await channel.pollOnce(); + assert.deepEqual(state.modes, ['bypass', '']); + + await channel.stop(); +}); + test('a nudge the seat holds is seen from the preview that drops the brackets, and the seat is not called addressable', async (t) => { const dir = temporaryDirectory(t); const transcriptPath = path.join(dir, 'session.jsonl'); From f1b1944fe2351ad266f078ba44c963bddbda783c Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 08:01:46 +0900 Subject: [PATCH 58/97] [skip ci] agmsgd: record why a start attempt failed, so status can show it (#1674) Before this, a bootstrap self-verification failure in scripts/daemon/agmsgd (an install_id/digest/bootstrap_version mismatch, exit 75) only ever reached stderr. The real launcher execs into this process, so there is no launcher left to capture that output, and the resident manager (launchd/systemd/Task Scheduler) doesn't forward it anywhere `agmsg daemon status` reads. Since owner.mjs is never imported on this path either, the failure left no trace in daemon_owner for status.mjs's decision text to report -- callers only ever saw the generic "executor or control socket unavailable" fallback, with no way to tell a digest mismatch from any other kind of stop. agmsgd's fail() now records the reason in daemon_start_attempts (the same best-effort insert main.mjs's own startup-declined path and agmsgd-launch.sh's _agmsgd_launch_record_attempt already use), which is exactly what status.mjs's existing daemon_start_attempts lookup surfaces as the reason in its decision text. Also closes the one launcher exit path (an in-progress install/uninstall holding the operation lock) that wasn't recording through _agmsgd_launch_record_attempt yet. --- scripts/daemon/agmsgd | 38 +++++++++++++++++++++++++++++++ scripts/daemon/agmsgd-launch.sh | 2 +- tests/test_agmsgd_entrypoint.bats | 29 +++++++++++++++++++++++ tests/test_agmsgd_launch.bats | 5 ++-- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/scripts/daemon/agmsgd b/scripts/daemon/agmsgd index 22149f912..f8db65f0b 100755 --- a/scripts/daemon/agmsgd +++ b/scripts/daemon/agmsgd @@ -24,7 +24,44 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { DatabaseSync } from "node:sqlite"; +// Set once installRoot/dbPath are known in main(), below. Every fail() +// before that point (an argv usage error) has nothing to record against and +// nothing to lose by skipping it -- that path only fires when something +// other than agmsgd-launch.sh invokes this file wrong. +let failureDbPath; + +// Best-effort, same convention as main.mjs's own startup-declined insert and +// agmsgd-launch.sh's _agmsgd_launch_record_attempt: status.mjs's +// daemon_start_attempts read is what turns this into the reason a user +// actually sees from `agmsg daemon start`/`status` instead of the generic +// "executor or control socket unavailable" fallback. A reader that can't see +// this row falls back to that generic text, never a crash here -- recording +// it is strictly additive to what daemon_start_attempts already offers for +// every OTHER kind of start failure. +function recordStartAttempt(reason) { + if (!failureDbPath) return; + let db; + try { + db = new DatabaseSync(failureDbPath); + db.exec("PRAGMA busy_timeout = 2000;"); + db.prepare("INSERT INTO daemon_start_attempts (at, reason, executor_pid) VALUES (?, ?, ?)").run( + new Date().toISOString(), + reason, + process.pid, + ); + } catch { + /* best-effort */ + } finally { + try { + db?.close(); + } catch { + /* best-effort */ + } + } +} + function fail(code, message) { + recordStartAttempt(message); process.stderr.write(`agmsgd: ${message}\n`); process.exit(code); } @@ -123,6 +160,7 @@ async function main() { const incompleteOperationPath = join(installRoot, "run", "install-op-incomplete.json"); const manifestPath = join(installRoot, "run", "install-manifest.json"); const dbPath = join(installRoot, "run", "install.db"); + failureDbPath = dbPath; const lockDb = new DatabaseSync(lockPath); lockDb.exec("BEGIN EXCLUSIVE;"); diff --git a/scripts/daemon/agmsgd-launch.sh b/scripts/daemon/agmsgd-launch.sh index 194a8c008..35bc8fe12 100755 --- a/scripts/daemon/agmsgd-launch.sh +++ b/scripts/daemon/agmsgd-launch.sh @@ -94,7 +94,7 @@ if [ -f "$MANIFEST" ]; then : # complete -- proceed elif [ -f "$MANIFEST.prev" ]; then if _agmsgd_launch_lock_held; then - echo "agmsgd-launch: an install/uninstall is in progress" >&2 + _agmsgd_launch_record_attempt "an install/uninstall is in progress" exit 75 fi _agmsgd_launch_record_attempt "a previous update never completed -- run install.sh again" diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats index 0f1686e39..beb633219 100644 --- a/tests/test_agmsgd_entrypoint.bats +++ b/tests/test_agmsgd_entrypoint.bats @@ -138,6 +138,35 @@ NODE [[ "$output" == *"operation is incomplete"* ]] } +# Before this fix, a digest mismatch (bootstrap self-verification, before +# main.mjs/owner.mjs is ever imported) only ever reached stderr -- discarded +# by the real launcher, which `exec`s into this process and so has no stderr +# left to capture, and never forwarded by the resident manager to anywhere a +# user runs `agmsg daemon status`. The reason never reached daemon_owner +# either (owner.mjs is never imported on this path), so status.mjs's +# daemon_start_attempts lookup -- keyed off owner.last_end_at -- was the only +# place left that could carry it forward. This is the end-to-end check that +# it now does: not just that agmsgd records the row, but that `agmsg daemon +# status`'s actual decision text (classify(), via status.mjs) surfaces this +# specific reason instead of falling back to its generic "executor or +# control socket unavailable" text. +@test "agmsgd records a digest-mismatch start attempt, and daemon status surfaces that reason (#1631)" { + _write_completion_record + printf 'tampered\n' >> "$TEST_SKILL_DIR/scripts/daemon/schema.sql" + + run node "$SCRIPTS/daemon/agmsgd" "$TEST_SKILL_DIR" on 0 + [ "$status" -eq 75 ] + grep -qF -- "digest verification failed: digest mismatch: scripts/daemon/schema.sql" <<<"$output" + + local recorded_reason + recorded_reason="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT reason FROM daemon_start_attempts;")" + [ "$recorded_reason" = "digest verification failed: digest mismatch: scripts/daemon/schema.sql" ] + + run node "$SCRIPTS/daemon/status.mjs" "$TEST_SKILL_DIR" + [ "$status" -eq 1 ] + grep -qF -- "reason: digest verification failed: digest mismatch: scripts/daemon/schema.sql" <<<"$output" +} + @test "agmsgd entrypoint waits for a CLI read lock before marking ready" { # Instrument the copied module before hashing it. Synchronize a separate # reader after ownership is committed, exactly at the ready-write boundary. diff --git a/tests/test_agmsgd_launch.bats b/tests/test_agmsgd_launch.bats index 26170ca3d..e53365ed7 100644 --- a/tests/test_agmsgd_launch.bats +++ b/tests/test_agmsgd_launch.bats @@ -48,7 +48,7 @@ setup_install_db() { [ "$count" -eq 1 ] } -@test "agmsgd-launch: an update in progress (a held install-op lock) -> exits 75" { +@test "agmsgd-launch: an update in progress (a held install-op lock) -> exits 75, records a start attempt" { setup_install_db sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" touch "$TEST_SKILL_DIR/run/install-manifest.json.prev" @@ -86,7 +86,8 @@ setup_install_db() { wait "$holder_pid" [ "$status" -eq 75 ] printf '%s\n' "$output" | grep -Fq 'an install/uninstall is in progress' - [ "$(sqlite3 "$SKILLDIR_INSTALL_DB" 'SELECT count(*) FROM daemon_start_attempts;')" -eq 0 ] + [ "$(sqlite3 "$SKILLDIR_INSTALL_DB" 'SELECT count(*) FROM daemon_start_attempts;')" -eq 1 ] + [ "$(sqlite3 "$SKILLDIR_INSTALL_DB" "SELECT reason FROM daemon_start_attempts;")" = 'an install/uninstall is in progress' ] } @test "agmsgd-launch: a crashed update (.prev present, lock free) -> exits 0, records a start attempt" { From 112fce1319b14ddf48994ae1864f3049a2eead0d Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 08:15:07 +0900 Subject: [PATCH 59/97] [skip ci] herdr: retry a rename that only failed because detection lagged (#1675) terminal_name's `herdr agent rename` call had exactly one attempt. Right after the running process in a pane exits and restarts in place -- measured here: `codex resume` after agmsgd's bridge was lost, which daemon.sh itself tells the user to run next -- herdr clears the agent's name on exit (its own documented behavior: "cleared when that agent exits, is released, or is replaced") and does not instantly recognize the new process as a live agent. Measured directly against a throwaway herdr pane: detection of a freshly (re)started codex process took 2-5s, and `agent rename` against that pane in the meantime fails with `{"error":{"code":"agent_not_found",...}}`. Since that failure is fatal and non-fatal callers swallow it with `|| true`, the member's key stayed unset, team.sh reported agent_key=mismatch, and nothing about re-running `/agmsg actas` changed the race it kept losing. Retries now, but only on that specific failure shape, bounded to the same ~5s (50 * 0.1s) this driver's own input-readiness wait already uses. Any other rename failure (a live-name collision, a key the server refuses outright) is not a detection race and still fails on the first try. --- scripts/drivers/terminals/herdr/ops.sh | 23 ++++++++- tests/test_terminal_registry.bats | 67 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/scripts/drivers/terminals/herdr/ops.sh b/scripts/drivers/terminals/herdr/ops.sh index e0b1347dd..28cf16b1c 100644 --- a/scripts/drivers/terminals/herdr/ops.sh +++ b/scripts/drivers/terminals/herdr/ops.sh @@ -1386,8 +1386,27 @@ terminal_name() { echo "herdr: cannot compute the internal key for '$team/$name' — the sha256 helper is unavailable, so the pane was not named" >&2 return 13 } - local _err _rc=0 - _err="$(_herdr_cli "$id" agent rename "$(_herdr_bare_of "$id")" "$key" 2>&1 >/dev/null)" || _rc=$? + # A pane whose running process just exited and restarted (measured: `codex + # resume` after the bridge to agmsgd was lost, #1631) has NO agent entry for + # herdr to rename for a few seconds after the new process starts -- herdr's + # own detection of it is passive and polled, not instant on the new + # process's first byte (measured here: 2-5s on an otherwise idle pane). + # `agent rename` on a pane with no agent entry yet fails with + # `{"error":{"code":"agent_not_found",...}}` on stderr -- retry only THAT + # failure, bounded, the same ~5s (50 * 0.1s) shape terminal_spawn's own + # input-readiness wait above uses. Any OTHER rename failure (a live-name + # collision, a key the server otherwise refuses) is not a detection race + # and still fails on the first try, exactly as before. + local _err _rc=0 _tries=0 + while :; do + _rc=0 + _err="$(_herdr_cli "$id" agent rename "$(_herdr_bare_of "$id")" "$key" 2>&1 >/dev/null)" || _rc=$? + [ "$_rc" -eq 0 ] && break + case "$_err" in *'"code":"agent_not_found"'*) ;; *) break ;; esac + [ "$_tries" -lt 50 ] || break + sleep 0.1 2>/dev/null || true + _tries=$((_tries + 1)) + done if [ "$_rc" -ne 0 ]; then echo runtime_error echo "herdr: 'agent rename' for '$team/$name' on pane '$id' failed (rc=$_rc)${_err:+: $_err}" >&2 diff --git a/tests/test_terminal_registry.bats b/tests/test_terminal_registry.bats index 537d9f04b..4fb4056ff 100644 --- a/tests/test_terminal_registry.bats +++ b/tests/test_terminal_registry.bats @@ -3008,6 +3008,73 @@ M refute grep -Fq 'herdr [agent] [rename]' "$ARGV_LOG" } +# A herdr whose `agent rename` answers `agent_not_found` (herdr's real shape, +# measured live: `{"error":{"code":"agent_not_found","message":"agent target +# not found"}}` on stderr, exit 1) for the first N attempts, then +# succeeds -- standing in for the pane whose running process just restarted +# (measured: `codex resume` after the bridge was lost, #1631), where herdr's +# own passive detection of the new process takes a few seconds and has not +# caught up yet. Everything else matches _install_fake_herdr. +_install_fake_herdr_rename_recovers_from_not_found() { + local sid="${1:-}" fail_times="${2:-1}" + printf '%s' "$fail_times" > "$TEST_SKILL_DIR/rename-not-found-remaining" + cat > "$FAKEBIN/herdr" <> "$ARGV_LOG" +counter="$TEST_SKILL_DIR/rename-not-found-remaining" +if [ "\$1" = agent ] && [ "\$2" = list ]; then + printf '{"id":"1","result":{"type":"list","agents":[{"agent":"claude","agent_session":{"agent":"claude","kind":"id","source":"herdr:claude","value":"%s"},"pane_id":"wC:p4","display_agent":"team:alice","name":"a-key"}]}}\n' "$sid" +elif [ "\$1" = pane ] && [ "\$2" = rename ]; then + exit 0 +elif [ "\$1" = agent ] && [ "\$2" = rename ]; then + remaining="\$(cat "\$counter" 2>/dev/null || echo 0)" + if [ "\$remaining" -gt 0 ]; then + echo "\$((remaining - 1))" > "\$counter" + echo '{"error":{"code":"agent_not_found","message":"agent target wC:p9 not found"},"id":"cli:agent:rename"}' >&2 + exit 1 + fi + exit 0 +fi +exit 0 +EOF + chmod +x "$FAKEBIN/herdr" + export PATH="$FAKEBIN:$PATH" + export HERDR_SOCKET_PATH="$TEST_SKILL_DIR/herdr.sock" +} + +# --- a rename that fails only because herdr has not detected the agent YET +# retries instead of failing the seat outright (#1631: `codex resume` after +# the bridge was lost left the key unset, and re-running `/agmsg actas` right +# after did not fix it -- herdr's own passive detection of the newly +# (re)started process is not instant, and the single rename attempt this +# driver made before this fix landed in exactly that few-second window). +@test "terminal_name retries an 'agent_not_found' rename and succeeds once herdr catches up (#1631)" { + _install_fake_herdr_rename_recovers_from_not_found "sess-retry" 3 + agmsg_terminal_load herdr + + run terminal_name 'wC:p9' teamx alice + [ "$status" -eq 0 ] + [ "$output" = ok ] + # Three agent_not_found answers, then the one that actually renamed it. + [ "$(grep -c '\[agent\] \[rename\]' "$ARGV_LOG")" -eq 4 ] +} + +# The retry is bounded and specific to agent_not_found: a rename that never +# recovers still fails (no infinite loop), and a DIFFERENT failure (no +# agent_not_found body at all, same shape test_terminal_registry.bats's other +# HERDR_AGENT_RENAME_RC=1 tests already use) is not retried at all -- which +# this asserts directly, by call count, rather than only by the end result +# those other tests already cover. +@test "terminal_name does not retry a rename failure that is not 'agent_not_found' (#1631)" { + _install_fake_herdr "sess-k" + export HERDR_AGENT_RENAME_RC=1 + agmsg_terminal_load herdr + + run terminal_name 'wC:p9' teamx alice + [ "$status" -ne 0 ] + [ "$(grep -c '\[agent\] \[rename\]' "$ARGV_LOG")" -eq 1 ] +} + # --- a driver that is missing a required op does not load (#1051) ------------- # # The registry has always refused a driver missing an ABI function; what it did From f8e1f7fc9743f80c0f670d4fc2f66cf47a452374 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 08:44:39 +0900 Subject: [PATCH 60/97] [skip ci] codex: stop moving a seat onto the new thread after /clear (#1468) (#1677) * [skip ci] codex: stop moving a seat onto the new thread after /clear (#1468) The rebind matched a seat by the pid in its record's owner, but a Codex seat's tool shells run under the profile's shared app-server daemon, so every seat resolves to the same pid. A stale owner on one seat made it the only candidate, and a /clear in another seat moved that seat's record onto the new thread. Remove the rebind and its helpers; the unseated notice still tells the user to run actas. The test now covers exactly that case (one stale owner, one seat without an owner) and fails on the previous code. * [skip ci] codex: say what /clear still does, and pin it The hook no longer picks a seat by process, but agmsg_codex_refresh_destination (the project's sole registration, or a unique placement in this terminal generation, never over a live owner) still moves a record. State that in the hook comment, narrow the existing test to the several-seat case, and add a test for the sole-registration case so the retained behavior is explicit. --- scripts/drivers/types/codex/_session-start.sh | 24 ++---- scripts/lib/role-session.sh | 59 --------------- tests/test_delivery.bats | 74 ++++++++----------- 3 files changed, 39 insertions(+), 118 deletions(-) diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index 9897f4737..9d88ce58d 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -155,16 +155,6 @@ agmsg_codex_refresh_destination() { AGMSG_CODEX_REFRESHED_PAIR="$chosen_team"$'\t'"$chosen_name" } -# A Codex seat holds no actas lock, so the only proof that a recorded seat is -# this very process is its record: owner=. naming the Codex pid -# this hook runs under (see agmsg_role_session_rebind_same_process). -agmsg_codex_rebind_after_clear() { - local thread="$1" project="$2" pairs="$3" cur_pid - cur_pid="$(agmsg_agent_pid codex 2>/dev/null || true)" - case "$cur_pid" in ''|*[!0-9]*) return 1 ;; esac - agmsg_role_session_rebind_same_process "$thread" "$project" "$cur_pid" "$pairs" -} - agmsg_codex_unseated_notice() { source "${SKILL_DIR:-}/scripts/lib/boot-command.sh" printf 'AGMSG delivery: this session is not connected to a seat yet. Run %s in Codex to receive messages.\n' "$(agmsg_actas_prompt codex '')" @@ -193,13 +183,13 @@ agmsg_session_start() { # shellcheck source=../../../lib/role-session.sh source "$SKILL_DIR/scripts/lib/role-session.sh" project_phys="$(agmsg_canonical_path "$PROJECT" 2>/dev/null || printf '%s' "$PROJECT")" - # #1468: /clear starts a new thread inside the same Codex process, so no - # seat of this project is recorded on it and the old owner's pid is still - # alive. Only an identity the session itself supplied (env or hook input, - # never a rollout guess) may drive the rebind. - if [ "$thread_id" = "${CODEX_THREAD_ID:-}" ] || [ "$thread_id" = "${SESSION_ID:-}" ]; then - agmsg_codex_rebind_after_clear "$thread_id" "$project_phys" "$PAIRS" || true - fi + # #1468: /clear starts a new thread inside the same Codex process. No + # seat is chosen here by the process: the tool shells run under the + # profile's shared app-server daemon, so every seat of a profile resolves to + # the same pid and a pid match could move another seat's record. The only + # moves left are agmsg_codex_refresh_destination's (#1607): the project's + # sole registration, or a unique placement in this terminal generation, + # and never over a live owner. # The hook runs in the TUI's environment; tool shells may not (see _profile.sh). if [ "$thread_id" = "${CODEX_THREAD_ID:-}" ] || [ "$thread_id" = "${SESSION_ID:-}" ]; then # shellcheck disable=SC1091 diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index 99713d067..8d4dd040b 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -152,65 +152,6 @@ _agmsg_role_session_write() ( "$@" ) -# Epoch at which started, empty when it cannot be read. -_agmsg_process_start_epoch() { - local lstart - lstart="$(LC_ALL=C ps -p "$1" -o lstart= 2>/dev/null)" || return 0 - [ -n "$lstart" ] || return 0 - if [ "$(uname -s)" = Darwin ]; then - LC_ALL=C date -j -f '%a %b %e %T %Y' "$lstart" +%s 2>/dev/null || true - else - LC_ALL=C date -d "$lstart" +%s 2>/dev/null || true - fi -} - -_agmsg_iso_epoch() { - if [ "$(uname -s)" = Darwin ]; then - LC_ALL=C date -j -u -f '%Y-%m-%dT%H:%M:%SZ' "$1" +%s 2>/dev/null || true - else - LC_ALL=C date -u -d "$1" +%s 2>/dev/null || true - fi -} - -_agmsg_role_session_rebind_same_process_unlocked() { - local thread="$1" project="$2" pid="$3" pairs="$4" team agent owner started updated rec_project - local match_team='' match_agent='' n=0 match_codex_home='' - # Select under the writer lock: a record another writer moved between a - # caller's look and this transaction must not be rewritten from a stale view. - while IFS=$'\t' read -r team agent; do - [ -n "$team" ] && [ -n "$agent" ] || continue - agmsg_role_session_load "$team" "$agent" - [ -n "$AGMSG_ROLE_SESSION_UUID" ] || continue - # A seat is already bound to this thread: nothing to rebind. - [ "$AGMSG_ROLE_SESSION_UUID" != "$thread" ] || return 1 - rec_project="$(agmsg_canonical_path "$AGMSG_ROLE_SESSION_PROJECT" 2>/dev/null || printf '%s' "$AGMSG_ROLE_SESSION_PROJECT")" - [ "$rec_project" = "$project" ] || continue - owner="${AGMSG_ROLE_SESSION_OWNER:-}" - agmsg_instance_is_composite "$owner" && [ "${owner##*.}" = "$pid" ] || continue - [ "$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" type)" = codex ] || continue - _agmsg_pid_alive "$pid" || continue - started="$(_agmsg_process_start_epoch "$pid")" - updated="$(_agmsg_iso_epoch "$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" updated_at)")" - case "$started:$updated" in ''|:*|*:|*[!0-9:]*) continue ;; esac - [ "$started" -le "$updated" ] || continue - n=$((n + 1)); match_team="$team"; match_agent="$agent" - match_codex_home="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" codex_home)" - done <<< "$pairs" - [ "$n" = 1 ] || return 1 - _agmsg_role_session_record_unlocked "$match_team" "$match_agent" "$thread" "$project" codex "$thread.$pid" "$match_codex_home" - agmsg_role_session_load "$match_team" "$match_agent" - [ "$AGMSG_ROLE_SESSION_UUID" = "$thread" ] -} - -# /clear starts a new thread inside the same live Codex process. Move the -# one seat of whose record names that very process (same pid, alive, -# started before the record was written) onto . Selection, check -# and write are one transaction with every other writer. is the -# candidate "teamagent" lines. -agmsg_role_session_rebind_same_process() { # - _agmsg_role_session_write _agmsg_role_session_rebind_same_process_unlocked "$@" -} - agmsg_role_session_record() { [ -n "${1:-}" ] && [ -n "${2:-}" ] && [ -n "${3:-}" ] || return 0 if _agmsg_role_session_write _agmsg_role_session_record_unlocked "$@"; then return 0; fi diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index 1bf808dfa..ebfd92e3c 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -2914,59 +2914,49 @@ EOF [ ! -f "$log" ] } -@test "session-start.sh for codex rebinds a /clear seat only to the same live process (#1468)" { +@test "session-start.sh for codex does not choose a /clear seat by process: with several seats none moves, even one whose owner names the same live pid (#1468)" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null local old=11111111-1111-4111-8111-111111111111 new=22222222-2222-4222-8222-222222222222 - local rec="$TEST_SKILL_DIR/run/role-session.team__alice" - # A Codex seat holds no actas lock: its record alone names the process. + local rec_a="$TEST_SKILL_DIR/run/role-session.team__alice" rec_b="$TEST_SKILL_DIR/run/role-session.team__bob" + # Seats of a profile run under one app-server daemon, so a stale owner left + # on one seat names the pid every seat resolves to, and that pid stays alive + # across the /clear. The other seat (the one actually cleared) has no owner. sleep 60 & local pid=$! SKILL_DIR="$TEST_SKILL_DIR" bash -c ' source "$1/lib/role-session.sh" - agmsg_role_session_record team alice "$2" "$3" codex "$2.$4" - ' _ "$SCRIPTS" "$old" "$TEST_PROJECT" "$pid" - local before; before="$(cat "$rec")" - _hook() { printf '{"session_id":"%s","source":"clear"}' "$new" \ - | env -u CODEX_THREAD_ID AGMSG_AGENT_PID="$1" bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null; } - - # A different process (pid reuse, another Codex): untouched. - _hook "$$" - [ "$(cat "$rec")" = "$before" ] - - # The same pid, but the record predates the process: a reused pid, untouched. - sed -i.bak 's/^updated_at=.*/updated_at=2000-01-01T00:00:00Z/' "$rec" - local stale; stale="$(cat "$rec")" - _hook "$pid" - [ "$(cat "$rec")" = "$stale" ] - sed -i.bak "s/^updated_at=.*/updated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)/" "$rec" - - # No identity supplied by the session (only a rollout could name it): untouched. - before="$(cat "$rec")" - env -u CODEX_THREAD_ID AGMSG_AGENT_PID="$pid" bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" /dev/null - [ "$(cat "$rec")" = "$before" ] - - # The record belongs to another project by the time it is examined: untouched. - cp "$rec" "$rec.keep" - sed -i.bak "s#^project=.*#project=$BATS_TEST_TMPDIR/elsewhere#" "$rec" - before="$(cat "$rec")" - _hook "$pid" - [ "$(cat "$rec")" = "$before" ] - mv "$rec.keep" "$rec" - - # Git Bash's ps has no start-time column, so no start evidence exists there - # and the rebind correctly refuses; the success case is POSIX-only. - case "${MSYSTEM:-}" in MINGW*|MSYS*|CLANGARM*) kill "$pid" 2>/dev/null || true; return 0 ;; esac - - # The same live process, hook input names the new thread: rebound, and the - # thread's profile is published alongside. + agmsg_role_session_record team alice "$2" "$4" codex "$2.$5" + agmsg_role_session_record team bob "$3" "$4" codex + ' _ "$SCRIPTS" "$old" "33333333-3333-4333-8333-333333333333" "$TEST_PROJECT" "$pid" + local before_a before_b + before_a="$(cat "$rec_a")"; before_b="$(cat "$rec_b")" mkdir -p "$BATS_TEST_TMPDIR/profile" - CODEX_HOME="$BATS_TEST_TMPDIR/profile" _hook "$pid" + printf '{"session_id":"%s","source":"clear"}' "$new" \ + | env -u CODEX_THREAD_ID CODEX_HOME="$BATS_TEST_TMPDIR/profile" AGMSG_AGENT_PID="$pid" \ + bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null kill "$pid" 2>/dev/null || true - grep -qx "session=$new" "$rec" - grep -qx "owner=$new.$pid" "$rec" + [ "$(cat "$rec_a")" = "$before_a" ] + [ "$(cat "$rec_b")" = "$before_b" ] + # The thread's profile is still published, as for any session. [ -f "$TEST_SKILL_DIR/run/codex-thread-home.$new" ] } +@test "session-start.sh for codex keeps #1607's recovery after /clear: the project's sole registration follows the new thread (#1468)" { + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + local old=11111111-1111-4111-8111-111111111111 new=22222222-2222-4222-8222-222222222222 + local rec="$TEST_SKILL_DIR/run/role-session.team__alice" + SKILL_DIR="$TEST_SKILL_DIR" bash -c ' + source "$1/lib/role-session.sh" + agmsg_role_session_record team alice "$2" "$3" codex + ' _ "$SCRIPTS" "$old" "$TEST_PROJECT" + mkdir -p "$BATS_TEST_TMPDIR/profile" + printf '{"session_id":"%s","source":"clear"}' "$new" \ + | env -u CODEX_THREAD_ID CODEX_HOME="$BATS_TEST_TMPDIR/profile" \ + bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null + grep -qx "session=$new" "$rec" +} + # #1477: Codex installed after agmsg never gets this install's writable_roots # (configure_codex_sandbox only writes them into a Codex config that already # existed at install time), and nothing later says so. session-start.sh's From ac9ce6ae74b0df2dbcd602f91533b8d1e469854b Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 08:48:57 +0900 Subject: [PATCH 61/97] release: 1.6.0-rc.6 (#1678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release of `1.6.0-rc.6` from `integration/agmsgd-beta`. - VERSION bump + synced `package.json` / `.claude-plugin/plugin.json` - CHANGELOG.md deliberately untouched — prerelease notes come from `release.yml`'s own `git-cliff --latest` Changes since rc.5: - Claude Code notices: a session that skips permission prompts held every agmsgd notice, because the daemon is not the session's own child and the token alone does not pass. Read the session's permission mode from its transcript and name bypass mode only for such a session; a held notice is retried once the other way, and the attempts survive a daemon restart (#1671) - Windows: `agmsg daemon enable` no longer fails with "could not acquire the install operation lock"; sqlite3 answers end in CRLF there (#1668) - Windows: an install run with a Windows-shaped TMPDIR (as Claude Code sets it) no longer fails to write its completion record (#1670) - `agmsg daemon start` / `status` show why agmsgd stopped (for example a digest mismatch), instead of "reason: none" (#1674) - herdr: a Codex seat restarted with `codex resume` no longer shows `identity=mismatch` (#1675) - Codex: stop moving a seat onto the new thread after `/clear` by process id. Every TUI shares Codex's app-server daemon, so the id cannot tell seats apart and could move another seat's record. Reverts the rebind from #1659; #1468 stays open (#1677) Known: on Windows, agmsgd starts, but Claude Code seats are still reached through the Monitor (`blocked (windows_not_supported_yet)` in `daemon status`). Merging this and pushing `v1.6.0-rc.6` triggers the publish pipeline (npm + GitHub Release). --- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9871759b2..a3965e8a5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agmsg", "description": "Cross-agent messaging via SQLite. Send messages between CLI AI agents. No daemon, no network.", - "version": "1.6.0-rc.5", + "version": "1.6.0-rc.6", "author": { "name": "fujibee" }, diff --git a/VERSION b/VERSION index 5145ef537..bcbe87df4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0-rc.5 +1.6.0-rc.6 diff --git a/package.json b/package.json index 59790595b..f2495f9b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.6.0-rc.5", + "version": "1.6.0-rc.6", "description": "Cross-agent messaging via SQLite for CLI AI agents (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Antigravity, OpenCode). The npm package is a thin bootstrapper that fetches and runs the canonical bash installer at https://github.com/fujibee/agmsg.", "bin": { "agmsg": "bin/agmsg.js" From 0522c8efe763ac2a80f6775fc81418a93da7768e Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 08:56:00 +0900 Subject: [PATCH 62/97] Pin that a permission-mode transcript row decides the mode a nudge is sent under (#1679) Test only. [skip ci] --- tests/agmsgd_claude_code_queue.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index 65c42c645..8606c1d88 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -265,6 +265,11 @@ test('a bypass seat is told from-mode=bypass; any other mode, or none readable, assert.equal(latestPermissionMode(transcript), 'auto', 'the latest row decides'); writeFileSync(transcript, row('auto') + row('bypassPermissions')); assert.equal(latestPermissionMode(transcript), 'bypassPermissions'); + // shift+tab writes a row of its own, between user rows. + writeFileSync(transcript, row('bypassPermissions') + `${JSON.stringify({ type: 'permission-mode', permissionMode: 'auto', sessionId: 's' })}\n` + `${JSON.stringify({ type: 'assistant' })}\n`); + assert.equal(latestPermissionMode(transcript), 'auto', 'a permission-mode row counts'); + writeFileSync(transcript, row('auto') + `${JSON.stringify({ type: 'permission-mode', permissionMode: 'bypassPermissions' })}\n`); + assert.equal(latestPermissionMode(transcript), 'bypassPermissions'); for (const [mode, expected] of [['bypassPermissions', 'bypass'], ['auto', ''], ['default', ''], ['acceptEdits', ''], ['dontAsk', ''], ['plan', ''], ['', '']]) { const { channel, state } = channelFixture(t); From f268d7c1268b6ae4d60b9b635ec84a7eba5f8bdc Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 09:01:58 +0900 Subject: [PATCH 63/97] [skip ci] codex: do not carry a dead owner into the next seat record (#1680) * [skip ci] codex: do not carry a dead owner into the next seat record codex-record-session.sh copied the record's owner forward unconditionally, so an owner naming a process that no longer exists (a stopped app-server daemon's pid) survived every re-record and every restart. Keep it while its pid is alive and drop it otherwise. * [skip ci] codex: drop a carried owner only on proof its process is gone Treat only a clear 'no such process' from kill(2), confirmed by the process table, as proof. An unavailable or failing observation, and anything on Git Bash where pids are not in the space kill sees, keeps the owner. --- .../types/codex/codex-record-session.sh | 19 ++++++++++++++- tests/test_codex_resume.bats | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/scripts/drivers/types/codex/codex-record-session.sh b/scripts/drivers/types/codex/codex-record-session.sh index 6a8c4b703..da8ba23e3 100755 --- a/scripts/drivers/types/codex/codex-record-session.sh +++ b/scripts/drivers/types/codex/codex-record-session.sh @@ -237,7 +237,24 @@ fi # as-is. The project is recorded in its canonical (physical) form so records # carry one path spelling regardless of how the caller spelled the argument. agmsg_role_session_load "$TEAM" "$AGENT" 2>/dev/null || true -agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" "$codex_home" || true +# A carried owner (.) is kept unless its process is PROVEN gone: +# one that outlives every restart (a stopped app-server daemon's pid, say) is +# otherwise handed on to each later record. Proof means kill(2) says "no such +# process" and the process table agrees; anything unknown or unavailable keeps +# the owner. Git Bash is never asked: its pids are not in the space kill(1) +# sees, so no observation there is proof. +rec_owner="${AGMSG_ROLE_SESSION_OWNER:-}" +case "${MSYSTEM:-}" in + MINGW*|MSYS*|CLANGARM*) ;; + *) + if agmsg_instance_is_composite "$rec_owner" \ + && [ "$(_agmsg_pid_signal_presence "${rec_owner##*.}")" = gone ] \ + && ! _agmsg_pid_alive "${rec_owner##*.}"; then + rec_owner="" + fi + ;; +esac +agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "$rec_owner" "$codex_home" || true # The Codex actas flow reaches this script instead of actas-claim.sh. Publish # the same seat request here so a resumed seat's dispatcher has an authority diff --git a/tests/test_codex_resume.bats b/tests/test_codex_resume.bats index bbb1a3f65..d9291b3a7 100644 --- a/tests/test_codex_resume.bats +++ b/tests/test_codex_resume.bats @@ -96,6 +96,30 @@ recorded_uuid() { [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } +@test "codex record: carries a live owner forward and drops one whose process is gone" { + local proj; proj="$(mktemp -d)" + mkdir -p "$HOME/.codex" + source "$SKILL_DIR/scripts/lib/role-session.sh" + sleep 60 & + local live=$! + sleep 0.1 & + local dead=$! + wait "$dead" + SKILL_DIR="$SKILL_DIR" bash -c 'source "$1/scripts/lib/role-session.sh"; agmsg_role_session_record team alice t0 "$2" codex "t0.$3"' _ "$SKILL_DIR" "$proj" "$live" + CODEX_THREAD_ID="t1" bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + [ "$(agmsg_role_session_get team alice owner)" = "t0.$live" ] + SKILL_DIR="$SKILL_DIR" bash -c 'source "$1/scripts/lib/role-session.sh"; agmsg_role_session_record team alice t0 "$2" codex "t0.$3"' _ "$SKILL_DIR" "$proj" "$dead" + kill "$live" 2>/dev/null || true + CODEX_THREAD_ID="t2" bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + [ "$(recorded_uuid team alice)" = "t2" ] + [ -z "$(agmsg_role_session_get team alice owner)" ] + + # No proof of death where the pid cannot be observed (Git Bash): kept. + SKILL_DIR="$SKILL_DIR" bash -c 'source "$1/scripts/lib/role-session.sh"; agmsg_role_session_record team alice t0 "$2" codex "t0.$3"' _ "$SKILL_DIR" "$proj" "$dead" + MSYSTEM=MINGW64 CODEX_THREAD_ID="t3" bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + [ "$(agmsg_role_session_get team alice owner)" = "t0.$dead" ] +} + @test "codex record: adds the effective profile path without changing the thread" { local proj explicit_home expected_home proj="$(mktemp -d)" From 3cf5336e7764a93a33ac397a288a253d08689dee Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 09:12:10 +0900 Subject: [PATCH 64/97] [skip ci] codex: choose a seat after SessionStart only when the project has exactly one (#1681) With several registrations the hook picked the seat whose placement record and naming mark matched the pane in its environment. The hook runs under the profile's shared app-server daemon, so that pane is the one that started the daemon, not the one the session is in, and a /clear in one seat could move another seat's record onto the new thread. Drop that branch: only a sole registration follows the new thread, and otherwise the session is reported as not connected to a seat so the user runs actas. --- scripts/drivers/types/codex/_session-start.sh | 43 ++++++------------- tests/test_delivery.bats | 34 +++++++++++++++ 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index 9d88ce58d..506915035 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -107,41 +107,23 @@ INNER_EOF return 0 } -# A restart gets a new thread. Prefer this project's sole registration, or a -# unique placement in this terminal generation, rather than keeping an old -# conversation merely because its rollout is still on disk. +# A restart gets a new thread. Prefer this project's sole registration rather +# than keeping an old conversation merely because its rollout is still on disk. +# With several registrations nothing here can say which seat this is: the hook +# runs under the profile's shared app-server daemon, whose environment (the +# pane it names, the pid it has) belongs to whichever TUI started the daemon, +# not to this seat (#1112, #1468). So no seat is chosen then; the session is +# reported as unseated and the user runs actas. agmsg_codex_refresh_destination() { - local thread="$1" project="$2" pairs="$3" team name count chosen_team='' chosen_name='' + local thread="$1" project="$2" pairs="$3" count chosen_team='' chosen_name='' AGMSG_CODEX_REFRESHED_PAIR='' [[ "$thread" =~ ^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$ ]] || return 1 # Never bind a role to the newest rollout inference from another session. [ "$thread" = "${CODEX_THREAD_ID:-}" ] || [ "$thread" = "${SESSION_ID:-}" ] || return 1 pairs="$(printf '%s\n' "$pairs" | sort -u)" count="$(printf '%s\n' "$pairs" | grep -c . || true)" - if [ "$count" = 1 ]; then - IFS=$'\t' read -r chosen_team chosen_name <<< "$pairs" - else - # Environment-only driver observations perform no terminal writes or RPCs. - source "${SKILL_DIR:-}/scripts/lib/terminal-registry.sh" || return 1 - local here terminal id epoch ref path recorded record_project record_type mark matches=0 - here="$(agmsg_terminal_self_env)" - case "$here" in ''|unknown:*) return 1 ;; esac - IFS=$'\t' read -r terminal id epoch <<< "$here" - case "$terminal:$epoch" in plain:*|*:|*:unknown:*) return 1 ;; esac - ref="$(agmsg_terminal_ref "$terminal" "$id")" || return 1 - while IFS=$'\t' read -r team name; do - [ -n "$team" ] && [ -n "$name" ] || continue - path="$(agmsg_spawn_path "$team" "$name")" || continue - [ -f "$path" ] || continue - IFS=$'\t' read -r recorded record_project record_type < "$path" || continue - [ "$recorded" = "$ref" ] && [ "$record_type" = codex ] || continue - [ "$(agmsg_canonical_path "$record_project" 2>/dev/null)" = "$project" ] || continue - mark="$(agmsg_role_session_named "$team" "$name")" - [ "$mark" = "$ref"$'\t'"$epoch" ] || continue - matches=$((matches + 1)); chosen_team="$team"; chosen_name="$name" - done <<< "$pairs" - [ "$matches" = 1 ] || return 1 - fi + [ "$count" = 1 ] || return 1 + IFS=$'\t' read -r chosen_team chosen_name <<< "$pairs" [ -n "$chosen_team" ] && [ -n "$chosen_name" ] || return 1 local profile owner # shellcheck disable=SC1091 @@ -187,9 +169,8 @@ agmsg_session_start() { # seat is chosen here by the process: the tool shells run under the # profile's shared app-server daemon, so every seat of a profile resolves to # the same pid and a pid match could move another seat's record. The only - # moves left are agmsg_codex_refresh_destination's (#1607): the project's - # sole registration, or a unique placement in this terminal generation, - # and never over a live owner. + # move left is agmsg_codex_refresh_destination's: the project's sole + # registration, never over a live owner. # The hook runs in the TUI's environment; tool shells may not (see _profile.sh). if [ "$thread_id" = "${CODEX_THREAD_ID:-}" ] || [ "$thread_id" = "${SESSION_ID:-}" ]; then # shellcheck disable=SC1091 diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index ebfd92e3c..036fee83e 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -2957,6 +2957,40 @@ EOF grep -qx "session=$new" "$rec" } +@test "session-start.sh for codex never picks a seat by the pane its environment names, and says the conversation is unseated (#1468)" { + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null + local old_a=11111111-1111-4111-8111-111111111111 old_b=33333333-3333-4333-8333-333333333333 + local new=22222222-2222-4222-8222-222222222222 + local rec_a="$TEST_SKILL_DIR/run/role-session.team__alice" rec_b="$TEST_SKILL_DIR/run/role-session.team__bob" + # The hook runs under the profile's shared app-server daemon, so the pane in + # its environment is the one that started the daemon: here, bob's, while the + # /clear happened in another seat. bob's placement record and naming mark + # both agree with that pane. + local sock="$BATS_TEST_TMPDIR/herdr.sock" ref epoch + : > "$sock" + SKILL_DIR="$TEST_SKILL_DIR" HERDR_ENV=1 HERDR_SOCKET_PATH="$sock" HERDR_PANE_ID=w1:p17 bash -c ' + source "$1/lib/terminal-registry.sh"; source "$1/lib/role-session.sh" + here="$(agmsg_terminal_self_env)"; IFS=$'"'"'\t'"'"' read -r t id epoch <<< "$here" + ref="$(agmsg_terminal_ref "$t" "$id")" + agmsg_role_session_record team alice "$2" "$4" codex + agmsg_role_session_record team bob "$3" "$4" codex + printf "%s\t%s\tcodex\n" "$ref" "$4" > "$(agmsg_spawn_path team bob)" + agmsg_role_session_mark_named team bob "$ref" "$epoch" "$4" codex + ' _ "$SCRIPTS" "$old_a" "$old_b" "$TEST_PROJECT" + local before_a before_b out + before_a="$(cat "$rec_a")"; before_b="$(cat "$rec_b")" + mkdir -p "$BATS_TEST_TMPDIR/profile" + out="$(printf '{"session_id":"%s","source":"clear"}' "$new" \ + | env -u CODEX_THREAD_ID CODEX_HOME="$BATS_TEST_TMPDIR/profile" \ + HERDR_ENV=1 HERDR_SOCKET_PATH="$sock" HERDR_PANE_ID=w1:p17 \ + bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT")" + [ "$(cat "$rec_a")" = "$before_a" ] + [ "$(cat "$rec_b")" = "$before_b" ] + grep -q "not connected to a seat" <<<"$out" + grep -q "actas" <<<"$out" +} + # #1477: Codex installed after agmsg never gets this install's writable_roots # (configure_codex_sandbox only writes them into a Codex config that already # existed at install time), and nothing later says so. session-start.sh's From 426b7f5a0d7bd7ecb1d08210119faf669a8f1efb Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 09:20:26 +0900 Subject: [PATCH 65/97] [skip ci] placement: do not read a Codex claimant as gone from herdr's agent list (#1682) * [skip ci] placement: do not read a Codex claimant as gone from herdr's agent list The pane-takeover check asked herdr whether the rival record's session is among the live agents. For a Codex claimant that answer is not usable: its commands run under one shared app-server, and the agent list carries a session for only some of those panes, sometimes another seat's thread. On the measured machine one of five Codex panes carried a session and it was not the thread running there, so a running Codex seat read as gone and another seat could take its pane. Answer unknown for a Codex claimant; other types are unchanged. * [skip ci] placement: say why a Codex claimant's pane is not taken over The refusal for a Codex claimant now adds that the seat is a Codex seat whose liveness cannot be read from the terminal, so its record was not treated as stale, next to the existing way out (drop or despawn that seat). --- scripts/lib/terminal-registry.sh | 10 +++++++++ tests/test_terminal_registry.bats | 34 ++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/scripts/lib/terminal-registry.sh b/scripts/lib/terminal-registry.sh index 267f8375a..a45bd6c2e 100644 --- a/scripts/lib/terminal-registry.sh +++ b/scripts/lib/terminal-registry.sh @@ -1165,6 +1165,13 @@ _agmsg_placement_claimant_here_now() { # declare -F _agmsg_role_session_field >/dev/null 2>&1 || { echo unknown; return 0; } claimant_sid="$(_agmsg_role_session_field "$role_path" session)" [ -n "$claimant_sid" ] || { echo unknown; return 0; } + # A Codex claimant's thread is not something herdr's agent list can answer + # for: its commands run under one shared app-server, and the list's + # agent_session for those panes is missing on most of them and, where present, + # names another seat's thread (measured: 1 of 5 Codex panes carried a session, + # and not the one running there). "Not among the live agents" would read a + # running Codex seat as gone and let another seat take its pane (#1112, #1468). + [ "$(_agmsg_role_session_field "$role_path" type)" != codex ] || { echo codex_unreadable; return 0; } declare -F terminal_session_live >/dev/null 2>&1 || { echo unknown; return 0; } live_out="$(terminal_session_live "$claimant_sid" 2>/dev/null)" || { echo unknown; return 0; } case "$live_out" in @@ -1533,6 +1540,9 @@ agmsg_terminal_name_self() { *"__$(_actas_lock_encode "$agent")") _self_hint=" If '$_claimed_by' is this seat under a team that no longer exists, remove run/spawn.$_claimed_by." ;; esac + if [ "${_claimant_here:-}" = codex_unreadable ]; then + _self_hint="$_self_hint That seat is a Codex seat, and whether it is still running cannot be read from the terminal (its commands run under a shared app-server), so the record was not treated as stale." + fi echo "agmsg: did not name or record this pane: this seat resolved $_claim_ref, and that pane is already recorded as $_claimed_by's. Keeping that seat's name and record. To keep this seat instead, drop or despawn that seat (or remove run/spawn.$_claimed_by) and act again.$_self_hint (#1114 placement guard, kept alongside #1112's label-first resolution.)" >&2 return 0 fi diff --git a/tests/test_terminal_registry.bats b/tests/test_terminal_registry.bats index 4fb4056ff..dd3b18e3b 100644 --- a/tests/test_terminal_registry.bats +++ b/tests/test_terminal_registry.bats @@ -3825,7 +3825,7 @@ _tmux_op_args() { } @test "placement guard: a claimant whose session is confirmed gone lets a live seat take the pane; a claimant still there still refuses (#1485)" { - # elder's Codex session ended; resumer's fresh session now sits in the same + # elder's session ended; resumer's fresh session now sits in the same # herdr pane. elder's spawn record still claims it (first-writer-wins, # #1114), but elder's own role-session record names the session that used # to hold it. @@ -3837,8 +3837,8 @@ _tmux_op_args() { local rival; rival="$(agmsg_spawn_path seatteam elder)" mkdir -p "$(dirname "$rival")" - printf 'herdr:%s:wC:p4\t/proj/OLD\tcodex\n' "$HERDR_SOCKET_PATH" > "$rival" - agmsg_role_session_record seatteam elder old-sid /proj/OLD codex + printf 'herdr:%s:wC:p4\t/proj/OLD\tclaude-code\n' "$HERDR_SOCKET_PATH" > "$rival" + agmsg_role_session_record seatteam elder old-sid /proj/OLD claude-code agmsg_role_session_mark_named seatteam elder "herdr:$HERDR_SOCKET_PATH:wC:p4" 1 local mine; mine="$(agmsg_spawn_path seatteam resumer)" @@ -3887,6 +3887,34 @@ _tmux_op_args() { cmp -s "$ghost_role" <(printf 'session=ghost-sid\nname=x\nteam=x\nagent=x\ntype=codex\nproject=/proj/GHOST\n') } +@test "placement guard: a Codex claimant is never read as gone from herdr's agent list (it cannot answer for those threads)" { + export HERDR_ENV=1 HERDR_PANE_ID='wC:p4' + unset TMUX TMUX_PANE + export AGMSG_TERMINAL_DRIVER=herdr + source "$SKILL_DIR/scripts/lib/actas-lock.sh" + source "$SKILL_DIR/scripts/lib/role-session.sh" + + local rival; rival="$(agmsg_spawn_path seatteam elder)" + mkdir -p "$(dirname "$rival")" + printf 'herdr:%s:wC:p4\t/proj/OLD\tcodex\n' "$HERDR_SOCKET_PATH" > "$rival" + agmsg_role_session_record seatteam elder old-sid /proj/OLD codex + agmsg_role_session_mark_named seatteam elder "herdr:$HERDR_SOCKET_PATH:wC:p4" 1 + local mine; mine="$(agmsg_spawn_path seatteam resumer)" + + # Same lists that let a non-Codex claimant be taken over: the claimant's + # session is absent, the asking seat's own is listed at the pane. + _fake_herdr_list_two_sessions "" "" my-sid wC:p4 + run agmsg_terminal_name_self "my-sid" seatteam resumer /proj/MINE codex record + [ "$status" -eq 0 ] + grep -q "recorded as seatteam__elder's" <<<"$output" + # Not a silent refusal: the reason and the way out are both printed. + grep -q "Codex seat" <<<"$output" + grep -q "cannot be read from the terminal" <<<"$output" + grep -q "drop or despawn that seat" <<<"$output" + refute test -e "$mine" + [ "$(agmsg_role_session_named seatteam elder)" = "$(printf 'herdr:%s:wC:p4\t1' "$HERDR_SOCKET_PATH")" ] +} + # --- #1112: a seat identifies its own pane by its LABEL ------------------------ # From ed091641083c832d40da2156efd666b00bd3d3e7 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 09:38:48 +0900 Subject: [PATCH 66/97] [skip ci] docs: agmsgd bypass-mode delivery and 1.5.x upgrade notes (#1683) * [skip ci] docs: cover bypass-mode delivery and 1.5.x upgrade notes for agmsgd Two gaps in the agmsgd beta section ahead of its 1.6.0 GA: it did not say how a Claude Code seat running in bypass-permissions mode is delivered to, and there was no guidance at all for someone upgrading an existing 1.5.x install about what --update does or does not do to a resident agmsgd or to already-running Codex sessions. * [skip ci] docs: fix stop/disable scoping and style per review (#1683) The upgrade-notes wording told anyone whose daemon was stopped to run daemon start, even someone who had deliberately run daemon disable -- undoing their own choice. Scope the restart/troubleshooting text to agmsgd being currently on, and say what happens when it was turned off on purpose. Also drop the hard-wrap the new paragraphs had picked up, to match every other paragraph in this file (one source line each), and soften the bypass-mode sentence so it describes the labeling agmsgd does rather than guaranteeing delivery outright. --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b107bc349..62ee797db 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Git Bash PATH). There is no PowerShell reimplementation. ### agmsgd beta (1.6.0) -The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue, and sends Claude Code sessions a notice directly through that session's own cross-session messaging socket. A Claude Code seat delivered this way never needs its Monitor watch re-armed. The Claude Code path is macOS/Linux only; a Windows Claude Code seat keeps using Monitor as before. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, take over remote sync, or change how any agent other than Claude Code and Codex receives messages. macOS, Linux, and Windows are supported for Codex. Windows requires native Codex 0.157.0 or later; earlier versions are blocked with a reason. Native queue execution and delivery to a logged-in live Windows session were measured with Codex 0.157.0 and 0.160.0. Unreadable version or delivery evidence keeps the existing conservative handling. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. +The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue, and sends Claude Code sessions a notice directly through that session's own cross-session messaging socket. It detects whether that Claude Code session is running in bypass-permissions mode and labels notices for that mode automatically, with no action from you. A Claude Code seat delivered this way never needs its Monitor watch re-armed. The Claude Code path is macOS/Linux only; a Windows Claude Code seat keeps using Monitor as before, even with agmsgd enabled and running. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, take over remote sync, or change how any agent other than Claude Code and Codex receives messages. macOS, Linux, and Windows are supported for Codex. Windows requires native Codex 0.157.0 or later; earlier versions are blocked with a reason. Native queue execution and delivery to a logged-in live Windows session were measured with Codex 0.157.0 and 0.160.0. Unreadable version or delivery evidence keeps the existing conservative handling. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. Requires Node >= 22.13.0 with `node:sqlite` (the experimental SQLite warning is expected). Install Node from [nodejs.org](https://nodejs.org/en/download). Enable and inspect the beta with: @@ -455,6 +455,12 @@ git pull DB and team configs are preserved. Only scripts and assets are updated. +### Upgrading from 1.5.x + +`--update` does not enable agmsgd. If you have never run `agmsg daemon enable`, nothing changes: agmsgd stays off, and messaging keeps working exactly as it did on 1.5.x. If agmsgd is currently on, an update restarts it on the new version automatically; if `agmsg daemon status` still shows it stopped afterward, run `agmsg daemon start`. If you had turned it off yourself (`agmsg daemon disable`), it stays off through the update, as intended. + +No running Codex session needs to be restarted just because you ran `--update`. Restarting a Codex session is only needed to pick up agmsgd itself — either turning it on for the first time (see "agmsgd beta" above) or dropping back to the bridge after `agmsg daemon disable`. + ## Uninstall An `uninstall.sh` copy ships inside every install, so this works whether you From 558303ba22e76232ef45bbf6fae54f98203f360a Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 05:08:00 +0900 Subject: [PATCH 67/97] Spawn the sync engine's children with windowsHide so they survive the SSH session that started it (#1688) The engine's driver and age spawns pass windowsHide so they keep working after the SSH session that started the engine ends. [skip ci] --- scripts/internal/remote-sync.mjs | 4 ++++ scripts/internal/sync-cipher.mjs | 2 +- tests/remote_sync_engine.test.mjs | 11 +++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index eaf6d2e36..3805bd72c 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -1948,9 +1948,13 @@ function runDriver({ args, label, operation, parse, input, rosterFile, team, bin // holds the lock -- everyone else keeps the pipe, and with it the bounded // failure a pipe gives and an input that never reaches disk. if (holdsRegistryLock) stageInput(input, staging); + // windowsHide: an engine started from an SSH session outlives it, and on + // Windows every child it then creates without this fails to initialise + // (exit 0xC0000142) once the session's console is gone. child = spawn("bash", args, { stdio: [staging.fd === null ? "pipe" : staging.fd, "pipe", "pipe"], env: childEnvironment, + windowsHide: true, }); } catch (error) { // Reached by a staging failure and by a synchronous spawn failure alike. diff --git a/scripts/internal/sync-cipher.mjs b/scripts/internal/sync-cipher.mjs index 9a0223e22..58af667b6 100644 --- a/scripts/internal/sync-cipher.mjs +++ b/scripts/internal/sync-cipher.mjs @@ -484,7 +484,7 @@ export function readNativeAgeIdentity(path) { function runAge(args, input) { const age = process.env.AGMSG_AGE_BIN || "age"; - const result = spawnSync(age, args, { input, maxBuffer: 4 * 1024 * 1024 }); + const result = spawnSync(age, args, { input, maxBuffer: 4 * 1024 * 1024, windowsHide: true }); if (result.error?.code === "ENOENT") { throw new CipherStateError("unsupported_cipher", `age executable "${age}" was not found on PATH (set AGMSG_AGE_BIN to its path)`); diff --git a/tests/remote_sync_engine.test.mjs b/tests/remote_sync_engine.test.mjs index bd5983869..90850eff8 100644 --- a/tests/remote_sync_engine.test.mjs +++ b/tests/remote_sync_engine.test.mjs @@ -4943,3 +4943,14 @@ test("driver() waits out a busy store and retries; anything else is asked once", assert.equal(await readFile(counter, "utf8"), "1"); assert.deepEqual(waits, []); }); + +test("every child the engine creates is spawned with windowsHide, or it fails with 0xC0000142 once the SSH session that started it is gone", async () => { + // The failure exists only on Windows after a disconnect, so CI on other + // platforms cannot observe it; guard the call sites themselves. + for (const [file, call] of [["remote-sync.mjs", "spawn(\"bash\", args, {"], ["sync-cipher.mjs", "spawnSync(age, args, {"]]) { + const source = await readFile(new URL(`../scripts/internal/${file}`, import.meta.url), "utf8"); + const at = source.indexOf(call); + assert.ok(at > 0, `${file}: spawn call not found`); + assert.match(source.slice(at, source.indexOf(");", at)), /windowsHide: true/, `${file}: spawn lacks windowsHide`); + } +}); From e0349112a46a33587fc0a8a2110291fd36501b67 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 05:09:57 +0900 Subject: [PATCH 68/97] Deliver agmsgd's nudges to Claude Code seats on native Windows (#1687) [skip ci] --- scripts/actas-claim.sh | 3 +- .../daemon/channels/claude-code-queue-io.mjs | 43 +++++++++-- scripts/daemon/channels/claude-code-queue.mjs | 20 +++-- .../types/claude-code/_session-start.sh | 1 - scripts/lib/role-session.sh | 8 ++ scripts/watch.sh | 1 - tests/agmsgd_claude_code_queue.test.mjs | 76 ++++++++++++++++++- tests/test_role_session.bats | 14 ++++ ...sion_start_claude_code_native_channel.bats | 45 +++++------ 9 files changed, 167 insertions(+), 44 deletions(-) diff --git a/scripts/actas-claim.sh b/scripts/actas-claim.sh index ecb365910..8fd6c105c 100755 --- a/scripts/actas-claim.sh +++ b/scripts/actas-claim.sh @@ -155,8 +155,7 @@ if [ "$TYPE" = claude-code ] && [ -n "${SKILL_DIR:-}" ] \ command -v _agmsg_detect_platform >/dev/null 2>&1 || . "$SKILL_DIR/scripts/lib/compat.sh" _agmsg_detect_platform agmsg_daemon_read_state - if [ "${AGMSGD_HEALTH:-}" = ready ] && [ "$_agmsg_platform" != msys ] \ - && agmsg_claude_code_native_storage_ready; then + if [ "${AGMSGD_HEALTH:-}" = ready ] && agmsg_claude_code_native_storage_ready; then NATIVE_DELIVERY=1 while IFS= read -r team; do [ -z "$team" ] && continue diff --git a/scripts/daemon/channels/claude-code-queue-io.mjs b/scripts/daemon/channels/claude-code-queue-io.mjs index fc1c45f70..108f92639 100644 --- a/scripts/daemon/channels/claude-code-queue-io.mjs +++ b/scripts/daemon/channels/claude-code-queue-io.mjs @@ -29,16 +29,35 @@ const TRANSCRIPT_CHUNK_BYTES = 256 * 1024; // Only this simple case was measured; a cwd containing a literal "-" was not // tested separately and is not known to collide in practice, so this is not // claimed exhaustive. -export function encodeClaudeProjectDir(cwd) { +// Native Windows (measured 2026-10-21): C:\Users\koich\agmsg-agents\win-test +// -> C--Users-koich-agmsg-agents-win-test, i.e. every character that is not a +// letter, digit or "-" becomes "-". +export function encodeClaudeProjectDir(cwd, platform = process.platform) { if (typeof cwd !== "string" || !cwd) return ""; - return cwd.replace(/\//g, "-"); + return platform === "win32" ? cwd.replace(/[^A-Za-z0-9-]/g, "-") : cwd.replace(/\//g, "-"); } -export function resolveTranscriptPath(claudeConfigDir, project, sessionId) { - if (typeof claudeConfigDir !== "string" || !isAbsolute(claudeConfigDir)) return ""; - if (typeof project !== "string" || !isAbsolute(project)) return ""; +// A path the shell recorded, as a path this process can open. Git Bash writes +// /c/Users/me; node on Windows needs C:/Users/me. Anything else is unchanged, +// and so is every path off Windows. +export function nativePath(value, platform = process.platform) { + if (platform !== "win32" || typeof value !== "string") return value; + const gitBash = value.match(/^\/([A-Za-z])(?:\/(.*))?$/); + return gitBash ? `${gitBash[1].toUpperCase()}:/${gitBash[2] ?? ""}` : value; +} + +export function isNamedPipePath(value) { + return typeof value === "string" && /^\\\\[.?]\\pipe\\[^\\/\0]/i.test(value); +} + +export function resolveTranscriptPath(claudeConfigDirRaw, projectRaw, sessionId, platform = process.platform) { + const claudeConfigDir = nativePath(claudeConfigDirRaw, platform); + const project = nativePath(projectRaw, platform); + const absolute = platform === "win32" ? winAbsolute : isAbsolute; + if (typeof claudeConfigDir !== "string" || !absolute(claudeConfigDir)) return ""; + if (typeof project !== "string" || !absolute(project)) return ""; if (typeof sessionId !== "string" || !sessionId) return ""; - const encoded = encodeClaudeProjectDir(project); + const encoded = encodeClaudeProjectDir(project, platform); if (!encoded) return ""; const expected = `${claudeConfigDir}/projects/${encoded}/${sessionId}.jsonl`; if (isFile(expected)) return expected; @@ -62,6 +81,11 @@ export function resolveTranscriptPath(claudeConfigDir, project, sessionId) { return found.length === 1 ? found[0] : expected; } +// A drive path (C:\x or C:/x) or a UNC one; the POSIX test does not apply. +function winAbsolute(value) { + return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]/.test(value); +} + function isFile(path) { try { return lstatSync(path).isFile(); @@ -75,7 +99,12 @@ function isFile(path) { // record must never be written to on trust alone. A symlinked path is // refused the same way every other agmsg state-file reader on this codebase // refuses one. -export function checkSocketOwnership(socketPath) { +export function checkSocketOwnership(socketPath, platform = process.platform) { + // A named pipe (native Windows) is not a file: every filesystem probe of it + // (stat, access, exists) can open it, which connects to the seat's inbox. + // Nothing is asked of it here; a pipe that is gone shows as ENOENT when the + // nudge connects, and the caller treats that as the socket being gone. + if (platform === "win32" && isNamedPipePath(socketPath)) return { state: "ok" }; let stat; try { stat = lstatSync(socketPath); diff --git a/scripts/daemon/channels/claude-code-queue.mjs b/scripts/daemon/channels/claude-code-queue.mjs index c964d5738..621b214e5 100644 --- a/scripts/daemon/channels/claude-code-queue.mjs +++ b/scripts/daemon/channels/claude-code-queue.mjs @@ -151,8 +151,13 @@ export function createClaudeCodeQueueChannel({ socketAlive = checkSocketOwnership, hostPlatform = process.platform, readMode = latestPermissionMode, + transcriptPathOf = resolveTranscriptPath, }) { let stopped = false; + // Sockets a nudge found gone when it connected. A named pipe cannot be + // checked beforehand, so without this each poll would add a queue row that + // fails at once until the seat records another pipe. + const goneSockets = new Set(); let activePoll = null; async function pollSeat(registration, paths) { @@ -181,7 +186,7 @@ export function createClaudeCodeQueueChannel({ const session = readSession(join(installRoot, "run"), team, agent); const record = session.state === "present" ? session.record : null; const transcriptNow = record - ? resolveTranscriptPath(record.claude_config_dir, record.project, record.session) + ? transcriptPathOf(record.claude_config_dir, record.project, record.session, hostPlatform) : ""; // The nudge is out of date when its socket is gone, or the seat's record // now names another socket or session (/compact, restart, --resume). @@ -217,10 +222,6 @@ export function createClaudeCodeQueueChannel({ } } if (!registered) return; - if (hostPlatform === "win32") { - saveSeat(db, { seat, state: "blocked", reason: "windows_not_supported_yet" }, now); - return; - } const session = readSession(join(installRoot, "run"), team, agent); if (session.state !== "present") { saveSeat(db, { seat, state: "unaddressable", reason: session.reason ?? "role_session_missing" }, now); @@ -287,7 +288,7 @@ export function createClaudeCodeQueueChannel({ return; } - const transcriptPath = resolveTranscriptPath(record.claude_config_dir, record.project, record.session); + const transcriptPath = transcriptPathOf(record.claude_config_dir, record.project, record.session, hostPlatform); if (!transcriptPath) { saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: "transcript_path_unresolvable" }, now); return; @@ -296,7 +297,7 @@ export function createClaudeCodeQueueChannel({ // not written its new one yet) has nowhere to be nudged. Make no queue // row for it: each poll would add one that expires at once. Once the seat // records a socket that exists, this passes and the nudge goes out. - if (socketAlive(record.messaging_socket).reason === "messaging_socket_missing") { + if (socketAlive(record.messaging_socket).reason === "messaging_socket_missing" || goneSockets.has(record.messaging_socket)) { saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "unaddressable", reason: "messaging_socket_missing" }, now); return; } @@ -315,6 +316,11 @@ export function createClaudeCodeQueueChannel({ const result = await send({ socketPath: record.messaging_socket, nonce, body: inboxNudge(nonce), token: record.messaging_token, fromMode: mode === "bypass" ? "bypass" : "", timeoutMs }); if (result.state !== "sent") { setQueueState(db, id, "expired"); + if (result.reason === "socket_error:ENOENT") { + goneSockets.add(record.messaging_socket); + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "unaddressable", reason: "messaging_socket_missing" }, now); + return; + } saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `send_failed:${result.reason}` }, now); return; } diff --git a/scripts/drivers/types/claude-code/_session-start.sh b/scripts/drivers/types/claude-code/_session-start.sh index aa82b49cf..f55e51c03 100644 --- a/scripts/drivers/types/claude-code/_session-start.sh +++ b/scripts/drivers/types/claude-code/_session-start.sh @@ -42,7 +42,6 @@ agmsg_session_start() { . "${SKILL_DIR:-}/scripts/lib/compat.sh" fi _agmsg_detect_platform - [ "$_agmsg_platform" != msys ] || return 0 if ! declare -F agmsg_daemon_read_state >/dev/null 2>&1; then # shellcheck disable=SC1091 . "${SKILL_DIR:-}/scripts/lib/daemon-state.sh" diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index 8d4dd040b..baecfed8d 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -539,11 +539,19 @@ agmsg_claude_code_native_target() { case "$raw" in uds:/*) socket="${raw#uds:}" ;; /*) socket="$raw" ;; + # Native Windows: a named pipe, \\.\pipe\LOCAL\cc-msg- + uds:\\\\*) socket="${raw#uds:}" ;; + \\\\*) socket="$raw" ;; esac case "$socket" in *[[:cntrl:]]*) socket="" ;; esac + # Native Windows sets no CLAUDE_CONFIG_DIR; its default is under the user + # profile, which a Git Bash hook sees as $HOME (/c/Users/me) or only as + # %USERPROFILE% (C:\Users\me). + [ -n "$config_dir" ] || config_dir="${USERPROFILE:+$USERPROFILE/.claude}" case "$config_dir" in *[[:cntrl:]]*) config_dir="" ;; /*) ;; + [A-Za-z]:[\\/]*) ;; *) config_dir="" ;; esac [ -n "$socket" ] && [ -n "$config_dir" ] || return 1 diff --git a/scripts/watch.sh b/scripts/watch.sh index ea14588b2..6eeb782e6 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -1256,7 +1256,6 @@ _agmsg_watch_native_delivery_active() { agmsg_daemon_read_state [ "${AGMSGD_DESIRED:-}" = on ] && [ "${AGMSGD_HEALTH:-}" = ready ] || return 1 declare -F _agmsg_detect_platform >/dev/null 2>&1 && _agmsg_detect_platform - [ "${_agmsg_platform:-}" != msys ] || return 1 declare -F agmsg_claude_code_native_storage_ready >/dev/null 2>&1 || return 1 agmsg_claude_code_native_storage_ready || return 1 local bare_sid="$SESSION_ID" diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index 8606c1d88..06246bc57 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -11,6 +11,10 @@ import { resolveTranscriptPath, resolvePendingDelivery, latestPermissionMode, + encodeClaudeProjectDir, + nativePath, + isNamedPipePath, + checkSocketOwnership, } from '../scripts/daemon/channels/claude-code-queue-io.mjs'; import { createClaudeCodeQueueChannel } from '../scripts/daemon/channels/claude-code-queue.mjs'; import { ensureClaudeCodeChannelSchema } from '../scripts/daemon/channels/claude-code-queue-store.mjs'; @@ -168,7 +172,7 @@ test('resolvePendingDelivery: nothing but a delivered nonce ends a pending nudge assert.equal(resolvePendingDelivery({ observation: { state: 'delivered' } }).state, 'confirmed'); }); -function channelFixture(t) { +function channelFixture(t, hostPlatform = 'linux') { const root = temporaryDirectory(t); mkdirSync(path.join(root, 'run')); const db = openInstallDb(path.join(root, 'run', 'install.db')); @@ -189,7 +193,8 @@ function channelFixture(t) { readMode: () => state.mode ?? '', observe: () => (state.hold ? { state: 'held' } : { state: 'absent' }), socketAlive: () => state.alive, - hostPlatform: 'linux', + hostPlatform, + transcriptPathOf: hostPlatform === 'win32' ? () => path.join(root, 'session.jsonl') : undefined, }; state.options = options; const channel = createClaudeCodeQueueChannel(options); @@ -357,3 +362,70 @@ test('a conversation stored under another project directory is found by its sess writeFileSync(expected, ''); assert.equal(resolveTranscriptPath(config, '/proj/recorded', session), expected, 'the recorded project wins when it has the file'); }); + +// Native Windows (measured on a real machine 2026-10-21): the inbox is the named +// pipe \\.\pipe\LOCAL\cc-msg-, the profile has no CLAUDE_CONFIG_DIR, and +// the transcript directory is the project path with every character that is not +// a letter, digit or "-" turned into "-". +test('Windows: the transcript directory name, shell-style paths and pipe paths are read the way Claude Code writes them', () => { + assert.equal(encodeClaudeProjectDir('C:\\Users\\koich\\agmsg-agents\\win-test', 'win32'), 'C--Users-koich-agmsg-agents-win-test'); + assert.equal(encodeClaudeProjectDir('C:/Users/koich/agmsg-agents/win-test', 'win32'), 'C--Users-koich-agmsg-agents-win-test'); + assert.equal(encodeClaudeProjectDir('/home/u/a_b.c', 'linux'), '-home-u-a_b.c'); + assert.equal(nativePath('/c/Users/koich/.claude', 'win32'), 'C:/Users/koich/.claude'); + assert.equal(nativePath('/c', 'win32'), 'C:/'); + assert.equal(nativePath('C:\\Users\\x', 'win32'), 'C:\\Users\\x'); + assert.equal(nativePath('/c/Users/x', 'linux'), '/c/Users/x'); + assert.equal(isNamedPipePath('\\\\.\\pipe\\LOCAL\\cc-msg-ab12'), true); + assert.equal(isNamedPipePath('/tmp/cc-socks/1.sock'), false); + assert.equal(isNamedPipePath('\\\\.\\pipe\\'), false); + // A pipe is never probed: no filesystem call can be made on this name, so the + // answer is "unknown, go on" for a name that is not there. + assert.deepEqual(checkSocketOwnership('\\\\.\\pipe\\LOCAL\\cc-msg-not-here', 'win32'), { state: 'ok' }); +}); + +test('Windows: a seat is no longer blocked as unsupported', async (t) => { + const { db, channel, state } = channelFixture(t, 'win32'); + await channel.pollOnce(); + assert.equal(state.sent, 1); + assert.equal(db.prepare('SELECT COUNT(*) AS n FROM beta_claude_seat WHERE reason = ?').get('windows_not_supported_yet').n, 0); + await channel.stop(); +}); + +test('a nudge that finds its socket gone (ENOENT on connect) ends the row and is not retried for that socket', async (t) => { + const { db, channel, state } = channelFixture(t, 'win32'); + const options = state.options; + let calls = 0; + const gone = createClaudeCodeQueueChannel({ ...options, send: async () => { calls += 1; return { state: 'failed', reason: 'socket_error:ENOENT' }; } }); + for (let i = 0; i < 4; i += 1) await gone.pollOnce(); + assert.equal(calls, 1, 'one attempt, then the seat waits for a new socket'); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM beta_claude_queue").get().n, 1); + assert.equal(db.prepare("SELECT reason FROM beta_claude_seat").get().reason, 'messaging_socket_missing'); + state.socket = '/tmp/cc-socks/9.sock'; + await gone.pollOnce(); + assert.equal(calls, 2); + await gone.stop(); + await channel.stop(); +}); + +// Only on a Windows machine: a real pipe, probed by the preflight, then nudged. +test('Windows pipe: the preflight makes no connection, and an authenticated nudge is delivered', { skip: process.platform !== 'win32' }, async (t) => { + const name = `\\\\.\\pipe\\LOCAL\\agmsg-test-${process.pid}`; + let connections = 0; + const lines = []; + const server = createServer((connection) => { + connections += 1; + let data = ''; + connection.on('data', (chunk) => { data += chunk; }); + connection.on('end', () => lines.push(...data.trim().split('\n'))); + }); + await new Promise((resolve) => server.listen(name, resolve)); + t.after(() => server.close()); + assert.deepEqual(checkSocketOwnership(name, 'win32'), { state: 'ok' }); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(connections, 0, 'the preflight connected to the pipe'); + const result = await sendClaudeCodeMessage({ socketPath: name, nonce: 'w1', body: '[agmsg:w1] hi', token: 'tok-w' }); + assert.equal(result.state, 'sent'); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(connections, 1); + assert.deepEqual(JSON.parse(lines[0]), { type: 'auth', token: 'tok-w' }); +}); diff --git a/tests/test_role_session.bats b/tests/test_role_session.bats index 1f8de2c0c..939c60a87 100644 --- a/tests/test_role_session.bats +++ b/tests/test_role_session.bats @@ -311,3 +311,17 @@ fake_session() { agmsg_role_session_set_messaging T alice /tmp/cc-socks/444.sock /home/x/.claude sid-2 [ -z "$(agmsg_role_session_get T alice messaging_token)" ] } + +@test "native target: a Windows named pipe and the profile directory under USERPROFILE are accepted" { + run env CLAUDE_CODE_MESSAGING_SOCKET='\\.\pipe\LOCAL\cc-msg-ab12' USERPROFILE='C:\Users\me' HOME= CLAUDE_CONFIG_DIR= \ + bash -c ". '$TEST_SKILL_DIR/scripts/lib/role-session.sh'; agmsg_claude_code_native_target" + [ "$status" -eq 0 ] + [ "$output" = $'\\\\.\\pipe\\LOCAL\\cc-msg-ab12\tC:\\Users\\me/.claude' ] + run env CLAUDE_CODE_MESSAGING_SOCKET='uds:\\.\pipe\LOCAL\cc-msg-ab12' CLAUDE_CONFIG_DIR='/c/Users/me/.claude' \ + bash -c ". '$TEST_SKILL_DIR/scripts/lib/role-session.sh'; agmsg_claude_code_native_target" + [ "$status" -eq 0 ] + [ "$output" = $'\\\\.\\pipe\\LOCAL\\cc-msg-ab12\t/c/Users/me/.claude' ] + run env CLAUDE_CODE_MESSAGING_SOCKET='relative.sock' CLAUDE_CONFIG_DIR='/c/Users/me/.claude' \ + bash -c ". '$TEST_SKILL_DIR/scripts/lib/role-session.sh'; agmsg_claude_code_native_target" + [ "$status" -ne 0 ] +} diff --git a/tests/test_session_start_claude_code_native_channel.bats b/tests/test_session_start_claude_code_native_channel.bats index 6d8c90df9..9ad919427 100644 --- a/tests/test_session_start_claude_code_native_channel.bats +++ b/tests/test_session_start_claude_code_native_channel.bats @@ -224,19 +224,14 @@ _mark_daemon_ready() { [ "$(agmsg_role_session_uuid team bob)" = shared ] } -# #1577 review: agmsgd's own Claude Code channel does not send to a Windows -# seat at all yet (named pipe + mandatory auth line is separate work), so the -# plug must never skip Monitor there even with a daemon that is otherwise -# ready and a role-session record that otherwise round-trips cleanly. Faking -# `uname -s` for the whole session-start.sh run (as the full integration tests -# above do for ready/not-ready) is unusable here: compat.sh's OWN platform -# branches change unrelated behavior under a faked Windows uname too, which -# would make this pass or fail for a confounded reason rather than the -# platform check this test exists to pin. Isolate it instead: stub every -# collaborator agmsg_session_start calls so the only real logic under test is -# its own final gate, and set _agmsg_platform directly (compat.sh's own memo -# variable, read, never recomputed, once non-empty). -@test "session-start plug: the Monitor-skip gate itself never fires on Windows, in isolation" { +# Native Windows: the inbox is a named pipe (\\.\pipe\LOCAL\cc-msg-) and +# Claude Code sets no CLAUDE_CONFIG_DIR, so the profile directory comes from +# USERPROFILE. A seat there is served by agmsgd like any other: the plug +# records the pipe and the token and skips the Monitor directive. Isolated from +# compat.sh's own platform branches: every collaborator is stubbed and +# _agmsg_platform is set directly, so the only logic under test is the plug's +# own. +@test "session-start plug: on Windows a named-pipe seat is declared native, with its token recorded" { run bash -c ' set -uo pipefail SKILL_DIR="'"$TEST_SKILL_DIR"'" @@ -244,27 +239,29 @@ _mark_daemon_ready() { TYPE="claude-code" SESSION_ID="sid-1" PAIRS="T alice" - CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/1.sock" - CLAUDE_CONFIG_DIR="/home/x/.claude" - # Stubs: every collaborator reports "this pair is fully addressable" -- - # the one thing NOT stubbed is _agmsg_platform/_agmsg_detect_platform and - # agmsg_daemon_read_state, which together are what this test is pinning. - agmsg_role_session_set_messaging() { :; } - agmsg_role_session_get() { printf "%s" "$3" | grep -q socket && echo "/tmp/cc-socks/1.sock" || echo "/home/x/.claude"; } + CLAUDE_CODE_MESSAGING_SOCKET='"'"'\\.\pipe\LOCAL\cc-msg-ab12'"'"' + CLAUDE_CODE_MESSAGING_TOKEN="tok-1" + unset CLAUDE_CONFIG_DIR + HOME="" + USERPROFILE="C:\\Users\\me" + . "$SKILL_DIR/scripts/lib/role-session.sh" + agmsg_role_session_publish_messaging() { printf "%s|%s|%s" "$3" "$4" "$6" > "'"$BATS_TEST_TMPDIR"'/published"; return 0; } + agmsg_role_session_get() { echo present; } agmsg_role_session_uuid() { echo "sid-1"; } actas_lock_claim() { echo "ok"; } agmsg_instance_bare_sid() { printf "%s" "$1"; } agmsg_normalize_instance_id() { echo "sid-1.4242"; } agmsg_instance_is_composite() { case "$1" in *.*) return 0 ;; *) return 1 ;; esac; } agmsg_daemon_read_state() { AGMSGD_HEALTH=ready; } + agmsg_claude_code_native_storage_ready() { return 0; } + agmsg_daemon_claude_seat_held() { return 1; } _agmsg_platform=msys - _agmsg_detect_platform() { :; } # already set -- compat.sh itself would also no-op here + _agmsg_detect_platform() { :; } SKILL_DIR="$SKILL_DIR" . "'"$TEST_SKILL_DIR"'/scripts/drivers/types/claude-code/_session-start.sh" agmsg_session_start - echo "FELL THROUGH (correct: Windows must not skip Monitor)" ' + grep -qF "will deliver to this session directly" <<<"$output" [ "$status" -eq 0 ] - grep -qF "FELL THROUGH" <<<"$output" - refute grep -q "agmsgd is running and will deliver" <<<"$output" + [ "$(cat "$BATS_TEST_TMPDIR/published")" = '\\.\pipe\LOCAL\cc-msg-ab12|C:\Users\me/.claude|tok-1' ] } From d90e99048aa1742959d9efc0cb1c62a19e6e1771 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 05:15:33 +0900 Subject: [PATCH 69/97] Wait out a store SQLite could not open for a moment instead of letting the engine die (#1689) read-prepare waits and retries when SQLite cannot open a file inside its transaction script, within the busy-store budget; other operations keep failing on the first attempt. [skip ci] --- scripts/internal/remote-sync.mjs | 20 +++++++++++++++++--- tests/remote_sync_engine.test.mjs | 27 +++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index 3805bd72c..00488c444 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -2090,6 +2090,9 @@ function runDriver({ args, label, operation, parse, input, rosterFile, team, bin // storage adapter's "busy" exit, see `driver` -- without parsing its own // sentence back out of the message. error.driverExitCode = code; + // The driver's own words, apart from the label/team/path context in the + // message, for the one caller that classifies a failure by them. + error.driverStderr = stderr; fail(error); }); child.on("close", () => { @@ -2122,6 +2125,16 @@ function runDriver({ args, label, operation, parse, input, rosterFile, team, bin // is a decision (a failed check, an unsupported operation) and asking again // does not change it, so nothing else is retried here. export const STORAGE_BUSY_EXIT = 11; +// SQLite could not open a file it needed (the store, or a temporary file) -- +// seen on Windows when something else held or removed it for a moment. Waited +// out like a busy store, but only where repeating the call is known to be safe: +// read-prepare, whose "Error near line N:" form names a line of its one +// BEGIN IMMEDIATE ... COMMIT script (it failed before the COMMIT, so nothing +// was committed), and whose read after the COMMIT is a separate statement that +// reports the plain "Error:" form. Other operations (apply, resync, ...) can +// fail after a COMMIT with the same words, so they stay a decision. +const STORAGE_OPEN_RETRY_OPERATIONS = new Set(["read-prepare"]); +const STORAGE_OPEN_FAILURE = /^Error near line \d+: unable to open database file/mu; // One wait is never longer than this; the budget is the total. The budget has // to outlast a real writer, not a polite one: the first prepare on a pulled // store held the lock for 63 s on the machine that filed #910 (155 s on a copy @@ -2167,7 +2180,8 @@ export async function driver(operation, config, input, extra = [], dependencies try { return await run(); } catch (error) { - if (error?.driverExitCode !== STORAGE_BUSY_EXIT) throw error; + const busy = error?.driverExitCode === STORAGE_BUSY_EXIT; + if (!busy && !(STORAGE_OPEN_RETRY_OPERATIONS.has(operation) && STORAGE_OPEN_FAILURE.test(error?.driverStderr ?? ""))) throw error; const waitMs = Math.min(STORAGE_BUSY_WAIT_CAP_MS, 1000 * 2 ** (attempt - 1)); if (waitedMs + waitMs > budgetMs) { // The driver's own sentence stays in front; what is added is how long @@ -2175,11 +2189,11 @@ export async function driver(operation, config, input, extra = [], dependencies // "held forever". error.message += ` -- waited ${Math.round(waitedMs / 1000)} s for the store to come free ` + `(the ${budgetMs / 1000} s budget) and it did not`; - error.code = "storage-busy"; + error.code = busy ? "storage-busy" : "storage-unavailable"; error.retryable = true; throw error; } - await eventCall("driver.busy", { operation, attempt, wait_ms: waitMs, waited_ms: waitedMs }); + await eventCall(busy ? "driver.busy" : "driver.unavailable", { operation, attempt, wait_ms: waitMs, waited_ms: waitedMs }); await sleepCall(waitMs); waitedMs += waitMs; } diff --git a/tests/remote_sync_engine.test.mjs b/tests/remote_sync_engine.test.mjs index 90850eff8..8fa57c213 100644 --- a/tests/remote_sync_engine.test.mjs +++ b/tests/remote_sync_engine.test.mjs @@ -4887,7 +4887,7 @@ test("driver() waits out a busy store and retries; anything else is asked once", }; const counter = join(root, "calls"); // A driver that is busy for its first `busyFor` calls and answers after that. - const useDriver = async (name, busyFor, exitWith = STORAGE_BUSY_EXIT) => { + const useDriver = async (name, busyFor, exitWith = STORAGE_BUSY_EXIT, message = "agmsg: sqlite-sync: prepare: the store is busy -- another writer held it") => { const script = join(root, name); await writeFile(script, [ "#!/usr/bin/env bash", @@ -4895,7 +4895,7 @@ test("driver() waits out a busy store and retries; anything else is asked once", `n=$(( $(cat ${shellQuote(counter)} 2>/dev/null || echo 0) + 1 ))`, `printf %s "$n" > ${shellQuote(counter)}`, `if [ "$n" -le ${busyFor} ]; then`, - " echo 'agmsg: sqlite-sync: prepare: the store is busy -- another writer held it' >&2", + ` echo ${shellQuote(message)} >&2`, ` exit ${exitWith}`, "fi", "printf '%s\\n' '{\"type\":\"sync_state\",\"transport_cursor\":\"0\"}'", @@ -4935,6 +4935,29 @@ test("driver() waits out a busy store and retries; anything else is asked once", assert.equal(await readFile(counter, "utf8"), "14"); assert.equal(waits.reduce((sum, ms) => sum + ms, 0), 271000); + // A store that could not be opened for a moment (seen on Windows) is waited + // out like a busy one, so the engine does not die on it. + await useDriver("cannot-open.sh", 1, 13, "Error near line 50: unable to open database file"); + waits.length = 0; + assert.deepEqual(await driver("read-prepare", config, [], [], dependencies), [{ type: "sync_state", transport_cursor: "0" }]); + assert.equal(await readFile(counter, "utf8"), "2"); + assert.deepEqual(waits, [1000]); + + // Another operation can fail after its COMMIT with the same words: a decision. + await useDriver("cannot-open-apply.sh", 1000, 13, "Error near line 50: unable to open database file"); + waits.length = 0; + await assert.rejects(() => driver("apply", config, [], [], dependencies), (error) => + error.driverExitCode === 13 && !isRetryable(error)); + assert.equal(await readFile(counter, "utf8"), "1"); + + // The same words from a statement outside the transaction script (it can + // follow a COMMIT) are a decision: asked once. + await useDriver("open-after-commit.sh", 1000, 13, "Error: unable to open database file"); + waits.length = 0; + await assert.rejects(() => driver("read-prepare", config, [], [], dependencies), (error) => + error.driverExitCode === 13 && !isRetryable(error)); + assert.equal(await readFile(counter, "utf8"), "1"); + // A failed check (13) is a decision: asked once, not retried, not retryable. await useDriver("refuses.sh", 1000, 13); waits.length = 0; From e3e57380073cf48dadf40edd38875c4a172f8450 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 05:19:50 +0900 Subject: [PATCH 70/97] Check agmsgd's liveness from Git Bash with the daemon's own judgement (#1690) [skip ci] --- scripts/daemon/executor.mjs | 29 ++++++++++++++++------------- scripts/daemon/liveness.mjs | 15 +++++++++++++++ scripts/lib/daemon-state.sh | 19 +++++++++++++++++++ tests/agmsgd_executor.test.mjs | 17 ++++++++++++++++- tests/test_agmsgd_switch.bats | 24 ++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 scripts/daemon/liveness.mjs diff --git a/scripts/daemon/executor.mjs b/scripts/daemon/executor.mjs index b1314f7ec..ddcedecf5 100644 --- a/scripts/daemon/executor.mjs +++ b/scripts/daemon/executor.mjs @@ -60,22 +60,25 @@ export function bootId() { return String(Math.round(Date.now() / 1000 - uptime())); } -// A darwin record written before bootId() moved to kern.bootsessionuuid holds -// kern.boottime seconds. The kernel shifts that value whenever the wall clock -// is stepped, so it can neither be compared for equality nor be trusted as -// proof of the same boot: such a record is "cannot tell" while its pid still -// exists. -function isOldDarwinBootId(recorded) { - return platform() === "darwin" && /^\d+$/.test(String(recorded)); -} +// How far two readings of the approximate boot id (win32) may differ and still +// be the same boot: they come from Date.now() minus os.uptime(), which +// wanders by seconds within one boot. +const APPROXIMATE_BOOT_TOLERANCE_SECONDS = 30; // true / false / null (cannot tell) for "`recorded` is this boot's id". -export function sameBoot(recorded) { +// `current` is this boot's id as read now (injectable for tests). +export function sameBoot(recorded, plat = platform(), current = bootId()) { // Checked before the value comparison: when kern.bootsessionuuid cannot // be read, bootId() falls back to a number, which must not make an // old-format record look like an exact match. - if (isOldDarwinBootId(recorded)) return null; - return bootId() === recorded; + if (plat === "darwin" && /^\d+$/.test(String(recorded))) return null; + // The approximate id proves nothing by differing: a gap beyond the tolerance + // (a sleep, a clock step) is "cannot tell", never "another boot". Only a + // record in some other form is a plain mismatch. + if (plat === "win32" && /^\d+$/.test(String(recorded)) && /^\d+$/.test(String(current))) { + return Math.abs(Number(current) - Number(recorded)) <= APPROXIMATE_BOOT_TOLERANCE_SECONDS ? true : null; + } + return current === recorded; } // This process's own executor triple, as of the call. `role` and `pid` are @@ -95,8 +98,8 @@ export function currentExecutor() { // (confirmed dead) without even checking the pid -- this is the one case // where a stale boot id is itself the proof, not a reason to say "cannot // tell". -export function isAlive({ pid, bootId: capturedBootId }) { - const sameBootId = sameBoot(capturedBootId); +export function isAlive({ pid, bootId: capturedBootId }, { plat = platform(), current = bootId() } = {}) { + const sameBootId = sameBoot(capturedBootId, plat, current); if (sameBootId === false) return false; try { process.kill(pid, 0); diff --git a/scripts/daemon/liveness.mjs b/scripts/daemon/liveness.mjs new file mode 100644 index 000000000..295654e5a --- /dev/null +++ b/scripts/daemon/liveness.mjs @@ -0,0 +1,15 @@ +// Prints "alive", "dead" or "unknown" for a recorded executor triple, using the +// same judgement as `agmsg daemon status` (isAlive in executor.mjs). Used by +// shell code on a platform where it cannot gather that evidence itself. +// +// usage: node liveness.mjs +import { isAlive } from "./executor.mjs"; + +const [pidText, bootId] = process.argv.slice(2); +const pid = /^[1-9][0-9]*$/.test(pidText ?? "") ? Number(pidText) : 0; +if (!pid || !bootId) { + console.log("unknown"); +} else { + const alive = isAlive({ pid, bootId }); + console.log(alive === true ? "alive" : alive === false ? "dead" : "unknown"); +} diff --git a/scripts/lib/daemon-state.sh b/scripts/lib/daemon-state.sh index 836075ae0..835fc81c9 100644 --- a/scripts/lib/daemon-state.sh +++ b/scripts/lib/daemon-state.sh @@ -42,6 +42,25 @@ agmsg_daemon_read_state() { [ "$hz" -gt 0 ] && started_epoch=$((current_boot + ticks / hz)) ;; esac ;; + MINGW*|MSYS*|CYGWIN*) + # Git Bash has no boot id or process start time to read. Ask the daemon's + # own judgement (the one `agmsg daemon status` gives) through the node it + # runs on, instead of repeating that evidence in shell. + local node_path verdict + node_path="$(sqlite3 -readonly -cmd '.timeout 100' "$_AGMSG_DAEMON_ROOT/run/install.db" 'SELECT node_path FROM meta;' 2>/dev/null | tr -d '\r')" || node_path="" + if [ -z "$node_path" ] || [ ! -x "$node_path" ] || [ ! -f "$_AGMSG_DAEMON_ROOT/scripts/daemon/liveness.mjs" ]; then + AGMSGD_HEALTH=unchecked + AGMSGD_REASON="the daemon's node cannot be run from here to check it" + return 0 + fi + verdict="$("$node_path" "$_AGMSG_DAEMON_ROOT/scripts/daemon/liveness.mjs" "$pid" "$boot" 2>/dev/null | tr -d '\r')" || verdict="" + case "$verdict" in + alive) AGMSGD_HEALTH=ready; AGMSGD_REASON="" ;; + dead) AGMSGD_REASON="executor missing or boot/start evidence does not match ($AGMSGD_REASON)" ;; + *) AGMSGD_HEALTH=unchecked; AGMSGD_REASON="the executor's liveness cannot be read from here" ;; + esac + return 0 + ;; *) AGMSGD_REASON="platform liveness check unavailable"; return 0 ;; esac # A macOS record written before the boot id became kern.bootsessionuuid diff --git a/tests/agmsgd_executor.test.mjs b/tests/agmsgd_executor.test.mjs index e7d99387d..35e6bd6bf 100644 --- a/tests/agmsgd_executor.test.mjs +++ b/tests/agmsgd_executor.test.mjs @@ -4,7 +4,7 @@ import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { bootId, currentExecutor, isAlive } from "../scripts/daemon/executor.mjs"; +import { bootId, currentExecutor, isAlive, sameBoot } from "../scripts/daemon/executor.mjs"; test("bootId is stable across calls and isAlive tells living/dead/wrong-boot apart", () => { assert.equal(bootId(), bootId(), "bootId must not change within the same boot"); @@ -57,3 +57,18 @@ test("bootId is stable across calls and isAlive tells living/dead/wrong-boot apa } } }); + +// The Windows boot id is Date.now() minus os.uptime(): two readings in one boot +// differ by a second or so, and a sleep can move them further. A live process +// must not be called dead for that. +test("the approximate (win32) boot id tolerates drift and never proves death by differing", () => { + const self = currentExecutor(); + assert.equal(sameBoot("99900", "win32", "99900"), true); + assert.equal(sameBoot("99900", "win32", "99899"), true, "a second of drift is the same boot"); + assert.equal(isAlive({ pid: self.pid, bootId: "99900" }, { plat: "win32", current: "99899" }), true, "a live pid across drift is alive"); + assert.equal(sameBoot("99900", "win32", "99000"), null, "a large gap cannot tell"); + assert.equal(isAlive({ pid: self.pid, bootId: "99900" }, { plat: "win32", current: "99000" }), null, "a live pid across a large gap cannot tell"); + const dead = spawnSync(process.execPath, ["-e", "process.exit(0)"]); + assert.equal(isAlive({ pid: dead.pid, bootId: "99900" }, { plat: "win32", current: "99000" }), false, "a pid that is gone is dead"); + assert.equal(sameBoot("another-boot", "win32", "99900"), false, "a record in another form is a plain mismatch"); +}); diff --git a/tests/test_agmsgd_switch.bats b/tests/test_agmsgd_switch.bats index b8f306f6e..cc1589a06 100644 --- a/tests/test_agmsgd_switch.bats +++ b/tests/test_agmsgd_switch.bats @@ -287,6 +287,30 @@ WRAPPER [ "$output" = stopped ] } +@test "Git Bash (no boot id or start time to read) takes the daemon's own liveness judgement" { + local boot node + case "$(uname -s)" in + Darwin) boot="$(sysctl -n kern.bootsessionuuid)" ;; + Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; + *) skip 'POSIX beta executor evidence' ;; + esac + node="$(command -v node)" + mkdir -p "$BATS_TEST_TMPDIR/gitbash" + printf '#!/bin/sh\necho MINGW64_NT-10.0-26200\n' > "$BATS_TEST_TMPDIR/gitbash/uname" + chmod +x "$BATS_TEST_TMPDIR/gitbash/uname" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET node_path='$node'; UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='$boot', executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" + run env PATH="$BATS_TEST_TMPDIR/gitbash:$PATH" bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$output" = ready ] + # The same record from another boot is a stopped daemon. + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET executor_boot_id='previous-boot';" + run env PATH="$BATS_TEST_TMPDIR/gitbash:$PATH" bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$output" = stopped ] + # A node that cannot be run is "cannot check", never "stopped". + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET node_path='$BATS_TEST_TMPDIR/no-such-node'; UPDATE daemon_owner SET executor_boot_id='$boot';" + run env PATH="$BATS_TEST_TMPDIR/gitbash:$PATH" bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$output" = unchecked ] +} + @test "all enabled failure causes produce recovery notices without losing messages" { for reason in 'restart limit reached' 'no usable Node recorded' 'repeated startup failures' crash; do sqlite3 "$TEST_SKILL_DIR/run/install.db" "DELETE FROM daemon_start_attempts; INSERT INTO daemon_start_attempts VALUES ('2099-01-01','$reason',0);" From 923a826ab595c1b8c780b21d93140e1acb46c359 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 05:25:55 +0900 Subject: [PATCH 71/97] Survive a power loss: flush roster state, set aside an unreadable copy, reclaim an interrupted lock release (#1691) The roster state file is flushed before it is renamed, an unreadable one is set aside and rebuilt, and an interrupted lock release with a single readable same-machine staged copy of a dead owner is reclaimed. [skip ci] --- scripts/internal/roster-sync.mjs | 48 +++++++++++++++++++--- scripts/lib/registry-lock.sh | 69 +++++++++++++++++++++++++++++++- scripts/remote.sh | 7 ++++ tests/roster_sync.test.mjs | 11 ++++- tests/test_registry_lock.bats | 34 ++++++++++++++++ tests/test_remote_refusal.bats | 4 ++ 6 files changed, 165 insertions(+), 8 deletions(-) diff --git a/scripts/internal/roster-sync.mjs b/scripts/internal/roster-sync.mjs index 791e924c2..5da239e36 100755 --- a/scripts/internal/roster-sync.mjs +++ b/scripts/internal/roster-sync.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { randomUUID } from "node:crypto"; -import { readFileSync, renameSync, writeFileSync } from "node:fs"; +import { appendFileSync, closeSync, fsyncSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import process from "node:process"; import { sealEnvelope } from "./sync-cipher.mjs"; @@ -28,22 +28,58 @@ function readJournal() { } } +// roster-sync.json holds the pull cursor and the wire ids reserved for roster +// mutations not yet acknowledged. Both can be rebuilt: a cursor of 0 pulls the +// roster again and apply skips mutation ids already in the journal, and a lost +// reservation only makes the next prepare reserve a new wire id for a mutation +// that is still unsynced, which receivers also skip by mutation id. So a file +// that cannot be read is set aside, never deleted, and rebuilt. +function setAside(error) { + const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\.\d+Z$/u, "Z"); + const aside = `${statePath}.corrupt-${stamp}-${process.pid}`; + renameSync(statePath, aside); + const note = `roster sync state ${statePath} was unreadable (${error.message}); kept as ${aside} and rebuilt from the server`; + process.stderr.write(`agmsg: ${note}\n`); + if (process.env.AGMSG_SYNC_LOG_FILE) { + try { + appendFileSync(process.env.AGMSG_SYNC_LOG_FILE, `${JSON.stringify({ + at: new Date().toISOString(), event: "roster.state_reset", file: aside, reason: error.message, + })}\n`, { encoding: "utf8", mode: 0o600 }); + } catch { /* the file set aside is the durable record */ } + } +} + function readState() { + let text; + try { + text = readFileSync(statePath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return { format_version: 1, bindings: {} }; + throw error; + } try { - const state = JSON.parse(readFileSync(statePath, "utf8")); - if (state.format_version !== 1 || typeof state.bindings !== "object") { + const state = JSON.parse(text); + if (state.format_version !== 1 || typeof state.bindings !== "object" || state.bindings === null) { throw new Error("roster sync state is invalid"); } return state; } catch (error) { - if (error.code === "ENOENT") return { format_version: 1, bindings: {} }; - throw error; + setAside(error); + return { format_version: 1, bindings: {} }; } } +// Flushed before the rename: a rename that reaches the disk ahead of the data +// leaves a file of the right size full of NUL bytes after a power loss. function writeAtomic(path, contents) { const temporary = `${path}.tmp.${process.pid}.${randomUUID()}`; - writeFileSync(temporary, contents, { encoding: "utf8", mode: 0o600 }); + const fd = openSync(temporary, "wx", 0o600); + try { + writeFileSync(fd, contents, "utf8"); + fsyncSync(fd); + } finally { + closeSync(fd); + } renameSync(temporary, path); } diff --git a/scripts/lib/registry-lock.sh b/scripts/lib/registry-lock.sh index ed9c2e5d7..d665b2a2b 100644 --- a/scripts/lib/registry-lock.sh +++ b/scripts/lib/registry-lock.sh @@ -188,10 +188,69 @@ _agmsg_lock_break_dead() { return 0 } +# A lock whose release was interrupted: the owner moved its record aside +# (`.holder.releasing.`) and died before the rmdir, so the directory +# stays with no record of its own. The staged copy is not a record, and a +# release that finished normally can leave one too, so it counts only with all +# of these: +# - the staged copy is readable and says this machine, and the pid in its +# name is the pid it records and is not running (a copy that cannot be read +# says nothing about which machine wrote it, so it is left alone: the same +# rule as a holder record from another machine); +# - it is the only staged copy there (several cannot be claimed as one); +# - the directory is more than a minute old, so no new owner is between its +# mkdir and writing its record; +# - the directory is not newer than the staged copy, which keeps the time its +# record was written at acquire, so the directory is the one that record +# belonged to and no later owner created it. +# A recycled pid reads as alive, which leaves the lock alone, the safe direction. +# +# CLAIM FIRST, JUDGE SECOND, as _agmsg_lock_break_dead does: the staged copy is +# renamed away (one breaker wins that rename), and only then are the age and +# generation checked against the claimed copy. Once it is claimed nobody else can +# legitimately remove this directory, so the rmdir below cannot reach a successor +# that a check made before the claim would have missed. +_agmsg_lock_break_interrupted_release() { + local lock="$1" staged claimed pid host recorded count=0 only="" + [ ! -e "$lock.holder" ] || return 1 + _agmsg_lock_load_liveness || return 1 + # Exactly one staged copy. Breakers can only compete for the same claim + # object when there is one; with several (normal releases leave them too) each + # could claim a different one and both go on to remove the directory. + for staged in "$lock".holder.releasing.*; do + [ -f "$staged" ] || continue + count=$((count + 1)); only="$staged" + done + [ "$count" = 1 ] || return 1 + for staged in "$only"; do + pid="${staged##*.holder.releasing.}" + claimed="$lock.dead.$$.${RANDOM:-0}" + mv "$staged" "$claimed" 2>/dev/null || continue + recorded="$(sed -n 's/^pid //p' "$claimed" 2>/dev/null | head -1)" + host="$(sed -n 's/^host //p' "$claimed" 2>/dev/null | head -1)" + if _agmsg_pid_valid "$pid" 2147483647 && [ "$recorded" = "$pid" ] \ + && [ -n "$host" ] && _agmsg_lock_same_host "$host" \ + && ! _agmsg_pid_alive_local "$pid" \ + && [ ! -e "$lock.holder" ] \ + && [ -n "$(find "$lock" -maxdepth 0 -mmin +1 2>/dev/null)" ] \ + && [ ! "$lock" -nt "$claimed" ] \ + && rmdir "$lock" 2>/dev/null; then + if command -v rm >/dev/null 2>&1; then + rm -f "$claimed" "$lock".holder.releasing.* 2>/dev/null || true + fi + return 0 + fi + # Not proven: the copy goes back where it was, unless something else has + # taken that name meanwhile. + if [ ! -e "$staged" ]; then mv "$claimed" "$staged" 2>/dev/null || true; fi + done + return 1 +} + agmsg_lock_acquire() { local team_dir="$1" lock i=0 max="${AGMSG_LOCK_TRIES:-1000}" err="" local budget="${AGMSG_LOCK_SECONDS:-10}" started elapsed - local tpid thost q + local tpid thost q staged started="$(date +%s)" lock="$team_dir/.config.lock" until err="$(mkdir "$lock" 2>&1)"; do @@ -233,6 +292,10 @@ agmsg_lock_acquire() { echo "agmsg: broke a registry lock in $team_dir whose recorded holder is gone" >&2 continue fi + if _agmsg_lock_break_interrupted_release "$lock"; then + echo "agmsg: broke a registry lock in $team_dir whose release was interrupted (its owner is gone)" >&2 + continue + fi i=$((i + 1)) elapsed=$(( $(date +%s) - started )) # Whichever bound arrives first, and the message says which — "1000 tries" @@ -282,6 +345,10 @@ agmsg_lock_acquire() { # paste cannot remove anything but an empty lock directory. q="$(printf "'%s'" "$(printf '%s' "$lock" | sed "s/'/'\\''/g")")" echo "agmsg: the lock records no holder, so nothing here could ask whether it is held." >&2 + for staged in "$lock".holder.releasing.*; do + [ -f "$staged" ] || continue + echo "agmsg: a release was interrupted: $staged is what its owner (pid ${staged##*.holder.releasing.}) staged. If that record cannot be read, nothing here can say which machine wrote it, so the lock is kept." >&2 + done echo "agmsg: a lock with no holder record is not broken automatically. If no agmsg command is running for this team, remove it:" >&2 echo "agmsg: rmdir $q" >&2 echo "agmsg: doctor.sh lists such locks." >&2 diff --git a/scripts/remote.sh b/scripts/remote.sh index 60a6bc37a..f939ec889 100644 --- a/scripts/remote.sh +++ b/scripts/remote.sh @@ -2743,6 +2743,13 @@ _remote_status_one() { echo " last fatal: $fatal_msg" fi fi + # A roster state file the engine could not read is set aside and rebuilt; say + # so, so a reset never passes unseen. The set-aside copy is never deleted here. + local _reset_file + for _reset_file in "$TEAMS_DIR/$team"/roster-sync.json.corrupt-*; do + [ -e "$_reset_file" ] || continue + echo " roster state was reset: unreadable copy kept at $_reset_file (delete it once the roster looks right)" + done # Connected, and unable to name anybody. # # `status` could say "engine running" indefinitely while `team.sh` said diff --git a/tests/roster_sync.test.mjs b/tests/roster_sync.test.mjs index ea0fd34b1..b30abdcc3 100644 --- a/tests/roster_sync.test.mjs +++ b/tests/roster_sync.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -142,6 +142,15 @@ test("roster mutations use the message transport and converge by server sequence }]); assert.equal(Object.keys(configA.agents).length, 1); assert.equal(Object.keys(configB.agents).length, 1); + + // A state file left full of NUL bytes by a power loss is set aside, not + // fatal and not deleted, and the next prepare rebuilds it. + await writeFile(join(aRoot, "roster-sync.json"), Buffer.alloc(997)); + call("prepare", a.config, prepare, ["10"]); + const kept = (await readdir(aRoot)).filter((name) => name.startsWith("roster-sync.json.corrupt-")); + assert.equal(kept.length, 1); + assert.equal((await readFile(join(aRoot, kept[0]))).length, 997); + assert.equal(JSON.parse(await readFile(join(aRoot, "roster-sync.json"), "utf8")).format_version, 1); } finally { await rm(scratch, { recursive: true }); } diff --git a/tests/test_registry_lock.bats b/tests/test_registry_lock.bats index ccdbdc72c..f954b14fa 100644 --- a/tests/test_registry_lock.bats +++ b/tests/test_registry_lock.bats @@ -463,6 +463,40 @@ acquire() { # runs the acquire in its own shell, with a short spin budget rm -f "$lock.holder.releasing.$gone" rmdir "$lock" + # The same leftover on a directory that is over a minute old and no newer than + # the staged copy (the release was interrupted): broken when the copy is + # readable, says this machine and names a pid that is not running. A live + # owner's copy, and a copy that cannot be read (it cannot say which machine + # wrote it), keep the lock. + mkdir "$lock" + printf 'token t\npid %s\ncommand t\nhost %s\n' "$live" "$me" > "$lock.holder.releasing.$live" + touch -t 202601010000 "$lock" + touch -t 202601010001 "$lock.holder.releasing.$live" + acquire + [ "$status" -ne 0 ] + refute grep -qF "broke a registry lock" <<<"$output" + rm -f "$lock.holder.releasing.$live" + : > "$lock.holder.releasing.$gone" + touch -t 202601010001 "$lock.holder.releasing.$gone" + acquire + [ "$status" -ne 0 ] + refute grep -qF "broke a registry lock" <<<"$output" + grep -qF "a release was interrupted" <<<"$output" + [ -d "$lock" ] + printf 'token t\npid %s\ncommand t\nhost %s\n' "$gone" "$me" > "$lock.holder.releasing.$gone" + touch -t 202601010001 "$lock.holder.releasing.$gone" + # Two staged copies are ambiguous (two breakers could claim one each): kept. + printf 'token t\npid %s\ncommand t\nhost %s\n' "$((gone + 1))" "$me" > "$lock.holder.releasing.$((gone + 1))" + touch -t 202601010001 "$lock.holder.releasing.$((gone + 1))" + acquire + [ "$status" -ne 0 ] + refute grep -qF "broke a registry lock" <<<"$output" + rm -f "$lock.holder.releasing.$((gone + 1))" + acquire + [ "$status" -eq 0 ] + grep -qF "release was interrupted" <<<"$output" + [ ! -e "$lock.holder.releasing.$gone" ] + # A dead holder on this machine: broken, and the acquire then succeeds. mkdir "$lock" printf 'token t\npid %s\ncommand t\nhost %s\n' "$gone" "$me" > "$lock.holder" diff --git a/tests/test_remote_refusal.bats b/tests/test_remote_refusal.bats index 5f3cc1a00..620c0d1d9 100644 --- a/tests/test_remote_refusal.bats +++ b/tests/test_remote_refusal.bats @@ -189,6 +189,10 @@ write_fatal() { [ "$status" -eq 0 ] printf '%s' "$output" | grep -q 'engine stopped' printf '%s' "$output" | grep -q 'last fatal: age executable not found on PATH' + # A roster state file the engine set aside is named, so a reset is not silent. + : > "$TEST_SKILL_DIR/teams/testteam/roster-sync.json.corrupt-20261021T000000Z-1" + run bash "$SCRIPTS/remote.sh" status testteam + printf '%s' "$output" | grep -q 'roster state was reset: unreadable copy kept at .*roster-sync.json.corrupt-20261021T000000Z-1' } # What `_remote_sync_engine_start_locked` writes as the very first thing From b069b31f7de36697fb7d1099a0a880c288c3e5c0 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 05:33:39 +0900 Subject: [PATCH 72/97] Give the lock-holding driver its input by shell redirect on Windows, not an inherited file handle (#1692) On Windows the child opens the staged input file itself with a shell redirect, because an inherited stdin file handle fails once the SSH session that started the engine is gone. [skip ci] --- scripts/internal/remote-sync.mjs | 48 +++++++++++++++++++++++++------ tests/remote_sync_engine.test.mjs | 16 ++++++++++- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index 00488c444..7345ea82a 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -1829,6 +1829,15 @@ function bindingNote(binding) { // has today, since a driver that stops reading a pipe fails at once while a // driver handed a file is waited for. This is a fix for one caller and it is // scoped to that caller. +// The command line that runs a driver with a staged file as its stdin by +// opening it in the child, rather than handing the child a file handle. +export function stdinRedirect(args, path) { + return { + args: ["-c", 'exec bash "$@" < "$AGMSG_DRIVER_INPUT"', "bash", ...args], + env: { AGMSG_DRIVER_INPUT: path.replace(/\\/gu, "/") }, + }; +} + function stageInput(input, staging) { // Filled IN PLACE, so that one cleanup path can undo whatever part of it got // made. The first version cleaned up inside here as well as at settle, which @@ -1845,6 +1854,7 @@ function stageInput(input, staging) { // A fresh read-only descriptor. Passing on the one the write used would hand // over a descriptor sitting at EOF, and the driver would read an empty input // and report a successful sync of nothing. + staging.path = path; staging.fd = openSync(path, "r"); // Then take the name away immediately, while the descriptor stays open: the // spawn duplicates it into the child, so both sides keep reading a file that @@ -1852,10 +1862,12 @@ function stageInput(input, staging) { // message content behind. Windows cannot unlink an open file, so there it // stays until the call settles -- which is why the directory is still tracked // and removed there rather than only here. + // (On Windows the child opens the file by name, so it is kept there.) + if (process.platform === "win32") return; try { rmSync(staging.directory, { recursive: true, force: true }); staging.directory = null; - } catch { /* Windows, or a tmpdir that will not allow it; settle cleans up */ } + } catch { /* a tmpdir that will not allow it; settle cleans up */ } } // Removing a temp directory must not fail the call around it -- refusing a sync @@ -1927,7 +1939,7 @@ function runDriver({ args, label, operation, parse, input, rosterFile, team, bin // that does. That matters more than it looks: the routes into it that a // test cannot reach are undone by the same lines as the route a test can, // so binding one binds them all. - const staging = { directory: null, fd: null }; + const staging = { directory: null, fd: null, path: null }; const releaseInput = () => { if (staging.fd !== null) { try { closeSync(staging.fd); } catch { /* already closed */ } @@ -1942,20 +1954,40 @@ function runDriver({ args, label, operation, parse, input, rosterFile, team, bin }; let child; + let inputSupplied = false; try { // Staged before the spawn, so before the lock: a failure to stage is a // failure with no child and no lock to leak. Only for the caller that // holds the lock -- everyone else keeps the pipe, and with it the bounded // failure a pipe gives and an input that never reaches disk. if (holdsRegistryLock) stageInput(input, staging); - // windowsHide: an engine started from an SSH session outlives it, and on - // Windows every child it then creates without this fails to initialise - // (exit 0xC0000142) once the session's console is gone. - child = spawn("bash", args, { - stdio: [staging.fd === null ? "pipe" : staging.fd, "pipe", "pipe"], + // On Windows a file handle handed to the child as its stdin fails to + // initialise it (exit 0xC0000142) once the SSH session that started the + // engine is gone, so there the child opens the staged file itself with a + // shell redirect. The file stays (it cannot be unlinked while open, and + // it is still removed when the call settles), the handle is closed, and + // nothing is inherited. + let childArgs = args; + let stdin = staging.fd === null ? "pipe" : staging.fd; + inputSupplied = staging.fd !== null; + if (inputSupplied && process.platform === "win32") { + const redirect = stdinRedirect(args, staging.path); + childArgs = redirect.args; + Object.assign(childEnvironment, redirect.env); + stdin = "ignore"; + } + // windowsHide: the same disconnect fails every child created without it. + child = spawn("bash", childArgs, { + stdio: [stdin, "pipe", "pipe"], env: childEnvironment, windowsHide: true, }); + if (inputSupplied && process.platform === "win32") { + // The child opens the file by name; this handle was only for the + // unlink attempt in stageInput. + try { closeSync(staging.fd); } catch { /* already closed */ } + staging.fd = null; + } } catch (error) { // Reached by a staging failure and by a synchronous spawn failure alike. // Neither leaves a child, and a synchronous spawn failure never reaches @@ -1966,7 +1998,7 @@ function runDriver({ args, label, operation, parse, input, rosterFile, team, bin } // Read once, here: `staging.fd` is nulled on release, so it cannot be asked // later whether there was ever a pipe to write to. - const staged = staging.fd !== null; + const staged = inputSupplied; let stdout = ""; let stderr = ""; let settled = false; let failure = null; // The last stdin-write error, kept only so a child that exits 0 despite a // broken write is not reported as a silent success. See the stdin handler. diff --git a/tests/remote_sync_engine.test.mjs b/tests/remote_sync_engine.test.mjs index 8fa57c213..65aebb78d 100644 --- a/tests/remote_sync_engine.test.mjs +++ b/tests/remote_sync_engine.test.mjs @@ -24,6 +24,7 @@ import { discardInputDirectory, driver, STORAGE_BUSY_EXIT, + stdinRedirect, storageDriverExitGraceMs, exportAgeHandoff, exportAgeSnapshot, @@ -4970,10 +4971,23 @@ test("driver() waits out a busy store and retries; anything else is asked once", test("every child the engine creates is spawned with windowsHide, or it fails with 0xC0000142 once the SSH session that started it is gone", async () => { // The failure exists only on Windows after a disconnect, so CI on other // platforms cannot observe it; guard the call sites themselves. - for (const [file, call] of [["remote-sync.mjs", "spawn(\"bash\", args, {"], ["sync-cipher.mjs", "spawnSync(age, args, {"]]) { + for (const [file, call] of [["remote-sync.mjs", "spawn(\"bash\", childArgs, {"], ["sync-cipher.mjs", "spawnSync(age, args, {"]]) { const source = await readFile(new URL(`../scripts/internal/${file}`, import.meta.url), "utf8"); const at = source.indexOf(call); assert.ok(at > 0, `${file}: spawn call not found`); assert.match(source.slice(at, source.indexOf(");", at)), /windowsHide: true/, `${file}: spawn lacks windowsHide`); } }); + +test("a driver can read its staged input through the shell redirect used on Windows, where an inherited stdin file handle fails once the SSH session that started the engine is gone", async (t) => { + const root = await mkdtemp(join(tmpdir(), "agmsg-stdin-redirect-")); + t.after(async () => { await rm(root, { recursive: true, force: true }); }); + const input = join(root, "input.jsonl"); + const driverScript = join(root, "driver.sh"); + await writeFile(input, '{"type":"a"}\n{"type":"b"}\n'); + await writeFile(driverScript, "#!/usr/bin/env bash\necho \"$1\"\ncat\n", { mode: 0o755 }); + const { args, env } = stdinRedirect([driverScript, "prepare"], input); + const result = spawnSync("bash", args, { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'prepare\n{"type":"a"}\n{"type":"b"}\n'); +}); From 05c15e2e6d3e84c5ccbd04025fbc9569f5943b5e Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 05:40:21 +0900 Subject: [PATCH 73/97] Restart a sync engine that stood down for an update, from the updated scripts (#1693) A stood-down engine hands a detached helper the team; the helper retries sync start on a growing wait, never taking the predecessor's own already-running as success, for up to about 18.5 minutes. [skip ci] --- scripts/internal/remote-sync.mjs | 52 +++++++++++++++++++++++ tests/remote_sync_engine.test.mjs | 68 +++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index 7345ea82a..fdbf270e6 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -3488,6 +3488,55 @@ export async function cycle(config, { pushLimit, pullLimit }, dependencies = {}) import { collectInstallBaseline, installChangedAgainst } from "./install-baseline.mjs"; export { collectInstallBaseline, installChangedAgainst }; +// After an update stands the engine down, nothing else starts the new one when +// the update did not know this engine was running (it started while the update +// was in progress, for example). So a stood-down engine asks for its own +// restart from a detached helper that outlives it. The helper notes the pid in +// the team's pidfile (the predecessor's), then waits and runs +// `remote.sh sync start`, again on a growing wait, until a start reports +// "started" or reports an engine already running under a DIFFERENT pid (someone +// else restarted it). An "already running" under the predecessor's own pid only +// means it has not exited yet, so that attempt is retried. A start that fails -- +// the update is still copying files, say -- is just another attempt; `sync +// start` itself refuses a team that is disconnected or owned by systemd. An +// engine it starts too early stands down again and asks again. The attempts are +// bounded: the waits add up to 1110 seconds (about 18.5 minutes), after which a +// person has to run `remote.sh sync start`, as the stand-down event says. +const STAND_DOWN_RESTART_DELAYS_SECONDS = [30, 60, 120, 300, 600]; +const STAND_DOWN_RESTART_SCRIPT = [ + 'remote="$1"; team="$2"; shift 2', + 'root="${AGMSG_SYNC_CONNECTION_DIR:-$(cd "$(dirname "$remote")/.." && pwd)}"', + 'old="$(cat "$root/run/remote-sync.$team.pid" 2>/dev/null)"', + 'for delay in "$@"; do', + ' sleep "$delay"', + ' out="$(bash "$remote" sync start "$team" 2>&1)"; rc=$?', + ' [ "$rc" = 0 ] || continue', + ' case "$out" in', + ' *started*) exit 0 ;;', + ' *already*running*) case "$out" in *"pid $old)"*) ;; *) exit 0 ;; esac ;;', + ' esac', + 'done', + 'exit 1', +].join("\n"); +export function standDownRestartArgs(team, scriptsRoot, delays = STAND_DOWN_RESTART_DELAYS_SECONDS) { + return ["-c", STAND_DOWN_RESTART_SCRIPT, "bash", join(scriptsRoot, "remote.sh"), team, ...delays.map(String)]; +} +export function restartAfterStandDown(team, scriptsRoot, { spawnCall = spawn, environment = process.env } = {}) { + const childEnvironment = { ...environment }; + delete childEnvironment.AGMSG_SYNC_TOKEN; + for (const key of Object.keys(childEnvironment)) { + if (/^(?:AGMSG_AGE_IDENTITY|AGMSG_SYNC_AGE_IDENTITY)/u.test(key)) delete childEnvironment[key]; + } + const child = spawnCall("bash", standDownRestartArgs(team, scriptsRoot), + { detached: true, stdio: "ignore", env: childEnvironment, windowsHide: true }); + // A spawn that fails (no bash) is reported on 'error' later, not thrown. + child.on?.("error", (error) => { + process.stderr.write(`agmsg: could not start the restart helper for '${team}': ${error.message}\n`); + }); + child.unref?.(); + return child; +} + export async function runLoop(config, options, dependencies = {}) { const cycleCall = dependencies.cycleCall ?? cycle; const sleepCall = dependencies.sleepCall ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); @@ -3501,6 +3550,7 @@ export async function runLoop(config, options, dependencies = {}) { const collectInstallBaselineCall = dependencies.collectInstallBaselineCall ?? collectInstallBaseline; const installChangedCall = dependencies.installChangedCall ?? installChangedAgainst; const scriptsRoot = dependencies.scriptsRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const restartCall = dependencies.restartCall ?? restartAfterStandDown; // An explicit --limit is a ceiling for BOTH push and pull (request size / // memory / slow-link timeout); only the default lets the loop go large. @@ -3544,6 +3594,8 @@ export async function runLoop(config, options, dependencies = {}) { restart: `remote.sh sync start ${config.local_team}`, }); } catch { /* logging is best-effort; the stand-down does not depend on it */ } + try { restartCall(config.local_team, scriptsRoot); } + catch { /* the restart line in the event above is what remains */ } return; } const pushLimit = ceiling ?? (catchUp ? LARGE_LIMIT : STEADY_PUSH_LIMIT); diff --git a/tests/remote_sync_engine.test.mjs b/tests/remote_sync_engine.test.mjs index 65aebb78d..e3a40ad5b 100644 --- a/tests/remote_sync_engine.test.mjs +++ b/tests/remote_sync_engine.test.mjs @@ -24,6 +24,8 @@ import { discardInputDirectory, driver, STORAGE_BUSY_EXIT, + restartAfterStandDown, + standDownRestartArgs, stdinRedirect, storageDriverExitGraceMs, exportAgeHandoff, @@ -3301,6 +3303,7 @@ test("configured native identity must belong to its epoch recipient manifest", a test("runLoop: an install update stands the engine down before any cycle, naming the update and the restart (#963)", async () => { const events = []; + const restarts = []; let cycles = 0; // Resolves -- a stand-down is a deliberate return, not a thrown failure. await runLoop(config, {}, { @@ -3309,7 +3312,11 @@ test("runLoop: an install update stands the engine down before any cycle, naming cycleCall: async () => { cycles += 1; return {}; }, sleepCall: async () => {}, eventCall: async (name, fields) => { events.push({ name, ...fields }); }, + restartCall: (team, root) => { restarts.push([team, root]); }, + scriptsRoot: "/skill/scripts", }); + // The stood-down engine asks for its own restart, from the updated scripts. + assert.deepEqual(restarts, [["demo", "/skill/scripts"]]); // Detected at the loop boundary: no driver was ever spawned. assert.equal(cycles, 0); assert.equal(events.length, 1); @@ -4991,3 +4998,64 @@ test("a driver can read its staged input through the shell redirect used on Wind assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, 'prepare\n{"type":"a"}\n{"type":"b"}\n'); }); + +test("restartAfterStandDown hands a detached helper the team and the updated remote.sh, without secrets, and survives a spawn error", () => { + const calls = []; + const listeners = {}; + const fake = { unref() { calls.push("unref"); }, on(name, fn) { listeners[name] = fn; } }; + restartAfterStandDown("demo", "/skill/scripts", { + spawnCall: (command, args, options) => { calls.push([command, args, options]); return fake; }, + environment: { PATH: "/bin", AGMSG_SYNC_TOKEN: "secret", AGMSG_AGE_IDENTITY_FILE: "/id", AGMSG_SYNC_CONNECTION_DIR: "/conn" }, + }); + const [command, args, options] = calls[0]; + assert.equal(command, "bash"); + assert.deepEqual(args.slice(3, 5), [join("/skill/scripts", "remote.sh"), "demo"]); + assert.equal(options.detached, true); + assert.equal(options.stdio, "ignore"); + assert.equal(options.windowsHide, true); + assert.deepEqual(options.env, { PATH: "/bin", AGMSG_SYNC_CONNECTION_DIR: "/conn" }); + assert.equal(calls[1], "unref"); + // An asynchronous spawn failure is reported, not raised. + assert.doesNotThrow(() => listeners.error(new Error("spawn bash ENOENT"))); +}); + +test("the restart helper retries a failed start, never takes the predecessor's own 'already running' as success, and stops once another engine is up", async (t) => { + const root = await mkdtemp(join(tmpdir(), "agmsg-restart-helper-")); + t.after(async () => { await rm(root, { recursive: true, force: true }); }); + await mkdir(join(root, "run")); + await writeFile(join(root, "run", "remote-sync.demo.pid"), "111\n"); + const remote = join(root, "remote.sh"); + const log = join(root, "calls"); + const counter = join(root, "n"); + const useRemote = async (script) => { + await rm(counter, { force: true }); await rm(log, { force: true }); + await writeFile(remote, [ + "#!/usr/bin/env bash", + `n=$(( $(cat ${shellQuote(counter)} 2>/dev/null || echo 0) + 1 )); printf %s "$n" > ${shellQuote(counter)}`, + `printf '%s\\n' "$*" >> ${shellQuote(log)}`, + script, "", + ].join("\n"), { mode: 0o755 }); + }; + const run = () => spawnSync("bash", standDownRestartArgs("demo", root, [0, 0, 0, 0, 0]).map((a) => a === join(root, "remote.sh") ? remote : a), + { encoding: "utf8", env: { ...process.env, AGMSG_SYNC_CONNECTION_DIR: root } }); + const calls = async () => (await readFile(log, "utf8")).trim().split("\n").length; + + // A failed start, then the predecessor (pid 111) still reported twice, then started: three + // tries after the failure, and the predecessor's no-ops never count. + await useRemote('case "$n" in 1) echo "not yet" >&2; exit 1 ;; 2|3) echo "Sync engine already running (pid 111)."; exit 0 ;; *) echo "Sync engine started for demo (pid 2)."; exit 0 ;; esac'); + let result = run(); + assert.equal(result.status, 0, result.stderr); + assert.equal(await calls(), 4); + + // Another engine (pid 222) already up: done at once. + await useRemote('echo "Sync engine already running (pid 222)."; exit 0'); + result = run(); + assert.equal(result.status, 0, result.stderr); + assert.equal(await calls(), 1); + + // The predecessor never exits: the bounded attempts run out and the helper says it failed. + await useRemote('echo "Sync engine already running (pid 111)."; exit 0'); + result = run(); + assert.equal(result.status, 1); + assert.equal(await calls(), 5); +}); From 580945ef70b0703b43e7d20aaf92dacbbd106177 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 06:00:36 +0900 Subject: [PATCH 74/97] Report a set-aside roster state in the engine log (#1694) A successful roster driver's note about an unreadable state file it set aside is logged as roster.state_reset by the engine, including paths with spaces. [skip ci] --- scripts/internal/remote-sync.mjs | 15 ++++++++++++++- scripts/internal/roster-sync.mjs | 9 +-------- tests/remote_sync_engine.test.mjs | 27 +++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index fdbf270e6..251f97a77 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -1915,7 +1915,7 @@ export function storageDriverExitGraceMs(busyTimeoutEnv = process.env.AGMSG_BUSY } function runDriver({ args, label, operation, parse, input, rosterFile, team, bindingPath, - holdsRegistryLock = false }) { + holdsRegistryLock = false, onNotice = null }) { return new Promise((resolve, reject) => { const childEnvironment = { ...process.env }; delete childEnvironment.AGMSG_SYNC_TOKEN; @@ -2136,6 +2136,11 @@ function runDriver({ args, label, operation, parse, input, rosterFile, team, bin // catches this when the exit is seen first; this catches the case where // the write error was recorded only after a clean exit had returned. if (lastStdinError) { settle(lastStdinError, null); return; } + // What a driver said on stderr while succeeding, for the one caller that + // reports it (a roster state it had to set aside). Never part of the result. + if (onNotice !== null && stderr.trim()) { + try { onNotice(stderr.trim()); } catch { /* a report must not cost the call */ } + } try { settle(null, parse(stdout)); } catch (error) { @@ -2281,6 +2286,14 @@ export async function rosterDriver(operation, config, input, extra = []) { // caller that has no connection root, which is what happened the first time // this was written unconditionally. rosterFile: rosterFileFor(config.local_team), + // The driver sets an unreadable roster state file aside and carries on; say + // so in the engine log, where the engine's other events are. + onNotice: (text) => { + const reset = /roster sync state .* was unreadable .*; kept as (.+) and rebuilt from the server$/u.exec(text); + if (reset) { + event("roster.state_reset", { team: config.local_team, file: reset[1] }).catch(() => {}); + } + }, }); } diff --git a/scripts/internal/roster-sync.mjs b/scripts/internal/roster-sync.mjs index 5da239e36..30e0e5467 100755 --- a/scripts/internal/roster-sync.mjs +++ b/scripts/internal/roster-sync.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { randomUUID } from "node:crypto"; -import { appendFileSync, closeSync, fsyncSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { closeSync, fsyncSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import process from "node:process"; import { sealEnvelope } from "./sync-cipher.mjs"; @@ -40,13 +40,6 @@ function setAside(error) { renameSync(statePath, aside); const note = `roster sync state ${statePath} was unreadable (${error.message}); kept as ${aside} and rebuilt from the server`; process.stderr.write(`agmsg: ${note}\n`); - if (process.env.AGMSG_SYNC_LOG_FILE) { - try { - appendFileSync(process.env.AGMSG_SYNC_LOG_FILE, `${JSON.stringify({ - at: new Date().toISOString(), event: "roster.state_reset", file: aside, reason: error.message, - })}\n`, { encoding: "utf8", mode: 0o600 }); - } catch { /* the file set aside is the durable record */ } - } } function readState() { diff --git a/tests/remote_sync_engine.test.mjs b/tests/remote_sync_engine.test.mjs index e3a40ad5b..22291faf9 100644 --- a/tests/remote_sync_engine.test.mjs +++ b/tests/remote_sync_engine.test.mjs @@ -5059,3 +5059,30 @@ test("the restart helper retries a failed start, never takes the predecessor's o assert.equal(result.status, 1); assert.equal(await calls(), 5); }); + +test("a roster state file the driver set aside is reported in the engine log, not in the result", async (t) => { + const root = await mkdtemp(join(tmpdir(), "agmsg-roster-reset-log-")); + const previous = { driver: process.env.AGMSG_SYNC_ROSTER_DRIVER, log: process.env.AGMSG_SYNC_LOG_FILE }; + t.after(async () => { + for (const [name, value] of [["AGMSG_SYNC_ROSTER_DRIVER", previous.driver], ["AGMSG_SYNC_LOG_FILE", previous.log]]) { + if (value === undefined) delete process.env[name]; else process.env[name] = value; + } + await rm(root, { recursive: true, force: true }); + }); + const script = join(root, "roster-driver.sh"); + await writeFile(script, [ + "#!/usr/bin/env bash", + "cat > /dev/null", + "echo 'agmsg: roster sync state /t/roster-sync.json was unreadable (bad); kept as /t/First Last/roster-sync.json.corrupt-1-2 and rebuilt from the server' >&2", + "echo '{\"type\":\"roster_sync_state\",\"transport_cursor\":\"0\"}'", + "", + ].join("\n"), { mode: 0o755 }); + process.env.AGMSG_SYNC_ROSTER_DRIVER = script; + process.env.AGMSG_SYNC_LOG_FILE = join(root, "engine.log"); + const records = await rosterDriver("prepare", config, [], []); + assert.deepEqual(records, [{ type: "roster_sync_state", transport_cursor: "0" }]); + await new Promise((resolve) => setTimeout(resolve, 50)); + const logged = (await readFile(process.env.AGMSG_SYNC_LOG_FILE, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(logged.map(({ event, team, file }) => [event, team, file]), + [["roster.state_reset", config.local_team, "/t/First Last/roster-sync.json.corrupt-1-2"]]); +}); From e9ec389c3a9cbc012223037a4232be08fcf9b3b8 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 14:04:18 -0700 Subject: [PATCH 75/97] release: 1.6.0-rc.7 --- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a3965e8a5..600a28a5c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agmsg", "description": "Cross-agent messaging via SQLite. Send messages between CLI AI agents. No daemon, no network.", - "version": "1.6.0-rc.6", + "version": "1.6.0-rc.7", "author": { "name": "fujibee" }, diff --git a/VERSION b/VERSION index bcbe87df4..a8d05f0fc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0-rc.6 +1.6.0-rc.7 diff --git a/package.json b/package.json index f2495f9b7..655bdd21d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.6.0-rc.6", + "version": "1.6.0-rc.7", "description": "Cross-agent messaging via SQLite for CLI AI agents (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Antigravity, OpenCode). The npm package is a thin bootstrapper that fetches and runs the canonical bash installer at https://github.com/fujibee/agmsg.", "bin": { "agmsg": "bin/agmsg.js" From 4e542e97e88fc7ad9af8359831ba409afa8a908e Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 07:28:40 +0900 Subject: [PATCH 76/97] Fix the Codex startup test for the sole-seat rule (#1696) [skip ci] A Codex session start picks a seat only when exactly one is registered; the two-seat test now expects no seat to be chosen and neither record to change. --- tests/agmsgd_status_display.test.mjs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/agmsgd_status_display.test.mjs b/tests/agmsgd_status_display.test.mjs index b9c77acb5..e4e765b87 100644 --- a/tests/agmsgd_status_display.test.mjs +++ b/tests/agmsgd_status_display.test.mjs @@ -110,7 +110,7 @@ test('Codex startup refreshes the sole destination from the current hook thread assert.match(record('alice'), new RegExp(`session=${current}\\n`)); }); -test('Codex startup selects a unique matching pane and epoch, and reports ambiguity instead of guessing', t => { +test('Codex startup with two seats never picks one by pane and epoch, and changes neither record', t => { const { root, start, record, old } = codexStartupFixture(t, ['alice', 'bob']); const current = '01a13352-3599-7d62-a149-4c8182bcf009'; const ref = 'tmux:/tmp/agmsg-hook-tmux:%4'; @@ -118,13 +118,10 @@ test('Codex startup selects a unique matching pane and epoch, and reports ambigu const mark = name => `${old(name)}named_ref=${ref}\nnamed_epoch=pid=321\n`; writeFileSync(join(root, 'run/spawn.test__bob'), `${ref}\t${root}\tcodex\n`); writeFileSync(join(root, 'run/role-session.test__bob'), mark('bob')); - start(current, pane); - assert.match(record('bob'), new RegExp(`session=${current}\\n`)); - assert.equal(record('alice'), old('alice')); - writeFileSync(join(root, 'run/role-session.test__bob'), mark('bob').replace('pid=321', 'pid=320')); + // The pane comes from Codex's shared app-server, so it cannot tell the seats apart. assert.match(start(current, pane), /not connected to a seat yet.*\$.* actas /); - assert.match(record('bob'), /named_epoch=pid=320/); - writeFileSync(join(root, 'run/role-session.test__bob'), mark('bob')); + assert.equal(record('bob'), mark('bob')); + assert.equal(record('alice'), old('alice')); writeFileSync(join(root, 'run/spawn.test__alice'), `${ref}\t${root}\tcodex\n`); writeFileSync(join(root, 'run/role-session.test__alice'), mark('alice')); assert.match(start(current, pane), /not connected to a seat yet.*\$.* actas /); From 6f0b4d06fa8275d73070fb6341fb4c82631a1f73 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 15:30:01 -0700 Subject: [PATCH 77/97] ci: run the full suite on the 1.6.0-rc.7 head From 3e1606c468b536480ce5a4766f62d1d55aed8571 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 08:40:24 +0900 Subject: [PATCH 78/97] setup.sh: stop exporting its working directory as TMP (#1697) `setup.sh` (the `npx agmsg` bootstrapper) assigned its working directory to `TMP`. `TMP` is the temp-directory environment variable, and on Windows it is already exported, so the assignment stayed exported: `install.sh`, and every sync engine it started, inherited `TMP=`. The `EXIT` trap deletes that directory when the installer finishes. An engine started that way has a `TMP` that no longer exists. Its `sqlite3` children fail with `unable to open database file` as soon as a statement is large enough to spill a sort to a temporary file. On a large team that is the read-state prepare (`sqlite-sync.sh`, the `INSERT INTO sync_read_prepared ... WITH held` statement): read-state sync stops, and `remote.sh status` shows no successful cycle. Small teams do not spill, so they keep working, which hid it. Checked on a Windows machine after `npx agmsg@1.6.0-rc.7 install --update`: - The engine started by the installer had `TMP=C:\Users\...\Temp\tmp.XXXX`, a directory that no longer existed (read from the process environment). Its read-prepare failed on every cycle for 15 minutes, through the full retry budget. - The same SQL and the real driver call succeed from a normal shell, and a restarted engine (normal `TMP`) completed its cycles at once. - An earlier occurrence (an engine that failed 35 s after its start) fits the same pattern. Fix: use a private variable name in `setup.sh`, and in `scripts/release/update-cask.sh`, the only other assignment of that shape in the tree. Test: `tests/test_setup.bats` passes `TMP`, `TEMP` and `TMPDIR` to `setup.sh` and checks that the fake `install.sh` sees them unchanged; it fails on the old `setup.sh`. --- scripts/release/update-cask.sh | 18 +++++++++--------- setup.sh | 21 ++++++++++++--------- tests/test_setup.bats | 19 +++++++++++++++++++ 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/scripts/release/update-cask.sh b/scripts/release/update-cask.sh index 488aabf87..0243a1bd0 100755 --- a/scripts/release/update-cask.sh +++ b/scripts/release/update-cask.sh @@ -39,8 +39,8 @@ command -v gh >/dev/null 2>&1 || die "gh not found" who="$(gh api user --jq .login 2>/dev/null || true)" [ "$who" = "fujibee" ] || die "gh identity is '$who', expected 'fujibee' (check direnv .envrc)" -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT # The cask's `app` stanza must track the bundle name, which follows # tauri.conf.json's productName (e.g. the 0.1.1 rename "agmsg app" -> @@ -66,8 +66,8 @@ occ="$(printf '%s' "$DMG_NAME" | grep -o "$VERSION" | wc -l | tr -d ' ')" # 2. Download + hash. echo "==> Downloading $DMG_NAME from $TAG" -gh release download "$TAG" --repo fujibee/agmsg --pattern "$DMG_NAME" --dir "$TMP" -SHA="$(shasum -a 256 "$TMP/$DMG_NAME" | awk '{print $1}')" +gh release download "$TAG" --repo fujibee/agmsg --pattern "$DMG_NAME" --dir "$WORK_DIR" +SHA="$(shasum -a 256 "$WORK_DIR/$DMG_NAME" | awk '{print $1}')" echo " sha256 $SHA" # 3. Rewrite the cask. Derive the url template from the real asset name so the @@ -76,8 +76,8 @@ URL_ASSET="${DMG_NAME//$VERSION/#{version\}}" URL="https://github.com/fujibee/agmsg/releases/download/app-v#{version}/$URL_ASSET" echo "==> Updating tap" -git clone --depth 1 -q "$TAP_REMOTE" "$TMP/tap" -CASK="$TMP/tap/Casks/agmsg.rb" +git clone --depth 1 -q "$TAP_REMOTE" "$WORK_DIR/tap" +CASK="$WORK_DIR/tap/Casks/agmsg.rb" [ -f "$CASK" ] || die "Casks/agmsg.rb not found in the tap" sed -i '' \ @@ -108,10 +108,10 @@ grep -Fq "app \"$APP_BUNDLE\"" "$CASK" || die "app stanza rewrite failed — cas grep -Fq "depends_on arch: :arm64" "$CASK" && die "depends_on arch: :arm64 still present — cask format changed?" # 4. Commit + push (no-op safe: bail politely if nothing changed). -if git -C "$TMP/tap" diff --quiet; then +if git -C "$WORK_DIR/tap" diff --quiet; then echo "==> Tap already up to date for $VERSION — nothing to push" exit 0 fi -git -C "$TMP/tap" commit -aqm "agmsg $VERSION" -git -C "$TMP/tap" push -q origin HEAD +git -C "$WORK_DIR/tap" commit -aqm "agmsg $VERSION" +git -C "$WORK_DIR/tap" push -q origin HEAD echo "==> Tap updated: agmsg $VERSION ($DMG_NAME)" diff --git a/setup.sh b/setup.sh index 47e165c97..cb89441b7 100755 --- a/setup.sh +++ b/setup.sh @@ -8,10 +8,13 @@ set -euo pipefail # main (the latest). See #172. REF="${AGMSG_REF:-main}" -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT +# Not named TMP: that is the temp-directory environment variable, already +# exported on Windows, so assigning it here would hand the installer and every +# engine it starts a directory this trap deletes on exit. +SETUP_DIR=$(mktemp -d) +trap 'rm -rf "$SETUP_DIR"' EXIT -if git clone --depth 1 --branch "$REF" https://github.com/fujibee/agmsg.git "$TMP/agmsg" 2>/dev/null; then +if git clone --depth 1 --branch "$REF" https://github.com/fujibee/agmsg.git "$SETUP_DIR/agmsg" 2>/dev/null; then : elif command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1; then # No git (or the clone failed for some other reason, e.g. no network path to @@ -20,25 +23,25 @@ elif command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1; then # already tolerates running from a non-git tarball checkout (see its # provenance-version comment), so no changes needed there. echo "agmsg: git clone unavailable/failed — falling back to a tarball download" >&2 - # A failed git clone can still leave a partial $TMP/agmsg behind (git + # A failed git clone can still leave a partial $SETUP_DIR/agmsg behind (git # creates the destination dir before it can fail) — clear it so the # tarball's extracted dir moves cleanly into place instead of nesting # inside the leftover directory. - rm -rf "$TMP/agmsg" - if ! curl -fsSL "https://github.com/fujibee/agmsg/archive/$REF.tar.gz" | tar -xz -C "$TMP" 2>/dev/null; then + rm -rf "$SETUP_DIR/agmsg" + if ! curl -fsSL "https://github.com/fujibee/agmsg/archive/$REF.tar.gz" | tar -xz -C "$SETUP_DIR" 2>/dev/null; then echo "agmsg: failed to download tarball for ref '$REF' from https://github.com/fujibee/agmsg" >&2 exit 1 fi # GitHub extracts to a single top-level "agmsg-" directory (slashes in # become dashes) — find it rather than hardcoding the exact name. - extracted="$(find "$TMP" -mindepth 1 -maxdepth 1 -type d -name 'agmsg-*' | head -1)" + extracted="$(find "$SETUP_DIR" -mindepth 1 -maxdepth 1 -type d -name 'agmsg-*' | head -1)" if [ -z "$extracted" ]; then echo "agmsg: tarball downloaded but no agmsg-* directory was found inside it" >&2 exit 1 fi - mv "$extracted" "$TMP/agmsg" + mv "$extracted" "$SETUP_DIR/agmsg" else echo "agmsg: failed to clone ref '$REF' from https://github.com/fujibee/agmsg.git (git unavailable, and no curl+tar to fall back to)" >&2 exit 1 fi -"$TMP/agmsg/install.sh" "$@" +"$SETUP_DIR/agmsg/install.sh" "$@" diff --git a/tests/test_setup.bats b/tests/test_setup.bats index 762178641..517dc6696 100644 --- a/tests/test_setup.bats +++ b/tests/test_setup.bats @@ -230,3 +230,22 @@ EOF tmp_dir="$(dirname "$install_dir")" [ ! -d "$tmp_dir" ] } + +@test "setup: the installer's temp directory never reaches the environment it hands on" { + stub_git_success + stub_curl_marker + # Whatever the installer starts (a sync engine, say) keeps this environment + # after setup.sh has deleted its working directory, so TMP/TEMP/TMPDIR must + # still be what the caller passed, not setup.sh's own directory. + FAKE_INSTALL='#!/usr/bin/env bash +echo "SEEN TMP=$TMP TEMP=$TEMP TMPDIR=$TMPDIR"' + export FAKE_INSTALL + # Real directories: mktemp honours TMPDIR and fails on one that does not exist. + mkdir "$FAKE_HOME/tmp" "$FAKE_HOME/temp" "$FAKE_HOME/tmpdir" + + run env PATH="$STUB_BIN:/usr/bin:/bin" HOME="$FAKE_HOME" \ + TMP="$FAKE_HOME/tmp" TEMP="$FAKE_HOME/temp" TMPDIR="$FAKE_HOME/tmpdir" \ + bash "$REPO_ROOT/setup.sh" --cmd agmsg-test + [ "$status" -eq 0 ] + [[ "$output" == *"SEEN TMP=$FAKE_HOME/tmp TEMP=$FAKE_HOME/temp TMPDIR=$FAKE_HOME/tmpdir"* ]] +} From 72be73527f406b871fda23e966364ecf85fd3675 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 08:41:49 +0900 Subject: [PATCH 79/97] fix(hooks): preserve unread messages when stdout is closed (#1698) On Ubuntu 26.04, a Stop-hook invocation with stdout closed could consume messages without delivering its payload. The installed uutils `cat` 0.10.0 reopens a missing fd 1 on `/dev/null` and exits successfully, so the hook interpreted a discarded payload as a successful delivery. Emit the Stop payload with the shell builtin `printf`, as the other emit paths already do. This preserves the JSON bytes and makes a closed stdout fail the write, leaving the messages unread. The existing regression and its expectations remain unchanged. Validation: reproduced the failure on Ubuntu 26.04/Bash 5.3.9 and traced `cat` observing `EBADF` on fd 1, opening `/dev/null` as fd 1, and exiting 0. The successful-delivery and closed-stdout regressions pass with the fix. Ubuntu 24.04/Bash 5.2 did not reproduce the original failure. The full `tests/test_delivery.bats` suite completed with exit 0 on Ubuntu 26.04 as a non-root user with `LANG=C.UTF-8` (221 cases, 3 platform skips). The same 221-case file also completed with exit 0 on macOS. `bash -n scripts/check-inbox.sh` and `git diff --check` pass. --- scripts/check-inbox.sh | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/check-inbox.sh b/scripts/check-inbox.sh index cca13a198..8f5e297ca 100755 --- a/scripts/check-inbox.sh +++ b/scripts/check-inbox.sh @@ -456,12 +456,10 @@ if [ -n "$OUTPUT" ]; then *) EMIT_RC=0 ;; esac else - cat < Date: Thu, 22 Oct 2026 08:52:02 +0900 Subject: [PATCH 80/97] fix(storage): require exclusive ownership inside directory locks (#1701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A racing `mkdir` can return success to multiple contenders on Ubuntu 26.04 (uutils 0.10.0). That lets concurrent registry writers and JSONL preparers enter one critical section, producing different wire reservations for the same local message. Keep the existing directory layout and require a shell noclobber `.owner` marker inside each owned directory. Registry acquisition, atomic-write private directories, JSONL journal locks, and cursor migration use shared helpers. The Node registry writer takes the same marker with `O_EXCL`. Release moves the marker aside before removing the directory, preserving the minimal PATH contract without requiring `rm`; a failed release may restore the old holder only after exclusive marker reacquisition. A vanished or transferred namespace never authorizes holder rollback. Existing positive-dead holder recovery and conservative treatment of unannotated directories remain in place. Validation: the sixteen-way EEXIST-success mkdir regression fails on the old core and passes with this change on Ubuntu 26.04 and macOS. Both platforms pass concurrent sync-start ownership, 19 registry lock/stale/release cases, and the Node SIGTERM lock-release test. The minimal PATH join test passes on macOS. Bash syntax, enforced assertions, and diff checks pass. A deterministic vanished-namespace release regression also fails before its fix and passes on both platforms. Windows validation is requested from the remote test runner and pending. Integration note: this targets `integration/agmsgd-beta`. Its `sync-autostart.sh` has no mkdir-based `.gaveup` arbiter. The version currently on main still uses bare mkdir at the child/parent cleanup arbitration sites (lines 186/207 in the audited main revision); those sites must use the same ownership helper when main and integration meet. This PR intentionally does not import main’s unrelated autostart changes. --- scripts/drivers/storage/jsonl.sh | 25 ++++++++------- scripts/internal/remote-sync.mjs | 9 +++++- scripts/lib/registry-lock.sh | 53 ++++++++++++++++++++++++------- tests/remote_sync_engine.test.mjs | 1 + tests/test_jsonl_remote_sync.bats | 47 +++++++++++++++++++++------ tests/test_registry_lock.bats | 31 +++++++++++++++++- 6 files changed, 132 insertions(+), 34 deletions(-) diff --git a/scripts/drivers/storage/jsonl.sh b/scripts/drivers/storage/jsonl.sh index 157987c8e..7fd7dcfac 100644 --- a/scripts/drivers/storage/jsonl.sh +++ b/scripts/drivers/storage/jsonl.sh @@ -28,6 +28,9 @@ # carry apostrophe-laden SQL inline (which the macOS bash 3.2 parser mis-handles). _JSONL_DRIVER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)" _JSONL_SYNC_HELPER_DEFAULT="$_JSONL_DRIVER_DIR/../../internal/jsonl-sync.mjs" +# Shared directory ownership also protects against non-atomic mkdir utilities. +# shellcheck source=../../lib/registry-lock.sh +source "$_JSONL_DRIVER_DIR/../../lib/registry-lock.sh" _jsonl_now() { date -u +%Y-%m-%dT%H:%M:%SZ; } # The storage selector for the call in progress. Every contract entry point below @@ -66,28 +69,28 @@ _jsonl_read_cursor_migrate() { local marker lock i=0 log cursors tmp tip marker="$(_jsonl_read_cursor_marker)"; [ -f "$marker" ] && return 0 lock="$marker.lock" - until mkdir "$lock" 2>/dev/null; do + until _agmsg_dir_take "$lock" 2>/dev/null; do [ -f "$marker" ] && return 0 i=$((i + 1)); [ "$i" -ge 1000 ] && return 1 sleep 0.01 done - if [ -f "$marker" ]; then rmdir "$lock" 2>/dev/null || true; return 0; fi + if [ -f "$marker" ]; then _agmsg_dir_drop "$lock" 2>/dev/null || true; return 0; fi log="$(_jsonl_log)"; cursors="$(_jsonl_read_cursors)" - tmp=$(mktemp "${cursors}.tmp.XXXXXX") || { rmdir "$lock" 2>/dev/null || true; return 1; } + tmp=$(mktemp "${cursors}.tmp.XXXXXX") || { _agmsg_dir_drop "$lock" 2>/dev/null || true; return 1; } tip=$(jq -s 'def logical_events: .[] | if .type=="sync_pull_commit" then .messages[]? | select(.status=="imported") | .local_event // empty else . end; [logical_events | select(.type=="message_sent")] | length' "$log") || { - rm -f "$tmp"; rmdir "$lock" 2>/dev/null || true; return 1; + rm -f "$tmp"; _agmsg_dir_drop "$lock" 2>/dev/null || true; return 1; } jq -r --argjson tip "$tip" -s 'def logical_events: .[] | if .type=="sync_pull_commit" then .messages[]? | select(.status=="imported") | .local_event // empty else . end; [logical_events | select(.type=="message_sent") | [.team,.to]] | unique[] | - @tsv + "\t" + ($tip|tostring)' "$log" > "$tmp" || { rm -f "$tmp"; rmdir "$lock" 2>/dev/null || true; return 1; } - mv "$tmp" "$cursors" || { rm -f "$tmp"; rmdir "$lock" 2>/dev/null || true; return 1; } - : > "$marker.tmp.$$" || { rmdir "$lock" 2>/dev/null || true; return 1; } - mv "$marker.tmp.$$" "$marker" || { rm -f "$marker.tmp.$$"; rmdir "$lock" 2>/dev/null || true; return 1; } - rmdir "$lock" 2>/dev/null || true + @tsv + "\t" + ($tip|tostring)' "$log" > "$tmp" || { rm -f "$tmp"; _agmsg_dir_drop "$lock" 2>/dev/null || true; return 1; } + mv "$tmp" "$cursors" || { rm -f "$tmp"; _agmsg_dir_drop "$lock" 2>/dev/null || true; return 1; } + : > "$marker.tmp.$$" || { _agmsg_dir_drop "$lock" 2>/dev/null || true; return 1; } + mv "$marker.tmp.$$" "$marker" || { rm -f "$marker.tmp.$$"; _agmsg_dir_drop "$lock" 2>/dev/null || true; return 1; } + _agmsg_dir_drop "$lock" 2>/dev/null || true } # Serialize all log mutations (append / mark / compact / import) behind a portable @@ -95,12 +98,12 @@ _jsonl_read_cursor_migrate() { _jsonl_with_lock() { local lock i=0 rc=0 max="${AGMSG_JSONL_LOCK_TRIES:-1000}" lock="$(_jsonl_log).lock" - until mkdir "$lock" 2>/dev/null; do + until _agmsg_dir_take "$lock" 2>/dev/null; do i=$((i + 1)); [ "$i" -ge "$max" ] && return 1 sleep 0.01 done "$@" || rc=$? - rmdir "$lock" 2>/dev/null || true + _agmsg_dir_drop "$lock" 2>/dev/null || true return $rc } diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index 251f97a77..a66c5d9e8 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -4251,6 +4251,7 @@ function releaseTeamConfigLockSync(held) { } catch { seen = undefined; } if (seen !== held.token) return; try { unlinkSync(held.holderPath); } catch { /* already gone */ } + try { unlinkSync(join(held.lockDir, ".owner")); } catch { /* legacy marker-less directory */ } try { rmdirSync(held.lockDir); } catch { /* already gone */ } } @@ -4281,7 +4282,13 @@ export async function withTeamConfigLock(team, fn) { const lockDir = join(dirname(teamConfigPath(team)), ".config.lock"); const holderPath = `${lockDir}.holder`; for (let attempt = 0; ; attempt += 1) { - try { mkdirSync(lockDir); break; } + try { + mkdirSync(lockDir); + // Same exclusive owner marker as registry-lock.sh. A shell mkdir utility + // may report a racing EEXIST as success, so mkdir alone is not shared exclusion. + closeSync(openSync(join(lockDir, ".owner"), "wx", 0o600)); + break; + } catch (error) { if (error?.code !== "EEXIST") throw error; // Same exit as the shell side (registry-lock.sh): a holder that died diff --git a/scripts/lib/registry-lock.sh b/scripts/lib/registry-lock.sh index d665b2a2b..522ba1bb6 100644 --- a/scripts/lib/registry-lock.sh +++ b/scripts/lib/registry-lock.sh @@ -9,7 +9,7 @@ # commands exit 0 (#141). # # The fix serializes each team's read-modify-write behind a lock. A directory is -# the lock primitive: mkdir is atomic on POSIX and needs no daemon, so it works +# the lock namespace; a shell noclobber owner marker provides exclusion, so it works # on macOS (where flock(1) is absent) under bash 3.2, and on Windows Git Bash. # This is the same idiom the jsonl storage driver uses. The lock is per-team # (teams//.config.lock), so operations on different teams never serialize @@ -26,6 +26,37 @@ # Newline-separated set of lock dirs this process currently holds. AGMSG_HELD_LOCKS="${AGMSG_HELD_LOCKS:-}" +# mkdir(1) can report success for a racing EEXIST (uutils 0.10.0). +# The directory remains the namespace; only the noclobber marker creator owns it. +_agmsg_dir_take() { + mkdir "$1" || return $? + ( umask 077; set -C; : > "$1/.owner" ) +} + +# Call only after ownership (or a positively dead holder) is established. +# Moving the marker aside also works on the minimal PATH without rm. If another +# delayed contender takes the marker while rmdir runs, ownership has transferred: +# never restore the old holder over that successor. Otherwise restore our marker +# on failure so the caller can roll its holder record back safely. +_agmsg_dir_drop() { + local dir="$1" staged="$1.owner.releasing.$$.${RANDOM:-0}" rc=0 + if [ ! -e "$dir/.owner" ]; then + rmdir "$dir" + return $? + fi + mv "$dir/.owner" "$staged" || return $? + rmdir "$dir" || rc=$? + if [ "$rc" -ne 0 ]; then + if ! ( umask 077; set -C; : > "$dir/.owner" ) 2>/dev/null; then + # The namespace may have disappeared or changed hands since rmdir. + # Without reacquiring the marker we have no authority to restore a holder. + rc=0 + fi + fi + if command -v rm >/dev/null 2>&1; then rm -f "$staged" 2>/dev/null || :; fi + return "$rc" +} + # agmsg_lock_acquire # Acquire 's lock. (teams/) must already exist — the # caller creates it for a brand-new/target team before locking, so this never @@ -175,7 +206,7 @@ _agmsg_lock_break_dead() { mv "$claimed" "$lock.holder" 2>/dev/null || true return 1 fi - if ! rmdir "$lock" 2>/dev/null; then + if ! _agmsg_dir_drop "$lock" 2>/dev/null; then # Not taking it after all, so the record goes back where it was. mv "$claimed" "$lock.holder" 2>/dev/null || true return 1 @@ -234,7 +265,7 @@ _agmsg_lock_break_interrupted_release() { && [ ! -e "$lock.holder" ] \ && [ -n "$(find "$lock" -maxdepth 0 -mmin +1 2>/dev/null)" ] \ && [ ! "$lock" -nt "$claimed" ] \ - && rmdir "$lock" 2>/dev/null; then + && _agmsg_dir_drop "$lock" 2>/dev/null; then if command -v rm >/dev/null 2>&1; then rm -f "$claimed" "$lock".holder.releasing.* 2>/dev/null || true fi @@ -253,7 +284,7 @@ agmsg_lock_acquire() { local tpid thost q staged started="$(date +%s)" lock="$team_dir/.config.lock" - until err="$(mkdir "$lock" 2>&1)"; do + until err="$(_agmsg_dir_take "$lock" 2>&1)"; do # WHY mkdir failed decides whether waiting can help, and only one reason # ever clears on its own: somebody holds the lock. Everything else -- no # write permission on the team dir, a read-only mount -- is a standing @@ -579,7 +610,7 @@ _agmsg_lock_drop() { echo "agmsg: nothing but this lock lives in there — it holds no team data." >&2 return 1 fi - if err="$(rmdir "$l" 2>&1)"; then + if err="$(_agmsg_dir_drop "$l" 2>&1)"; then # Best-effort: nothing but this staged copy is left to clean up, and # leaving it behind (no `rm`, or a failed `rm`) is inert — it sits # under a name no acquirer ever looks for, so it is not this process's @@ -693,7 +724,7 @@ _agmsg_discard_temp() { # allowed to fail here. It is not allowed to decide (#804, raised in review). _agmsg_cleanup_attempt() { _agmsg_discard_temp "$1" || : - rmdir "$2" 2>/dev/null || : + _agmsg_dir_drop "$2" 2>/dev/null || : } # SAID, NOT SWALLOWED (#802). On the PATH `join` is required to work under there @@ -759,9 +790,9 @@ agmsg_write_atomic() { # tree draw from the same `$$.$RANDOM` space. The removal was the defect, # not the detection. # - # `mkdir` answers both. It is atomic, it fails rather than joining an existing - # directory, and its SUCCESS is the proof of ownership that `[ -e ]` could - # never be: everything inside belongs to this call, so the payload is written + # mkdir plus an exclusive noclobber marker answers both. Only the marker + # creator owns the directory, even when mkdir reports a racing EEXIST as + # success: everything inside belongs to this call, so the payload is written # into a name nothing else can be holding, and removing that name cannot # remove anyone else's. It is the same primitive this file already trusts for # the registry lock, for the same reason. @@ -771,7 +802,7 @@ agmsg_write_atomic() { local attempts=0 tmpdir while :; do tmpdir="$dest.tmp.$$.$RANDOM.d" - if ( umask 077; mkdir "$tmpdir" ) 2>/dev/null; then + if ( umask 077; _agmsg_dir_take "$tmpdir" ) 2>/dev/null; then break fi # Taken, by anyone, for any reason: draw another name. Nothing is removed @@ -818,7 +849,7 @@ agmsg_write_atomic() { # directory is empty and 0700, so a failure here is close to impossible; if it # happens, what leaks is an empty private directory, and the operator is told # which one rather than left to find it. - if ! rmdir "$tmpdir" 2>/dev/null; then + if ! _agmsg_dir_drop "$tmpdir" 2>/dev/null; then printf 'agmsg: wrote %s, but could not remove the temporary directory %s\n' "$dest" "$tmpdir" >&2 fi return 0 diff --git a/tests/remote_sync_engine.test.mjs b/tests/remote_sync_engine.test.mjs index 22291faf9..8d0ae8b96 100644 --- a/tests/remote_sync_engine.test.mjs +++ b/tests/remote_sync_engine.test.mjs @@ -2281,6 +2281,7 @@ test("SIGTERM while this process holds the team config lock releases it before e } assert.ok(existsSync(lockDir), "child never took the team config lock"); assert.ok(existsSync(holderPath), "child never wrote its holder"); + assert.ok(existsSync(join(lockDir, ".owner")), "child never took the shared owner marker"); child.kill("SIGTERM"); await once(child, "exit"); assert.ok(!existsSync(lockDir), "SIGTERM left the lock directory behind"); diff --git a/tests/test_jsonl_remote_sync.bats b/tests/test_jsonl_remote_sync.bats index c9d4e9eeb..7d9be0dd7 100644 --- a/tests/test_jsonl_remote_sync.bats +++ b/tests/test_jsonl_remote_sync.bats @@ -525,16 +525,43 @@ EOF @test "jsonl concurrent prepare converges on one journal winner" { storage_send demo alice bob concurrent >/dev/null - local first="$BATS_TEST_TMPDIR/first" second="$BATS_TEST_TMPDIR/second" log - (printf '%s\n' "$PREPARE" | - storage_sync_prepare_push demo "$SERVER_ID" "$TEAM_ID" 1 100 >"$first") 3>&- 4>&- & - local first_pid=$! - (printf '%s\n' "$PREPARE" | - storage_sync_prepare_push demo "$SERVER_ID" "$TEAM_ID" 1 100 >"$second") 3>&- 4>&- & - local second_pid=$! - wait "$first_pid"; wait "$second_pid" - [ "$(candidate_of "$(cat "$first")" | jq -c '{local_id,id,envelope}')" = \ - "$(candidate_of "$(cat "$second")" | jq -c '{local_id,id,envelope}')" ] + # Model mkdir implementations that report success for a racing EEXIST. + # Both contenders must reach mkdir before either enters the journal helper. + local shim="$BATS_TEST_TMPDIR/mkdir-shim" + export JSONL_REAL_MKDIR="$(command -v mkdir)" + export JSONL_MKDIR_ARRIVALS="$BATS_TEST_TMPDIR/mkdir-arrivals" + mkdir -p "$shim" "$JSONL_MKDIR_ARRIVALS" + cat > "$shim/mkdir" <<'SH' +#!/usr/bin/env bash +case "$*" in + *events.jsonl.lock) + "$JSONL_REAL_MKDIR" -p "$@" || exit $? + : > "$JSONL_MKDIR_ARRIVALS/$$" + for ((i=0; i<500; i++)); do + arrivals=("$JSONL_MKDIR_ARRIVALS"/*) + [ "${#arrivals[@]}" -ge 16 ] && exit 0 + sleep 0.01 + done + exit 1 ;; + *) exec "$JSONL_REAL_MKDIR" "$@" ;; +esac +SH + chmod +x "$shim/mkdir" + export PATH="$shim:$PATH" + local first="$BATS_TEST_TMPDIR/prepare-0" log i pid expected wait_rc=0 + local pids=() + for ((i=0; i<16; i++)); do + (printf '%s\n' "$PREPARE" | + storage_sync_prepare_push demo "$SERVER_ID" "$TEAM_ID" 1 100 >"$BATS_TEST_TMPDIR/prepare-$i") 3>&- 4>&- & + pids+=("$!") + done + for pid in "${pids[@]}"; do wait "$pid" || wait_rc=$?; done + [ "$wait_rc" -eq 0 ] + expected="$(candidate_of "$(cat "$first")" | jq -c '{local_id,id,envelope}')" + [ -n "$expected" ] + for ((i=1; i<16; i++)); do + [ "$(candidate_of "$(cat "$BATS_TEST_TMPDIR/prepare-$i")" | jq -c '{local_id,id,envelope}')" = "$expected" ] + done log="$(dirname "$(agmsg_db_path demo)")/events.jsonl" [ "$(jq -s '[.[]|select(.type=="sync_prepare_commit")]|length' "$log")" -eq 1 ] } diff --git a/tests/test_registry_lock.bats b/tests/test_registry_lock.bats index f954b14fa..63e7c291b 100644 --- a/tests/test_registry_lock.bats +++ b/tests/test_registry_lock.bats @@ -91,6 +91,7 @@ acquire() { # runs the acquire in its own shell, with a short spin budget . "$LOCKLIB" agmsg_lock_acquire "$TEAM_DIR" || exit 1 [ -d "$TEAM_DIR/.config.lock" ] || exit 2 + [ -f "$TEAM_DIR/.config.lock/.owner" ] || exit 4 agmsg_lock_release [ ! -d "$TEAM_DIR/.config.lock" ] || exit 3 ' @@ -497,8 +498,9 @@ acquire() { # runs the acquire in its own shell, with a short spin budget grep -qF "release was interrupted" <<<"$output" [ ! -e "$lock.holder.releasing.$gone" ] - # A dead holder on this machine: broken, and the acquire then succeeds. + # A dead holder with the new owner marker is still reclaimable. mkdir "$lock" + : > "$lock/.owner" printf 'token t\npid %s\ncommand t\nhost %s\n' "$gone" "$me" > "$lock.holder" acquire [ "$status" -eq 0 ] @@ -506,3 +508,30 @@ acquire() { # runs the acquire in its own shell, with a short spin budget kill "$live" 2>/dev/null || true } + +@test "lock: a vanished namespace after marker release never restores an old holder" { + run env LOCKLIB="$LOCKLIB" TEAM_DIR="$TEAM_DIR" bash -c ' + . "$LOCKLIB" + agmsg_lock_acquire "$TEAM_DIR" || exit 1 + rmdir() { + # A delayed contender has taken and released the marker and namespace. + command rmdir "$1" || return 1 + return 1 + } + mv() { + case "$1" in + *.holder.releasing.*) + # The next owner arrives just before the old rollback would overwrite it. + command mkdir "$TEAM_DIR/.config.lock" + : > "$TEAM_DIR/.config.lock/.owner" + printf "token SUCCESSOR\n" > "$TEAM_DIR/.config.lock.holder" + : > "$TEAM_DIR/rollback-attempted" ;; + esac + command mv "$@" + } + agmsg_lock_release + [ ! -e "$TEAM_DIR/rollback-attempted" ] || exit 2 + [ ! -e "$TEAM_DIR/.config.lock.holder" ] || exit 3 + ' + [ "$status" -eq 0 ] +} From df0930b9fea450fe15e59e71379c82a6672688d7 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 09:32:27 +0900 Subject: [PATCH 81/97] fix(sync): arbitrate autostart cleanup with exclusive ownership (#1704) The autostart parent and child decide who cleans up temporary results by racing to create a `.gaveup` directory. A mkdir implementation that returns success for racing EEXIST can make both sides believe they won, leaving the temporary results behind. Use the shared exclusive owner-marker helper at both arbitration sites and its matching directory cleanup helper. The existing deadline and parent/child cleanup protocol remain in place; the helper is loaded only when an autostart is actually attempted. Validation: an EEXIST-success mkdir fixture synchronizes the caller deadline with child completion and fails on the old code because temporary results remain. It passes with the fix. Focused checks cover that regression, eventual cleanup after the caller stops waiting, delayed reads of a completed result, and nonblocking session/actas startup. All four focused Bats cases passed on Ubuntu 26.04 and macOS after rebasing onto integration following #1699. Enforced assertions and diff checks pass. Windows validation is requested through the coordinator. --- scripts/lib/sync-autostart.sh | 63 ++++++++++++---------------------- tests/test_sync_autostart.bats | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 42 deletions(-) diff --git a/scripts/lib/sync-autostart.sh b/scripts/lib/sync-autostart.sh index efa69d7b5..2ac4d3646 100644 --- a/scripts/lib/sync-autostart.sh +++ b/scripts/lib/sync-autostart.sh @@ -63,6 +63,10 @@ agmsg_sync_autostart() { fi [ -x "$remote_sh" ] || return 0 [ $# -gt 0 ] || return 0 + if ! declare -F _agmsg_dir_take >/dev/null 2>&1; then + # shellcheck source=registry-lock.sh + . "${BASH_SOURCE[0]%/*}/registry-lock.sh" + fi # Seconds, for the whole call. Overridable so a test can drive the deadline # without waiting for it, and so an operator on a slow machine can raise it. @@ -152,42 +156,17 @@ agmsg_sync_autostart() { agmsg_close_inherited_fds "$remote_sh" sync start "$team" >"$tmp" 2>&1 printf '%s\n' "$?" > "$tmp.rc" - # Who removes $tmp/$tmp.rc, this child or the caller, is decided by a - # SINGLE atomic `mkdir` on a THIRD name, not by timing (a fixed sleep - # here was tried and was wrong: `mkdir` existing is not the same - # question as "has the caller actually read the files yet", and a - # caller delayed between seeing $tmp.rc exist and getting to its own - # `cat` -- reproduced -- raced a sleep-based cleanup into deleting - # both out from under a start that had already succeeded. See the - # caller's matching mkdir, right below the budget check, for the - # other half). Either side's `mkdir` can be the one that actually - # creates it -- whichever gets here first -- which is why the loser - # of THIS mkdir, not some fixed role, is what decides ownership - # below (also reproduced, a round after the first fix): - # - this mkdir FAILS (EEXIST): the caller's own give-up branch - # created it first, which only happens on the path where it - # already gave up on its budget -- nothing else will ever read - # these files again, so this child removes all three names - # itself. - # - this mkdir SUCCEEDS: the caller has not given up (yet, or at - # all). Still ambiguous, though: the caller may have already - # taken its FAST path (saw $tmp.rc exist, read it, removed it) - # entirely without ever touching this marker -- its fast path - # only ever REMOVES this marker as a courtesy, never creates - # one, so it cannot have raced us here. Re-check $tmp.rc right - # now, fresh, to tell the two apart: gone already means the fast - # path beat us to it and already came and went -- its own - # rmdir attempt (always AFTER its rm, never before -- an - # earlier order left exactly this marker stranded, reproduced) - # ran before we had created anything for it to find, so nothing - # is coming back for this marker and we remove it ourselves. - # Still there means the fast path has not reached that point - # yet, so we leave the marker for it to find and clear. - if mkdir "$tmp.gaveup" 2>/dev/null; then - [ -f "$tmp.rc" ] || rmdir "$tmp.gaveup" 2>/dev/null + # One noclobber owner marker decides which side reached cleanup first; + # mkdir alone can report a racing EEXIST as success on both sides. + # If the child loses, the caller has stopped waiting and delegates cleanup + # to this child. If the child wins, leave the results for the caller, + # unless its fast path has already read and removed them. In that case + # the child also removes the marker the caller may have missed. + if _agmsg_dir_take "$tmp.gaveup" 2>/dev/null; then + [ -f "$tmp.rc" ] || _agmsg_dir_drop "$tmp.gaveup" 2>/dev/null else rm -f "$tmp" "$tmp.rc" - rmdir "$tmp.gaveup" 2>/dev/null + _agmsg_dir_drop "$tmp.gaveup" 2>/dev/null fi ) /dev/null 2>&1 3>&- 4>&- & # The literal `3>&- 4>&-` as well as the call inside, because the repo-wide @@ -201,17 +180,17 @@ agmsg_sync_autostart() { if [ ! -f "$tmp.rc" ]; then # Budget spent, but NOT necessarily "the child has not finished" -- # the write ($tmp.rc) and this check are not atomic with each other, - # so the child may finish in the gap between them. The mkdir below is + # so the child may finish in the gap between them. The owner claim below is # what actually answers "did the child already get here first", not # this -f test (see the matching one in the child above): - if mkdir "$tmp.gaveup" 2>/dev/null; then - # Got there first. The child has not reached its own mkdir attempt + if _agmsg_dir_take "$tmp.gaveup" 2>/dev/null; then + # Got there first. The child has not reached its own owner claim # yet (has not finished, or has not reached that line even if it # has), so it still owns removing $tmp/$tmp.rc once it does. slow="$slow$team"$'\n' continue fi - # The child's mkdir got there first, which only happens once it has + # The child's owner claim got there first, which only happens once it has # already written $tmp.rc -- so despite the budget, this reads # exactly like the fast path below, just a little later. fi @@ -219,22 +198,22 @@ agmsg_sync_autostart() { out="$(cat "$tmp" 2>/dev/null)" # Order matters here, and is NOT interchangeable with the rmdir right # below: this rm must run FIRST. The child's own self-correction (see - # its mkdir-succeeded branch above) only removes the marker when it + # its ownership-succeeded branch above) only removes the marker when it # finds $tmp.rc already gone -- if this rm ran AFTER that rmdir instead, # a child that is still between its mkdir and that re-check would see # the marker gone, assume this side already finished, and never clean # up $tmp/$tmp.rc itself, leaking both. rm -f "$tmp" "$tmp.rc" # Covers BOTH ways this line is reached: the budget-exceeded branch just - # above, where the child's mkdir winning is what sent this here instead + # above, where the child's owner claim winning is what sent this here instead # of to `continue`, and the plain fast path (the while loop above exited # because $tmp.rc already existed, never entering that branch at all) -- - # on the fast path the child's own mkdir almost always wins (the caller + # on the fast path the child's own owner claim almost always wins (the caller # never tried its own), leaving the marker owned by nobody once the # child's "do nothing further" half of the protocol is reached; cleaned # up here rather than left as a new kind of leaked empty directory. A # no-op either way if the child got here first and already removed it. - rmdir "$tmp.gaveup" 2>/dev/null + _agmsg_dir_drop "$tmp.gaveup" 2>/dev/null if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'already running'; then continue fi diff --git a/tests/test_sync_autostart.bats b/tests/test_sync_autostart.bats index 34f6659aa..c7e7f8fd8 100644 --- a/tests/test_sync_autostart.bats +++ b/tests/test_sync_autostart.bats @@ -543,3 +543,63 @@ EOF # The remedy is per team and runnable, unchanged from #765. printf '%s' "$output" | grep -q 'sync start' } + +@test "cleanup arbiter has one owner when mkdir reports racing EEXIST as success" { + local shim="$TEST_SKILL_DIR/arbiter-shim" arrivals="$TEST_SKILL_DIR/arbiter-arrivals" + local fake="$TEST_SKILL_DIR/arbiter-remote" temps="$TEST_SKILL_DIR/arbiter-temps" + mkdir -p "$shim" "$arrivals" + export ARBITER_REAL_MKDIR="$(command -v mkdir)" + export ARBITER_REAL_MKTEMP="$(command -v mktemp)" + export ARBITER_ARRIVALS="$arrivals" ARBITER_TEMPS="$temps" + cat > "$shim/mkdir" <<'SH' +#!/usr/bin/env bash +case "$*" in + *.gaveup) + "$ARBITER_REAL_MKDIR" -p "$@" || exit $? + if [ -f "${1%.gaveup}.rc" ]; then + : > "$ARBITER_ARRIVALS/child" + else + : > "$ARBITER_ARRIVALS/parent" + fi + for ((i=0; i<500; i++)); do + [ -e "$ARBITER_ARRIVALS/child" ] && exit 0 + sleep 0.01 + done + exit 1 ;; + *) exec "$ARBITER_REAL_MKDIR" "$@" ;; +esac +SH + cat > "$shim/mktemp" <<'SH' +#!/usr/bin/env bash +result="$("$ARBITER_REAL_MKTEMP" "$@")" || exit $? +[ "$#" -ne 0 ] || printf '%s\n' "$result" >> "$ARBITER_TEMPS" +printf '%s\n' "$result" +SH + cat > "$fake" <<'SH' +#!/usr/bin/env bash +for ((i=0; i<500; i++)); do + if [ -e "$ARBITER_ARRIVALS/parent" ]; then + echo 'Sync engine started for testteam (pid 4242).' + exit 0 + fi + sleep 0.01 +done +exit 1 +SH + chmod +x "$shim/mkdir" "$shim/mktemp" "$fake" + run env PATH="$shim:$PATH" AGMSG_SYNC_AUTOSTART_TIMEOUT_S=0 bash -c '. "$1"; agmsg_sync_autostart "$2" testteam' _ "$SCRIPTS/lib/sync-autostart.sh" "$fake" + [ "$status" -eq 0 ] + [ -e "$arrivals/parent" ] + [ -e "$arrivals/child" ] + local path i + [ -s "$temps" ] + while IFS= read -r path; do + for ((i=0; i<200; i++)); do + [ ! -e "$path" ] && [ ! -e "$path.rc" ] && [ ! -d "$path.gaveup" ] && break + sleep 0.01 + done + [ ! -e "$path" ] + [ ! -e "$path.rc" ] + [ ! -d "$path.gaveup" ] + done < "$temps" +} From 638afc0acfb2d04897df54385eea9b496b3455f8 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 09:36:41 +0900 Subject: [PATCH 82/97] fix(storage): print a usable timeout remedy for marker locks (#1705) A timed-out registry lock without a holder record can still contain its `.owner` marker. The suggested `rmdir` command then fails, leaving the operator unable to perform the documented recovery. For a marker-bearing lock, print a quoted removal command that includes the marker; retain `rmdir` for legacy empty directories. This remains manual recovery after checking that no command is running for the team. Bash quoting preserves spaces, apostrophes, and dollar signs in the exact lock path. Validation: the regression executes the printed command, checks that the lock disappears, and verifies that sibling `config.json` data is preserved. It fails on the old code and passes on Ubuntu 26.04 and macOS. The existing dead-holder/legacy record-less-lock case also passes on both platforms. Enforced assertions and diff checks pass. This follows #1704, which landed while this requested addition was being prepared; the target is `integration/agmsgd-beta`. --- scripts/lib/registry-lock.sh | 13 ++++++++----- tests/test_registry_lock.bats | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/scripts/lib/registry-lock.sh b/scripts/lib/registry-lock.sh index ec5de3a6a..f6afe3137 100644 --- a/scripts/lib/registry-lock.sh +++ b/scripts/lib/registry-lock.sh @@ -390,17 +390,20 @@ agmsg_lock_acquire() { echo "agmsg: that process is not running — the lock was being broken as this wait ended." >&2 fi else - # QUOTED, because this line is meant to be pasted: the store root and the - # team name can both contain a space. `rmdir` rather than `rm -r`, so the - # paste cannot remove anything but an empty lock directory. - q="$(printf "'%s'" "$(printf '%s' "$lock" | sed "s/'/'\\''/g")")" + # Quote the exact lock path for the recovery command, including spaces, + # apostrophes and shell metacharacters. Only lock metadata lives inside. + q="$(printf '%q' "$lock")" echo "agmsg: the lock records no holder, so nothing here could ask whether it is held." >&2 for staged in "$lock".holder.releasing.*; do [ -f "$staged" ] || continue echo "agmsg: a release was interrupted: $staged is what its owner (pid ${staged##*.holder.releasing.}) staged. If that record cannot be read, nothing here can say which machine wrote it, so the lock is kept." >&2 done echo "agmsg: a lock with no holder record is not broken automatically. If no agmsg command is running for this team, remove it:" >&2 - echo "agmsg: rmdir $q" >&2 + if [ -e "$lock/.owner" ] || [ -L "$lock/.owner" ]; then + echo "agmsg: rm -r -- $q" >&2 + else + echo "agmsg: rmdir $q" >&2 + fi echo "agmsg: doctor.sh lists such locks." >&2 fi # The reason travels with the timeout too. If the wait was hopeless for diff --git a/tests/test_registry_lock.bats b/tests/test_registry_lock.bats index 6c3e4c22e..c92b69974 100644 --- a/tests/test_registry_lock.bats +++ b/tests/test_registry_lock.bats @@ -558,3 +558,22 @@ acquire() { # runs the acquire in its own shell, with a short spin budget ' [ "$status" -eq 0 ] } + +@test "lock: the timeout remedy removes a stale owner marker and preserves team data" { + local component remedy + for component in "team space \$literal" "team 'quote' \$literal"; do + TEAM_DIR="$BATS_TEST_TMPDIR/$component" + mkdir -p "$TEAM_DIR/.config.lock" + : > "$TEAM_DIR/.config.lock/.owner" + printf '%s\n' preserved > "$TEAM_DIR/config.json" + TRIES=1 acquire + [ "$status" -ne 0 ] + [ -f "$TEAM_DIR/.config.lock/.owner" ] + remedy="$(printf '%s\n' "$output" | sed -n 's/^agmsg: //p' | tail -1)" + [ -n "$remedy" ] + run bash -c "$remedy" + [ "$status" -eq 0 ] + [ ! -d "$TEAM_DIR/.config.lock" ] + [ "$(cat "$TEAM_DIR/config.json")" = preserved ] + done +} From 6527ada5501a5d86b4dcc81b761522d14d7d9e50 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 17:37:21 -0700 Subject: [PATCH 83/97] release: 1.6.0-rc.8 --- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 600a28a5c..180752bdf 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agmsg", "description": "Cross-agent messaging via SQLite. Send messages between CLI AI agents. No daemon, no network.", - "version": "1.6.0-rc.7", + "version": "1.6.0-rc.8", "author": { "name": "fujibee" }, diff --git a/VERSION b/VERSION index a8d05f0fc..af680a2a7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0-rc.7 +1.6.0-rc.8 diff --git a/package.json b/package.json index 655bdd21d..fc115c40a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.6.0-rc.7", + "version": "1.6.0-rc.8", "description": "Cross-agent messaging via SQLite for CLI AI agents (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Antigravity, OpenCode). The npm package is a thin bootstrapper that fetches and runs the canonical bash installer at https://github.com/fujibee/agmsg.", "bin": { "agmsg": "bin/agmsg.js" From db0c453ef566cc15585a635f6532c519f6ca300e Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 14:39:21 +0900 Subject: [PATCH 84/97] Find the Windows Claude Code pid from a hook (#1708) [skip ci] A SessionStart hook does not get CLAUDE_PID, so on native Windows the seat's delivery record was not written on --resume. Ask Windows for the nearest claude.exe ancestor when CLAUDE_PID is unusable. --- scripts/lib/resolve-project.sh | 25 +++++++++++++++++++++++++ tests/test_instance_id.bats | 24 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/scripts/lib/resolve-project.sh b/scripts/lib/resolve-project.sh index c97da8483..ef5769d90 100644 --- a/scripts/lib/resolve-project.sh +++ b/scripts/lib/resolve-project.sh @@ -459,6 +459,21 @@ agmsg_pid_is_agent() { return 1 } +# Print the Windows pid of the nearest ancestor of this shell whose image name is +# (e.g. claude.exe), asking Windows because Git Bash's ps stops at the +# native parent. Empty and non-zero when there is none or CIM is unavailable. +_agmsg_msys_ancestor_pid() { # + local image="$1" winpid found + [ -z "${_AGMSG_COMPAT_NO_CIM:-}" ] || return 1 + winpid="$(_compat_get_winpid "$$" 2>/dev/null)" + case "$winpid" in ''|*[!0-9]*) return 1 ;; esac + found="$(powershell.exe -NoProfile -Command \ + "\$id=$winpid; for(\$i=0;\$i -lt 16 -and \$id;\$i++){ \$p=Get-CimInstance Win32_Process -Filter \"ProcessId=\$id\"; if(-not \$p){break}; if(\$p.Name -ieq '$image'){\$p.ProcessId;break}; \$id=\$p.ParentProcessId }" \ + 2>/dev/null | tr -d '\r')" + case "$found" in ''|*[!0-9]*) return 1 ;; esac + printf '%s' "$found" +} + # Walk the process tree from $$ upward, echoing the PID of the nearest ancestor # that looks like an agent process of . Empty (return 1) when none is # found — e.g. a detached watcher or a plain human shell. @@ -494,6 +509,16 @@ agmsg_agent_pid() { printf '%s' "${CLAUDE_PID:-}"; return 0 fi ;; esac + # A hook does not get CLAUDE_PID (only the Bash tool does), and Git Bash's ps + # cannot see claude.exe, so a SessionStart hook found no seat pid and the + # seat's delivery record was never written on --resume. Ask Windows for the + # nearest claude.exe ancestor instead. + if [ "$_agmsg_platform" = msys ]; then + local native + native="$(_agmsg_msys_ancestor_pid claude.exe)" && [ -n "$native" ] && { + printf '%s' "$native"; return 0 + } + fi fi local pid="$$" hops=0 while [ "${pid:-0}" -gt 1 ] && [ "$hops" -lt 20 ]; do diff --git a/tests/test_instance_id.bats b/tests/test_instance_id.bats index 235360f2e..033e8a935 100644 --- a/tests/test_instance_id.bats +++ b/tests/test_instance_id.bats @@ -476,6 +476,30 @@ gone_pid() { [ "$status" -ne 0 ] } +@test "override: a Windows hook without CLAUDE_PID finds the claude.exe ancestor through Windows" { + unset AGMSG_AGENT_PID CLAUDE_PID _AGMSG_COMPAT_NO_CIM + _agmsg_pid_alive() { return 0; } + compat_get_ppid() { return 1; } + _compat_get_winpid() { printf '7777'; } + # Records the question so the test can see which image was asked for. + powershell.exe() { printf '%s\n' "$*" > "$BATS_TEST_TMPDIR/ps-args"; printf '5480\r\n'; } + _agmsg_platform=msys run agmsg_agent_pid claude-code + [ "$status" -eq 0 ] + [ "$output" = "5480" ] + grep -Fq "claude.exe" "$BATS_TEST_TMPDIR/ps-args" + grep -Fq "7777" "$BATS_TEST_TMPDIR/ps-args" + # No claude.exe ancestor: the bare fallback, as before. + powershell.exe() { :; } + _agmsg_platform=msys run agmsg_agent_pid claude-code + [ "$status" -ne 0 ] + # Never asked off Windows or for another host type. + powershell.exe() { printf '5480'; } + _agmsg_platform=linux run agmsg_agent_pid claude-code + [ "$status" -ne 0 ] + _agmsg_platform=msys run agmsg_agent_pid codex + [ "$status" -ne 0 ] +} + # --- actas distinctness: the #93 payoff --- # Two instance ids that share a session_id prefix but differ in pid must be From 141ebedec6e200f639051bfbff21a1f6cdb27459 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 14:48:18 +0900 Subject: [PATCH 85/97] Report seat_pid_unresolved when a hook cannot publish a destination (#1709) [skip ci] When the SessionStart hook cannot tell which process the session is, the plug clears the seat's old destination if the record names this session (checked under the write lock) and records messaging_blocked=seat_pid_unresolved; agmsgd reports the seat as unaddressable with that reason. --- .../channels/claude-code-queue-store.mjs | 2 + scripts/daemon/channels/claude-code-queue.mjs | 4 ++ .../types/claude-code/_session-start.sh | 19 ++++++++- scripts/lib/role-session.sh | 39 ++++++++++++++++++- tests/agmsgd_claude_code_queue.test.mjs | 17 +++++++- tests/test_role_session.bats | 23 +++++++++++ ...sion_start_claude_code_native_channel.bats | 33 ++++++++++++++++ 7 files changed, 134 insertions(+), 3 deletions(-) diff --git a/scripts/daemon/channels/claude-code-queue-store.mjs b/scripts/daemon/channels/claude-code-queue-store.mjs index 61de0f130..90910f9c9 100644 --- a/scripts/daemon/channels/claude-code-queue-store.mjs +++ b/scripts/daemon/channels/claude-code-queue-store.mjs @@ -87,6 +87,8 @@ export function readClaudeRoleSession(runDir, team, agent) { messaging_socket: fields.messaging_socket ?? "", claude_config_dir: fields.claude_config_dir ?? "", messaging_token: fields.messaging_token ?? "", + // Why the seat published no destination (set by the SessionStart hook). + messaging_blocked: /^[a-z_]{1,64}$/.test(fields.messaging_blocked ?? "") ? fields.messaging_blocked : "", }, }; } catch { diff --git a/scripts/daemon/channels/claude-code-queue.mjs b/scripts/daemon/channels/claude-code-queue.mjs index 621b214e5..d40f0d086 100644 --- a/scripts/daemon/channels/claude-code-queue.mjs +++ b/scripts/daemon/channels/claude-code-queue.mjs @@ -236,6 +236,10 @@ export function createClaudeCodeQueueChannel({ saveSeat(db, { seat, state: "blocked", reason: "role_session_type_mismatch" }, now); return; } + if (record.messaging_blocked) { + saveSeat(db, { seat, state: "unaddressable", reason: record.messaging_blocked }, now); + return; + } if (!record.messaging_socket || !record.claude_config_dir || !record.session) { saveSeat(db, { seat, state: "unaddressable", reason: "messaging_destination_missing" }, now); return; diff --git a/scripts/drivers/types/claude-code/_session-start.sh b/scripts/drivers/types/claude-code/_session-start.sh index f55e51c03..4f02584c6 100644 --- a/scripts/drivers/types/claude-code/_session-start.sh +++ b/scripts/drivers/types/claude-code/_session-start.sh @@ -75,7 +75,10 @@ agmsg_session_start() { bare_sid="$(agmsg_instance_bare_sid "${SESSION_ID:-}" 2>/dev/null || true)" my_instance="$(agmsg_normalize_instance_id "${SESSION_ID:-}" "${TYPE:-}" 2>/dev/null || true)" [ -n "$bare_sid" ] || return 0 - agmsg_instance_is_composite "$my_instance" 2>/dev/null || return 0 + # An instance id without a process id cannot claim the seat's lock; that is + # reported below, once the seat is known, rather than skipped silently. + local pid_unresolved=0 + agmsg_instance_is_composite "$my_instance" 2>/dev/null || pid_unresolved=1 local _tab _p_team _p_agent _rs _own _owner _n=0 _tab="$(printf '\t')" while IFS="$_tab" read -r _p_team _p_agent; do @@ -110,6 +113,20 @@ EOF return 0 fi + if [ "$pid_unresolved" = 1 ]; then + # The record still holds the destination of the process this session + # replaced, so say the seat cannot be reached rather than leave it looking + # addressable. Only a record that names this session is cleared (checked + # under the write lock): a seat held by another live session keeps its own + # destination. + if agmsg_role_session_mark_messaging_blocked "$pair_team" "$pair_agent" seat_pid_unresolved "$bare_sid"; then + echo "AGMSG delivery: agmsgd is running, but this session's own process could not be identified, so the native channel is not used and the old delivery record for $pair_agent@$pair_team was cleared (agmsg daemon status shows seat_pid_unresolved). Next: arm the Monitor below." + else + echo "AGMSG delivery: agmsgd is running, but this session's own process could not be identified, so the native channel is not used. Next: arm the Monitor below." + fi + return 0 + fi + # actas_lock_claim is the write gate -- the SAME primitive watch.sh uses to # reclaim a role's lock across --resume (session-start.sh's own 4th-arg # "Role-aware resume" relies on exactly this, via watch.sh, when Monitor diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index baecfed8d..b05359ba4 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -269,7 +269,7 @@ _agmsg_role_session_set_messaging_unlocked() { # , a short snake_case word). Drop the old destination, which belongs to +# a process that is gone, and record why, so agmsgd reports the seat as +# unaddressable for that reason instead of keeping an out-of-date address. +# is checked under the same lock as the write: a session that +# published in between keeps its destination, and rc 1 says nothing was changed. +agmsg_role_session_mark_messaging_blocked() { # + _agmsg_role_session_write _agmsg_role_session_mark_messaging_blocked_unlocked "$@" +} + +_agmsg_role_session_mark_messaging_blocked_unlocked() { + local team="$1" agent="$2" reason="$3" expect="${4:-}" path dir tmp line current="" + case "$reason" in ''|*[!a-z_]*) return 1 ;; esac + [ -n "$team" ] && [ -n "$agent" ] && [ -n "$expect" ] || return 1 + _agmsg_role_session_path_into "$team" "$agent" + path="$_AGMSG_ROLE_SESSION_PATH" + [ -f "$path" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in session=*) current="${line#session=}"; break ;; esac + done < "$path" + [ "$current" = "$expect" ] || return 1 + dir="$(_actas_lock_dir)" + tmp="$(mktemp "$dir/.role-session.XXXXXX" 2>/dev/null)" || return 1 + { + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + messaging_socket=*|claude_config_dir=*|messaging_token=*|messaging_blocked=*) ;; + *) printf '%s\n' "$line" ;; + esac + done < "$path" + printf 'messaging_blocked=%s\n' "$reason" + } > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; } + chmod 600 "$tmp" 2>/dev/null || true + mv -f "$tmp" "$path" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; } + return 0 +} + agmsg_role_session_mark_named() { _agmsg_role_session_write _agmsg_role_session_mark_named_unlocked "$@" || true } diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index 06246bc57..301facf6f 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -17,7 +17,7 @@ import { checkSocketOwnership, } from '../scripts/daemon/channels/claude-code-queue-io.mjs'; import { createClaudeCodeQueueChannel } from '../scripts/daemon/channels/claude-code-queue.mjs'; -import { ensureClaudeCodeChannelSchema } from '../scripts/daemon/channels/claude-code-queue-store.mjs'; +import { ensureClaudeCodeChannelSchema, readClaudeRoleSession } from '../scripts/daemon/channels/claude-code-queue-store.mjs'; import { openInstallDb } from '../scripts/daemon/db.mjs'; function temporaryDirectory(t) { @@ -429,3 +429,18 @@ test('Windows pipe: the preflight makes no connection, and an authenticated nudg assert.equal(connections, 1); assert.deepEqual(JSON.parse(lines[0]), { type: 'auth', token: 'tok-w' }); }); + +test('a seat whose hook could not publish a destination is reported with that reason, and the record is read from disk', async (t) => { + const { root, db, state } = (() => { const f = channelFixture(t); return { root: f.state.options.installRoot, db: f.db, state: f.state }; })(); + const record = (extra) => `team=test\nagent=alice\ntype=claude-code\nproject=${root}\nsession=sess-1\n${extra}`; + writeFileSync(path.join(root, 'run', 'role-session.test__alice'), record('messaging_blocked=seat_pid_unresolved\n')); + const read = readClaudeRoleSession(path.join(root, 'run'), 'test', 'alice'); + assert.equal(read.record.messaging_blocked, 'seat_pid_unresolved'); + const channel = createClaudeCodeQueueChannel({ ...state.options, readSession: () => read }); + await channel.pollOnce(); + assert.equal(db.prepare("SELECT state || '/' || reason AS r FROM beta_claude_seat").get().r, 'unaddressable/seat_pid_unresolved'); + assert.equal(state.sent, 0); + // A value that is not a plain word is ignored, not shown. + writeFileSync(path.join(root, 'run', 'role-session.test__alice'), record('messaging_blocked=bad value;\n')); + assert.equal(readClaudeRoleSession(path.join(root, 'run'), 'test', 'alice').record.messaging_blocked, ''); +}); diff --git a/tests/test_role_session.bats b/tests/test_role_session.bats index 939c60a87..96a131ef1 100644 --- a/tests/test_role_session.bats +++ b/tests/test_role_session.bats @@ -274,6 +274,29 @@ fake_session() { [[ "$output" == *"OK u=sid-9 p=/p/q r"* ]] } +@test "role-session: mark_messaging_blocked rechecks the session under the lock and leaves a newer session's destination alone" { + agmsg_role_session_record T alice sid-old /p/q claude-code + agmsg_role_session_set_messaging T alice /tmp/cc-socks/old.sock /home/x/.claude sid-old tok-old + # The record is read as sid-old, then a newer session publishes before the mark lands. + agmsg_role_session_set_messaging T alice /tmp/cc-socks/new.sock /home/x/.claude sid-new tok-new + run agmsg_role_session_mark_messaging_blocked T alice seat_pid_unresolved sid-old + [ "$status" -eq 1 ] + [ "$(agmsg_role_session_get T alice messaging_socket)" = /tmp/cc-socks/new.sock ] + [ "$(agmsg_role_session_get T alice messaging_token)" = tok-new ] + [ -z "$(agmsg_role_session_get T alice messaging_blocked)" ] + # The session that owns the record gets the mark, and its destination goes. + run agmsg_role_session_mark_messaging_blocked T alice seat_pid_unresolved sid-new + [ "$status" -eq 0 ] + [ "$(agmsg_role_session_get T alice messaging_blocked)" = seat_pid_unresolved ] + [ -z "$(agmsg_role_session_get T alice messaging_socket)" ] + [ -z "$(agmsg_role_session_get T alice messaging_token)" ] + # No expected session, or a reason that is not a plain word: nothing is written. + run agmsg_role_session_mark_messaging_blocked T alice seat_pid_unresolved "" + [ "$status" -eq 1 ] + run agmsg_role_session_mark_messaging_blocked T alice "bad reason" sid-new + [ "$status" -eq 1 ] +} + # Claude Code native-channel delivery (2026-10-05, arch-8 §12.1): a --resume # starts a brand-new process bound to a brand-new CLAUDE_CODE_MESSAGING_SOCKET # (measured in memory/design/2026-10-05-cross-session-messaging-socket- diff --git a/tests/test_session_start_claude_code_native_channel.bats b/tests/test_session_start_claude_code_native_channel.bats index 9ad919427..ecdbeebc1 100644 --- a/tests/test_session_start_claude_code_native_channel.bats +++ b/tests/test_session_start_claude_code_native_channel.bats @@ -117,6 +117,39 @@ _mark_daemon_ready() { grep -qF "agmsgd is running and will deliver" <<<"$output" } +@test "session-start: a resumed session whose own pid cannot be identified clears the stale destination and says why" { + _mark_daemon_ready + env AGMSG_AGENT_PID=$$ bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "sid-nopid" >/dev/null + run env AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/old.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-nopid"}' + grep -qF "agmsgd is running and will deliver" <<<"$output" + grep -qF "messaging_socket=/tmp/cc-socks/old.sock" "$RUN_DIR/role-session.team__alice" + # The resume: same session id, the pid cannot be told. + run env AGMSG_AGENT_PID= AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/new.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-nopid"}' + [ "$status" -eq 0 ] + grep -qF "seat_pid_unresolved" <<<"$output" + refute grep -qF "will deliver to this session directly" <<<"$output" + refute grep -q "^messaging_socket=" "$RUN_DIR/role-session.team__alice" + grep -qF "messaging_blocked=seat_pid_unresolved" "$RUN_DIR/role-session.team__alice" + # A later publish (the pid is known) takes the block away again. + run env AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/new.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-nopid"}' + grep -qF "messaging_socket=/tmp/cc-socks/new.sock" "$RUN_DIR/role-session.team__alice" + refute grep -q "^messaging_blocked=" "$RUN_DIR/role-session.team__alice" +} + +@test "session-start: an unidentified pid never clears a record that names another session" { + _mark_daemon_ready + env AGMSG_AGENT_PID=$$ bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "sid-owner" >/dev/null + run env AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/owner.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-owner"}' + run env AGMSG_AGENT_PID= AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/other.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-other"}' + grep -qF "messaging_socket=/tmp/cc-socks/owner.sock" "$RUN_DIR/role-session.team__alice" + refute grep -q "^messaging_blocked=" "$RUN_DIR/role-session.team__alice" +} + @test "session-start: agmsgd not running -> the usual Monitor directive still fires" { run _run_session_start "sid-no-daemon" [ "$status" -eq 0 ] From c0d57d5f88f7b4d02a1397a342e073757f6604d9 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 14:50:44 +0900 Subject: [PATCH 86/97] Exit 0 when agmsgd steps aside for an update (#1711) [skip ci] Stepping aside for an update exits 0 so launchd and systemd schedule no restart during the update; the installer starts the new version right after. The launcher keeps exit 75 for its own refusals. --- install.sh | 4 ++-- scripts/daemon/main.mjs | 16 +++++++++++++--- tests/agmsgd_main.test.mjs | 11 ++++++++++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/install.sh b/install.sh index 4618261b5..89742123c 100755 --- a/install.sh +++ b/install.sh @@ -141,8 +141,8 @@ agmsg_source_version() { } # After an --update the running agmsgd notices the new files within one poll -# (5s), stops itself with "stepped_aside_for_update" and nothing starts it -# again. When the install had the daemon turned on, wait for that step-aside +# (5s), stops itself with "stepped_aside_for_update" (exit 0, so the service +# manager does not schedule a restart of its own) and nothing starts it again. When the install had the daemon turned on, wait for that step-aside # (bounded) and start the new version, so an enabled daemon comes back by # itself. A failure here never fails the update; it says how to start it. agmsg_install_restart_daemon() { # diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs index 9055720da..eb2a0406c 100644 --- a/scripts/daemon/main.mjs +++ b/scripts/daemon/main.mjs @@ -143,8 +143,18 @@ export async function gracefulStop(db, installRoot, gen, controlHandle, channelH // pollOnce(), real SIGTERM handling, and control.mjs's stop/status // handlers, onto db/manifest values the caller (agmsgd's bootstrap) has // already verified. Exit codes: 0 for a declined -// start or a normal/SIGTERM stop, 75 for stepping aside for an update, 1 -// for a bind failure or any other unexpected error. +// start, a normal/SIGTERM stop or stepping aside for an update, 1 for a bind +// failure or any other unexpected error. +// The code a stopped daemon exits with. Stepping aside for an update exits 0 on +// purpose: the service managers restart only after a failure (launchd +// KeepAlive SuccessfulExit=false, systemd Restart=on-failure), and a restart +// during an update finds the install half replaced, is refused by the launcher +// and starts the manager's wait (60 s on launchd) before the installer's own +// start can bring the new version up. The installer starts it right after. +export function stopExitCode(_reason) { + return 0; +} + export async function main(db, { installRoot, manifest, manifestText, expectedDesired, expectedOpGen, version, prepareClaim }) { const channelHooks = []; let controlHandle; @@ -157,7 +167,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe stopping = true; clearInterval(timer); await gracefulStop(db, installRoot, gen, controlHandle, channelHooks, reason); - process.exit(reason === "stepped_aside_for_update" ? 75 : 0); + process.exit(stopExitCode(reason)); } const started = await startup(db, { diff --git a/tests/agmsgd_main.test.mjs b/tests/agmsgd_main.test.mjs index e46666b29..8487b0941 100644 --- a/tests/agmsgd_main.test.mjs +++ b/tests/agmsgd_main.test.mjs @@ -5,7 +5,7 @@ import { join } from "node:path"; import test from "node:test"; import { openInstallDb } from "../scripts/daemon/db.mjs"; import { readOwner } from "../scripts/daemon/owner.mjs"; -import { gracefulStop, pollChannelHooks, pollCycle, pollOnce, prepareClaimWithCodexSchema, startup } from "../scripts/daemon/main.mjs"; +import { gracefulStop, pollChannelHooks, pollCycle, pollOnce, prepareClaimWithCodexSchema, startup, stopExitCode } from "../scripts/daemon/main.mjs"; import { captureWatchState } from "../scripts/daemon/lifecycle.mjs"; import { createHash } from "node:crypto"; @@ -189,3 +189,12 @@ test("channel setup is inside the prepared install lock and channel polls stay s rmSync(root, { recursive: true, force: true }); } }); + +test("stepping aside for an update exits 0 so the service manager does not schedule a restart", () => { + assert.equal(stopExitCode("stepped_aside_for_update"), 0); + assert.equal(stopExitCode("normal"), 0); + // The premise: both managers restart only after a failure, so exit 0 leaves the start to the installer. + const script = readFileSync(join(import.meta.dirname, "..", "scripts", "daemon.sh"), "utf8"); + assert.match(script, /SuccessfulExit<\/key>/); + assert.match(script, /Restart=on-failure/); +}); From 6c8be9c633d880ac0e12729d74c5f3061bc336c3 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 21 Oct 2026 22:51:14 -0700 Subject: [PATCH 87/97] release: 1.6.0-rc.9 --- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 180752bdf..264a30714 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agmsg", "description": "Cross-agent messaging via SQLite. Send messages between CLI AI agents. No daemon, no network.", - "version": "1.6.0-rc.8", + "version": "1.6.0-rc.9", "author": { "name": "fujibee" }, diff --git a/VERSION b/VERSION index af680a2a7..b65fb4f39 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0-rc.8 +1.6.0-rc.9 diff --git a/package.json b/package.json index fc115c40a..ae53ca193 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agmsg", - "version": "1.6.0-rc.8", + "version": "1.6.0-rc.9", "description": "Cross-agent messaging via SQLite for CLI AI agents (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Antigravity, OpenCode). The npm package is a thin bootstrapper that fetches and runs the canonical bash installer at https://github.com/fujibee/agmsg.", "bin": { "agmsg": "bin/agmsg.js" From b2c1f8823931136cc6a1e1c99d4665c8a28944fa Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 15:22:20 +0900 Subject: [PATCH 88/97] README: agmsgd Windows Claude Code notes; installer: point at agmsgd while it is off (#1715) [skip ci] --- README.md | 2 +- install.sh | 11 +++++++++++ tests/agmsgd_status_display.test.mjs | 21 ++++++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 62ee797db..49bd4baed 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Git Bash PATH). There is no PowerShell reimplementation. ### agmsgd beta (1.6.0) -The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue, and sends Claude Code sessions a notice directly through that session's own cross-session messaging socket. It detects whether that Claude Code session is running in bypass-permissions mode and labels notices for that mode automatically, with no action from you. A Claude Code seat delivered this way never needs its Monitor watch re-armed. The Claude Code path is macOS/Linux only; a Windows Claude Code seat keeps using Monitor as before, even with agmsgd enabled and running. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, take over remote sync, or change how any agent other than Claude Code and Codex receives messages. macOS, Linux, and Windows are supported for Codex. Windows requires native Codex 0.157.0 or later; earlier versions are blocked with a reason. Native queue execution and delivery to a logged-in live Windows session were measured with Codex 0.157.0 and 0.160.0. Unreadable version or delivery evidence keeps the existing conservative handling. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. +The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue, and sends Claude Code sessions a notice directly through that session's own cross-session messaging socket. It detects whether that Claude Code session is running in bypass-permissions mode and labels notices for that mode automatically, with no action from you. A Claude Code seat delivered this way never needs its Monitor watch re-armed. On Windows, Claude Code must be 2.1.302 or later to receive notices this way (2.1.198 has no endpoint for them). A Claude Code seat whose delivery destination cannot be confirmed, such as one on an older Claude Code, keeps using Monitor as before, even with agmsgd enabled and running. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, take over remote sync, or change how any agent other than Claude Code and Codex receives messages. macOS, Linux, and Windows are supported for Codex. Windows requires native Codex 0.157.0 or later; earlier versions are blocked with a reason. Native queue execution and delivery to a logged-in live Windows session were measured with Codex 0.157.0 and 0.160.0. Unreadable version or delivery evidence keeps the existing conservative handling. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. Requires Node >= 22.13.0 with `node:sqlite` (the experimental SQLite warning is expected). Install Node from [nodejs.org](https://nodejs.org/en/download). Enable and inspect the beta with: diff --git a/install.sh b/install.sh index 89742123c..5b5a82318 100755 --- a/install.sh +++ b/install.sh @@ -177,6 +177,15 @@ agmsg_install_restart_guidance() { echo ' This installation does not enable agmsgd or switch existing sessions automatically.' } +# One line pointing at agmsgd, only while it is off. Never turns it on. Kept in +# one place so the wording, and later a different default, change here only. +agmsg_install_daemon_hint() { # + local skill="$1" desired + desired="$(sqlite3 -readonly "$skill/run/install.db" "SELECT desired FROM daemon_intent;" 2>/dev/null || true)" + [ "$desired" != on ] || return 0 + printf ' agmsgd (beta) delivers notices to Claude Code and Codex sessions directly. Turn it on with: bash %q daemon enable\n' "$skill/scripts/agmsg" +} + # --- Defaults --- CMD_NAME="" UPDATE_ONLY=false @@ -1239,6 +1248,7 @@ $_agmsg_running_team" agmsg_install_op_run_phase configure_codex_sandbox || exit 1 echo "" agmsg_install_restart_guidance + agmsg_install_daemon_hint "$SKILL_DIR" echo "" # #963: a sync engine has no equivalent self-detection, so this update # restarts one itself for every team the snapshot above found running. @@ -1471,6 +1481,7 @@ echo " ✓ Installed to ~/.agents/skills/$CMD_NAME/ (version $INSTALLED_VERSION echo "" echo " Next steps:" agmsg_install_restart_guidance +agmsg_install_daemon_hint "$SKILL_DIR" echo " Run the command to join a team:" echo " Claude Code: /$CMD_NAME" echo " Codex: \$$CMD_NAME" diff --git a/tests/agmsgd_status_display.test.mjs b/tests/agmsgd_status_display.test.mjs index e4e765b87..d3ea12607 100644 --- a/tests/agmsgd_status_display.test.mjs +++ b/tests/agmsgd_status_display.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { chmodSync, cpSync, mkdirSync, mkdtempSync, existsSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'; +import { chmodSync, cpSync, mkdirSync, mkdtempSync, existsSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { execFileSync, spawn, spawnSync } from 'node:child_process'; @@ -195,6 +195,25 @@ test('installer restart guidance tells users to resume, refresh their destinatio assert.equal((source.match(/^agmsg_install_restart_guidance$/gm) ?? []).length, 1); }); +test('installer points at agmsgd only while it is off, and never turns it on', t => { + const source = readFileSync(join(repo, 'install.sh'), 'utf8'); + const fn = source.match(/agmsg_install_daemon_hint\(\) \{[\s\S]*?\n\}/)[0]; + const skill = mkdtempSync(join(tmpdir(), 'agmsg-install-hint-')); + t.after(() => rmSync(skill, { recursive: true, force: true })); + const hint = () => execFileSync('bash', ['-c', `${fn}\nagmsg_install_daemon_hint "$1"`, 'bash', skill], { encoding: 'utf8' }); + // No install record yet (a fresh install): off. + assert.match(hint(), /agmsgd \(beta\) delivers notices.*Turn it on with: bash .*\/scripts\/agmsg daemon enable/); + mkdirSync(join(skill, 'run')); + const setIntent = (desired) => execFileSync('sqlite3', [join(skill, 'run', 'install.db'), + `DROP TABLE IF EXISTS daemon_intent; CREATE TABLE daemon_intent (desired TEXT); INSERT INTO daemon_intent VALUES ('${desired}');`]); + setIntent('off'); + assert.match(hint(), /daemon enable/); + setIntent('on'); + assert.equal(hint(), ''); + assert.equal((source.match(/^ agmsg_install_daemon_hint "\$SKILL_DIR"$/gm) ?? []).length, 1); + assert.equal((source.match(/^agmsg_install_daemon_hint "\$SKILL_DIR"$/gm) ?? []).length, 1); +}); + test('pending confirmations remain discoverable after a seat registration is removed', t => { const { db, root, status } = fixture(t); db.prepare("INSERT INTO beta_codex_queue (seat,codex_home,thread,up_to,nonce,state,children,created_at) VALUES (?, ?, ?, '{}', 'removed-pending', 'pending', 'incomplete', '2020-01-01T00:00:00Z')") From 3dd97117fbce523452dc746c77ec9db7c6a291bc Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 15:37:12 +0900 Subject: [PATCH 89/97] session-start: introduce agmsgd once to an install that has not turned it on (#1716) Existing installs that update to 1.6.0 keep their current delivery and have no way to learn that agmsgd exists. This prints one line to the agent's screen on the first Claude Code or Codex SessionStart of an install whose agmsgd intent is not `on`: `A new delivery, agmsgd, is available: notices arrive directly, with no Monitor watch to re-arm. Switch with /agmsg delivery agmsgd. Nothing changes until you do.` (`$agmsg` on Codex; the prefix and command name come from the install, as for `actas`.) - Once per install: the notice claims `run/agmsgd-intro-shown` first (noclobber) and prints only if it created the file. Sessions starting together show it once, and a marker that cannot be written shows nothing instead of repeating on every start. - Only when the recorded intent is `off` or `unset`. An install with agmsgd on, or with no readable record, gets nothing and no marker. - It switches nothing. Tests (in `test_session_start_claude_code_native_channel.bats`): the notice appears on the first start and not on the second, with the marker present; with agmsgd on there is no notice and no marker. --- scripts/lib/agmsgd-intro.sh | 37 +++++++++++++++++++ scripts/session-start.sh | 5 +++ ...sion_start_claude_code_native_channel.bats | 24 ++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 scripts/lib/agmsgd-intro.sh diff --git a/scripts/lib/agmsgd-intro.sh b/scripts/lib/agmsgd-intro.sh new file mode 100644 index 000000000..ba92a5bb2 --- /dev/null +++ b/scripts/lib/agmsgd-intro.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# One-time introduction to agmsgd for an install that has not turned it on. +# +# Sourced by session-start.sh. The notice goes to the agent's own screen (hook +# stdout), once per install: the first SessionStart after the install is seen +# with agmsgd off writes run/agmsgd-intro-shown, and no later session prints it +# again. It never switches anything; the reader decides. +# +# Requires: SKILL_DIR and RUN_DIR set; type-registry.sh sourced by the caller. + +[ -n "${_AGMSG_AGMSGD_INTRO_SH:-}" ] && return 0 +_AGMSG_AGMSGD_INTRO_SH=1 + +agmsg_agmsgd_intro_notice() { + local type="${1:-}" skill_dir="${SKILL_DIR:-}" run_dir="${RUN_DIR:-}" marker prefix cmd + [ -n "$skill_dir" ] && [ -n "$run_dir" ] || return 0 + marker="$run_dir/agmsgd-intro-shown" + case "$type" in claude-code|codex) ;; *) return 0 ;; esac + [ ! -e "$marker" ] || return 0 + if ! declare -F agmsg_daemon_read_state >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$skill_dir/scripts/lib/daemon-state.sh" + fi + agmsg_daemon_read_state + # Only a recorded intent that is not "on" gets the notice. An install with no + # readable record cannot switch anyway, so there is nothing to offer. + case "${AGMSGD_DESIRED:-unknown}" in off|unset) ;; *) return 0 ;; esac + # Claim the marker before printing, and print only if this call created it: + # two sessions starting together show it once, and a marker that cannot be + # written shows nothing rather than the same line on every start. + mkdir -p "$run_dir" 2>/dev/null || return 0 + ( set -C; : > "$marker" ) 2>/dev/null || return 0 + cmd="$(basename "$skill_dir")" + prefix="$(agmsg_type_get "$type" cmd_prefix 2>/dev/null || true)" + [ -n "$prefix" ] || prefix="/" + printf 'AGMSG: A new delivery, agmsgd, is available: notices arrive directly, with no Monitor watch to re-arm. Switch with %s%s delivery agmsgd. Nothing changes until you do.\n' "$prefix" "$cmd" +} diff --git a/scripts/session-start.sh b/scripts/session-start.sh index 8c7bb78e4..3f0b4daf0 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -61,6 +61,11 @@ source "$SCRIPT_DIR/lib/terminal-context-line.sh" PAIRS=$("$SCRIPT_DIR/identities.sh" "$PROJECT" "$TYPE" 2>/dev/null || true) [ -n "$PAIRS" ] || exit 0 +# One-time introduction to agmsgd, for an install that has not turned it on. +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/agmsgd-intro.sh" +agmsg_agmsgd_intro_notice "$TYPE" + # Read hook input JSON from stdin BEFORE the type plug below runs (#1468): # stdin can only be read once, and a plug that needs a hook input field (e.g. # codex reading `source` to tell startup/resume/clear apart) has to see it diff --git a/tests/test_session_start_claude_code_native_channel.bats b/tests/test_session_start_claude_code_native_channel.bats index ecdbeebc1..7d164532a 100644 --- a/tests/test_session_start_claude_code_native_channel.bats +++ b/tests/test_session_start_claude_code_native_channel.bats @@ -298,3 +298,27 @@ _mark_daemon_ready() { [ "$status" -eq 0 ] [ "$(cat "$BATS_TEST_TMPDIR/published")" = '\\.\pipe\LOCAL\cc-msg-ab12|C:\Users\me/.claude|tok-1' ] } + +# 1.6.0 migration: an install that has not turned agmsgd on is told about it +# once, on a session's screen, and never again; one that has turned it on is +# never told. +@test "session-start: agmsgd off -> the introduction is printed once per install, then never again" { + sqlite3 "$RUN_DIR/install.db" < "$SCRIPTS/daemon/schema.sql" + run _run_session_start "sid-intro-1" + [ "$status" -eq 0 ] + grep -qF "A new delivery, agmsgd, is available" <<<"$output" + grep -qF "Switch with /" <<<"$output" + grep -qF "Nothing changes until you do." <<<"$output" + [ -e "$RUN_DIR/agmsgd-intro-shown" ] + run _run_session_start "sid-intro-2" + [ "$status" -eq 0 ] + refute grep -qF "A new delivery, agmsgd" <<<"$output" +} + +@test "session-start: agmsgd on -> no introduction and no marker" { + _mark_daemon_ready + run _run_session_start "sid-intro-on" + [ "$status" -eq 0 ] + refute grep -qF "A new delivery, agmsgd" <<<"$output" + [ ! -e "$RUN_DIR/agmsgd-intro-shown" ] +} From 524af9d9128ec79e4361167a92c5e5d47d1c392e Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 15:49:08 +0900 Subject: [PATCH 90/97] Offer agmsgd as the recommended delivery choice; add agmsg delivery (#1717) [skip ci] The delivery choice screen lists agmsgd first as the recommended choice and the old monitor as monitor (legacy). agmsg daemon offer reports whether agmsgd can be offered, is running, or is turned on but not running. agmsg delivery is the command name (mode stays an alias) and switches delivery from the agent or a shell. The README delivery section follows. --- README.ja.md | 22 ++-- README.md | 22 ++-- SKILL.md | 19 ++- scripts/agmsg | 5 + scripts/daemon.sh | 55 +++++++- scripts/delivery-cli.sh | 92 +++++++++++++ scripts/drivers/types/claude-code/template.md | 19 ++- scripts/drivers/types/codex/template.md | 20 ++- tests/test_daemon_offer.bats | 82 ++++++++++++ tests/test_delivery_choice_screen.bats | 75 +++++++++++ tests/test_delivery_cli.bats | 122 ++++++++++++++++++ 11 files changed, 501 insertions(+), 32 deletions(-) create mode 100755 scripts/delivery-cli.sh create mode 100644 tests/test_daemon_offer.bats create mode 100644 tests/test_delivery_choice_screen.bats create mode 100644 tests/test_delivery_cli.bats diff --git a/README.ja.md b/README.ja.md index 63cd3866d..af544920d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -229,23 +229,27 @@ despawnは指定されたメンバーにのみ作用する — `despawn` を実 ## 配信モード -受信メッセージがエージェントにどう届くか。初回参加時のプロンプトで1つ選ぶか、後で `/agmsg mode ` で変更する。 +受信メッセージがエージェントにどう届くか。初回参加時のプロンプトで1つ選ぶか、後で `/agmsg delivery ` で変更する(`/agmsg mode` も別名として使える。シェルからは `agmsg delivery `)。Claude CodeとCodexではプロンプトの先頭にagmsgdがおすすめとして出て、monitorは **`monitor (legacy)`** と表示される(モードの値は今まで通り `monitor`)。インストールや更新でagmsgdが勝手にオンになることはなく、更新で今使っているモードが変わることもない。 | モード | 仕組み | レイテンシ | 向いている相手 | |---|---|---|---| -| **`monitor`**(Claude Codeのデフォルト。OpenCodeではopencode-sentinel pluginで利用可能) | SessionStartフック → Monitorツール → ブロッキングSQLiteストリーム | 約5秒 | リアルタイムプッシュを望むClaude Codeユーザー | +| **agmsgd**(Claude CodeとCodexでおすすめ。プロンプトか `/agmsg delivery agmsgd` で選ぶ) | バックグラウンドサービスが通知をセッションに直接届ける。Monitorタスクは不要 | 数秒 | サービスを動かせるClaude Code / Codexユーザー。ログイン時に起動するサービスを登録し、`agmsg daemon disable` で止めて削除できる | +| **`monitor (legacy)`**(値は `monitor`。Claude Codeのデフォルト。OpenCodeではopencode-sentinel pluginで利用可能) | SessionStartフック → Monitorツール → ブロッキングSQLiteストリーム | 約5秒 | リアルタイムプッシュを望むClaude Codeユーザー | | **`turn`**(Codex / Copilot CLI / OpenCode(plugin未導入)のデフォルト) | アシスタントのターン間でStopフックが `check-inbox.sh` を発火 | 次のやり取りまで | monitorを実行していないCodex / Copilot CLI / OpenCodeユーザー、より静かなループを好むClaude Codeユーザー | | **`both`** | monitorを主に、turnをセッションごとの安全網として | 約5秒。ウォッチャー障害時はturn相当にフォールバック | 二重の保険をかけたい場合 | | **`off`** | 自動配信なし | 手動の `/agmsg` のみ | ミニマリスト | +`agmsg daemon enable` が断る環境(Node 22.13未満、JSONLのメッセージストレージ、Linuxでsystemdのユーザーサービスが使えない、WindowsのCodexが0.157未満)ではagmsgdは出さず、理由を1行添えて他のモードを並べる。agmsgdがすでにオンのときはどれを使うかを聞かず、そう伝えたうえで、そのまま使うか `monitor (legacy)` に切り替えるかだけ尋ねる。agmsgdを選ぶと `agmsg daemon enable` を実行し、届け先がまだ確かめられないセッションのために、プロジェクトのモードは `monitor` のままにしておく。 + ### モードの選択 ``` -/agmsg mode monitor — このプロジェクトをリアルタイムプッシュに切り替え(Claude Code) -/agmsg mode turn — ターン間チェックに切り替え -/agmsg mode both — monitorをturnを安全網として併用 -/agmsg mode off — 手動の/agmsgのみ -/agmsg mode — 現在のモードを表示 +/agmsg delivery agmsgd — バックグラウンドサービスによる直接配信(Claude Code、Codex) +/agmsg delivery monitor — このプロジェクトをリアルタイムプッシュに切り替え(Claude Code) +/agmsg delivery turn — ターン間チェックに切り替え +/agmsg delivery both — monitorをturnを安全網として併用 +/agmsg delivery off — 手動の/agmsgのみ +/agmsg delivery — 現在のモードを表示 ``` 設定はプロジェクトごと。`/.claude/settings.local.json` にはそのプロジェクトで選んだモードが必要とするフックだけが設定される — `set` の呼び出しを繰り返しても冪等だ。 @@ -257,7 +261,7 @@ despawnは指定されたメンバーにのみ作用する — `despawn` を実 `hook on` は現在 `mode turn` の薄いエイリアスになっている(非推奨の一行ヒント付き)。リアルタイムプッシュに切り替えるには: ``` -/agmsg mode monitor +/agmsg delivery monitor ``` このコマンドは `db/config.yaml` を更新し、プロジェクトのフックエントリを書き換え、現在のセッションで `monitor` を有効化する `AGMSG-DIRECTIVE` を表示する — エージェントの再起動は不要。 @@ -271,7 +275,7 @@ despawnは指定されたメンバーにのみ作用する — `despawn` を実 /agmsg history — メッセージ履歴 /agmsg team — チームメンバー一覧 /agmsg send — メッセージ送信 -/agmsg mode — 配信モード切り替え +/agmsg delivery — 配信の切り替え(別名: /agmsg mode) /agmsg mode — 現在のモードを表示 /agmsg actas — このプロジェクトで別のロールに切り替え(必要なら作成) /agmsg drop — このプロジェクトからロールを削除 diff --git a/README.md b/README.md index 49bd4baed..38f3c5212 100644 --- a/README.md +++ b/README.md @@ -266,23 +266,27 @@ the manual fallback. ## Delivery modes -How incoming messages reach your agent. Pick one at first join via the prompt, or change it later with `/agmsg mode `. +How incoming messages reach your agent. Pick one at first join via the prompt, or change it later with `/agmsg delivery ` (`/agmsg mode` still works as an alias; from a shell: `agmsg delivery `). On Claude Code and Codex the prompt offers agmsgd first, as the recommended choice, and lists the monitor as **`monitor (legacy)`** (the mode value is still `monitor`). Installing or updating never turns agmsgd on by itself, and an update changes nothing about the mode you already use. | mode | mechanism | latency | who it's for | |---|---|---|---| -| **`monitor`** (default on Claude Code; on OpenCode with the opencode-sentinel plugin) | SessionStart hook → Monitor tool → blocking SQLite stream | ~5s | Claude Code users wanting real-time push | +| **agmsgd** (recommended on Claude Code and Codex; choose it at the prompt or with `/agmsg delivery agmsgd`) | a background service delivers a notice straight into the session, no Monitor task needed | a few seconds | anyone on Claude Code or Codex who can run the service: it registers one that starts at login, and `agmsg daemon disable` stops and removes it | +| **`monitor (legacy)`** (value `monitor`; default on Claude Code; on OpenCode with the opencode-sentinel plugin) | SessionStart hook → Monitor tool → blocking SQLite stream | ~5s | Claude Code users wanting real-time push | | **`turn`** (default on Codex / Copilot CLI / OpenCode without the plugin) | Stop hook fires `check-inbox.sh` between assistant turns | until your next interaction | Codex / Copilot CLI / OpenCode users not running monitor; Claude Code users on a quieter loop | | **`both`** | monitor primary, turn as per-session safety net | ~5s; falls back to turn-end on watcher failure | belt-and-suspenders | | **`off`** | no automatic delivery | manual `/agmsg` only | minimalists | +agmsgd is not offered where `agmsg daemon enable` would refuse (Node older than 22.13, a JSONL message storage, no systemd user services on Linux, Codex older than 0.157 on Windows); the prompt says why in one line and lists the other modes. When agmsgd is already on, the prompt does not ask which to use: it says so and asks only whether to keep it or switch to `monitor (legacy)`. Choosing agmsgd runs `agmsg daemon enable` and leaves the project on `monitor` as the fallback for a session whose destination is not confirmed yet. + ### Picking a mode ``` -/agmsg mode monitor — switch this project to real-time push (Claude Code) -/agmsg mode turn — switch to between-turns checking -/agmsg mode both — monitor with turn as a safety net -/agmsg mode off — manual /agmsg only -/agmsg mode — show current mode +/agmsg delivery agmsgd — direct delivery by the background service (Claude Code, Codex) +/agmsg delivery monitor — switch this project to real-time push (Claude Code) +/agmsg delivery turn — switch to between-turns checking +/agmsg delivery both — monitor with turn as a safety net +/agmsg delivery off — manual /agmsg only +/agmsg delivery — show current mode ``` Settings are per-project. Each `/.claude/settings.local.json` gets exactly the hooks the chosen mode needs — repeated `set` calls are idempotent. @@ -312,7 +316,7 @@ TaskList may list this task, but not every environment does (the desktop app's C `hook on` is now a thin alias for `mode turn` (with a one-line deprecation hint). To switch to real-time push: ``` -/agmsg mode monitor +/agmsg delivery monitor ``` The command updates `db/config.yaml`, rewrites the project's hook entries, and prints an `AGMSG-DIRECTIVE` that activates `monitor` in the current session — no agent restart needed. @@ -326,7 +330,7 @@ The command updates `db/config.yaml`, rewrites the project's hook entries, and p /agmsg history — message history /agmsg team — list team members /agmsg send — send message -/agmsg mode — switch delivery mode +/agmsg delivery — switch delivery (alias: /agmsg mode) /agmsg mode — show current mode /agmsg actas — switch to another role in this project (create if needed) /agmsg drop — remove a role from this project diff --git a/SKILL.md b/SKILL.md index 69e30f69a..490eb8223 100644 --- a/SKILL.md +++ b/SKILL.md @@ -87,8 +87,19 @@ Four possible outputs: > - `/agmsg history` — message history - 5. **REQUIRED — Do NOT skip this step.** Ask the user to pick `monitor`, `turn`, `both`, or `off` delivery. Empty input means `monitor`. - Run `~/.agents/skills/agmsg/scripts/delivery.sh set claude-code "$(pwd)"` and follow the printed `AGMSG-DIRECTIVE` block. + 5. **REQUIRED — Do NOT skip this step.** Choose how incoming messages reach this agent. Show this screen every time (a new join, a new seat added by someone already using agmsg, and when the mode is changed). First run `~/.agents/skills/agmsg/scripts/agmsg daemon offer claude-code`; it only reads and prints one line: + - `agmsgd=available`: ask the user to pick one, in this order: + 1. `agmsgd` — notices delivered directly by a background service (recommended). Choosing it registers a background service that starts at login; `agmsg daemon disable` stops and removes it. Empty input means this one. + 2. `monitor (legacy)` — the Claude Code Monitor tool streams the inbox (the `monitor` mode value). + 3. `turn`, 4. `both`, 5. `off` — as before. + - `agmsgd=on`: do not ask which to use. Say "agmsgd is on: notices are delivered directly" and ask only whether to keep using it or switch to `monitor (legacy)`. + - `agmsgd=not-running reason=...`: agmsgd is turned on but is not running, so do not say notices are delivered directly. Say so with the reason and ask whether to start it (`agmsg delivery agmsgd` starts it) or switch to `monitor (legacy)`. + - `agmsgd=unavailable reason=...`: do not offer agmsgd; say the reason in one line, then ask for `monitor (legacy)`, `turn`, `both`, or `off` (empty input means `monitor`). + + To apply the choice, run `~/.agents/skills/agmsg/scripts/agmsg delivery --type claude-code --project "$(pwd)"` with `` = `agmsgd`, `monitor`, `turn`, `both` or `off` (`monitor (legacy)` is the value `monitor`) and show its output and follow the printed `AGMSG-DIRECTIVE` block: + - `agmsgd` turns the background service on (`daemon enable`) and leaves the project on `monitor` as the fallback for a seat whose destination is not confirmed yet. If it fails, say so and offer the other choices. + - Keep using agmsgd (it is on or turned on but not running): run `agmsg delivery agmsgd` the same way. + - `monitor (legacy)` when agmsgd is on: run `~/.agents/skills/agmsg/scripts/agmsg daemon disable` first, then `agmsg delivery monitor`. 6. Then check inbox for the newly joined team. @@ -288,9 +299,9 @@ If argument starts with "poke" (e.g. "poke reviewer status?"): 3. `poke` TYPES INTO another agent's session and submits it, as if a person had typed it there. Use it to reach a member whose watcher is not delivering (that is what it is for); use `send` for ordinary messages, which the member reads on its own terms. 4. Exit codes split what "could not poke" means — see the shape and the pointer to the driver-specific file in point 4 of the "where" section above. **13** specifically means: do not fall back to `send` silently; the two are not the same act, say which one you did. Two more codes are `poke.sh`'s own, the same across every driver (not in the per-driver files, which only cover the driver's own layer below this one): **14** means it found the input box and it looks like someone is actively typing there right now — a transient condition `--retries` waits out. **15** means it could not even confirm where the input box is on this read (e.g. a mid-redraw screen) — a different finding from 14, not a typing detection, though it is also transient and also covered by `--retries`. -If argument is "mode", run `~/.agents/skills/agmsg/scripts/delivery.sh status claude-code "$(pwd)"`. Show the output to the user, and if it says `mode: monitor` (or `both`), say explicitly that this reports project *configuration* only — it does not prove the runtime Monitor task is attached in the current session. To confirm the runtime state, look for a Monitor whose description begins with `agmsg inbox stream` (after an `actas` it reads `agmsg inbox stream (acting as )`). TaskList may list this task, but not every environment does (the desktop app's Code tab runs the Monitor and delivers its events without listing it), so a task missing from TaskList is not a failure: judge by the Monitor call starting and its events arriving. The background-task footer is not a reliable check either. +If argument is "delivery" (or its alias "mode") with nothing after it, run `~/.agents/skills/agmsg/scripts/agmsg delivery status --type claude-code --project "$(pwd)"`. It shows both the install-wide agmsgd state (for example "delivery: agmsgd (on) — notices are delivered directly") and this project's own delivery ("this project's fallback: monitor (legacy)"). Show the output to the user, and if it says `mode: monitor` (or `both`), say explicitly that this reports project *configuration* only — it does not prove the runtime Monitor task is attached in the current session. To confirm the runtime state, look for a Monitor whose description begins with `agmsg inbox stream` (after an `actas` it reads `agmsg inbox stream (acting as )`). TaskList may list this task, but not every environment does (the desktop app's Code tab runs the Monitor and delivers its events without listing it), so a task missing from TaskList is not a failure: judge by the Monitor call starting and its events arriving. The background-task footer is not a reliable check either. When agmsgd is off but available, tell the user that `/agmsg delivery agmsgd` turns direct delivery on. -For `mode monitor|turn|both|off`, run `delivery.sh set claude-code "$(pwd)"` and follow its `AGMSG-DIRECTIVE` block. Legacy `hook on` maps to `turn`; `hook off` maps to `off`. +For `delivery agmsgd|monitor|turn|both|off` (and the same values after the alias `mode`), run `~/.agents/skills/agmsg/scripts/agmsg delivery --type claude-code --project "$(pwd)"` and follow its `AGMSG-DIRECTIVE` block. `agmsgd` runs `daemon enable` and leaves the project on `monitor` as the fallback; the other values are exactly `delivery.sh set `. `monitor` is always shown as `monitor (legacy)` on the choice screen, but its value stays `monitor`. Legacy `hook on` maps to `turn`; `hook off` maps to `off`. If argument is "fix" (no further words): 1. Run: `~/.agents/skills/agmsg/scripts/fix.sh` — with NO arguments. `fix` repairs THIS session's own seat marks (placement record, pane label, agent key, session name) at the pane the seat PROVES it is in. It takes no location: the seat establishes where it is from its own process ancestry (and, when that cannot decide, by writing a token to its own screen and finding it), and when it cannot establish that, it writes nothing and says why. Whoever invokes it — a `poke` from another member, a person at the keyboard, or this skill — gets the same answer. Passing a pane, a `--pane`, or any word is refused by name: a location handed in from outside is exactly the mistake this exists to remove. diff --git a/scripts/agmsg b/scripts/agmsg index f5095818a..3e20a24a6 100755 --- a/scripts/agmsg +++ b/scripts/agmsg @@ -15,6 +15,7 @@ commands: daemon start|stop|status|enable|disable manage the agmsgd beta daemon status --all include older destination records status --json machine-readable status + delivery [status|agmsgd|monitor|turn|both|off] how messages reach this agent --version print the installed version help show this message @@ -27,6 +28,10 @@ case "${1:-}" in shift exec bash "$SCRIPT_DIR/daemon.sh" "$@" ;; + delivery) + shift + exec bash "$SCRIPT_DIR/delivery-cli.sh" "$@" + ;; --version|-v) printf 'agmsg %s (install: %s)\n' "$(bash "$SCRIPT_DIR/version.sh")" "$(cd "$SCRIPT_DIR/.." && pwd)" ;; diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 63e83c2ab..134f5d17d 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -44,7 +44,7 @@ AGMSGD_SCHTASKS="${AGMSGD_SCHTASKS:-schtasks}" _usage() { cat >&2 <<'EOF' -usage: agmsg daemon start|stop|status|enable|disable +usage: agmsg daemon start|stop|status|enable|disable|offer status options: --json (machine-readable), --all (include older destinations) EOF } @@ -864,11 +864,64 @@ cmd_disable() { _path_hint disable } +# `agmsg daemon offer ` -- read-only. One line saying whether the delivery +# choice screen should offer agmsgd for this agent type: +# agmsgd=on turned on and running; do not ask again +# agmsgd=not-running reason=... turned on, but not confirmed running +# agmsgd=available enable would go ahead +# agmsgd=unavailable reason=... enable would refuse (or does not apply) +# It changes nothing, and never enables anything. +cmd_offer() { + local type="${1:-}" desired node_path found + case "$type" in + claude-code|codex) ;; + *) echo "agmsgd=unavailable reason=agmsgd does not deliver to $type sessions"; return 0 ;; + esac + desired="$(sqlite3 -readonly -cmd '.timeout 100' "$INSTALL_DB" 'SELECT desired FROM daemon_intent;' 2>/dev/null | tr -d '\r' || true)" + if [ "$desired" = on ]; then + # Turned on is an intent, not proof that notices are being delivered: + # a failed start or a stopped executor leaves it on with nothing running. + local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON + agmsg_daemon_read_state + if [ "${AGMSGD_HEALTH:-}" = ready ]; then + echo "agmsgd=on" + else + echo "agmsgd=not-running reason=${AGMSGD_REASON:-agmsgd is turned on but is not confirmed running}" + fi + return 0 + fi + if [ "$(_os)" = linux ] && ! _systemd_user_usable; then + echo "agmsgd=unavailable reason=systemd user services are not available here, so agmsgd cannot start on its own" + return 0 + fi + node_path="$(_resolve_node node)" || { + echo "agmsgd=unavailable reason=agmsgd needs Node 22.13 or newer with node:sqlite, and none was found" + return 0 + } + if ! "$node_path" "$SCRIPT_DIR/daemon/channels/storage-gate.mjs" >/dev/null 2>&1; then + echo "agmsgd=unavailable reason=agmsgd reads only the sqlite message storage, and this install uses another storage" + return 0 + fi + if [ "$type" = codex ] && [ "$(_os)" = windows ]; then + found="$("$node_path" --input-type=module -e ' + const { checkWindowsCodexQueueGate } = await import(process.argv[1]); + const gate = checkWindowsCodexQueueGate({ executable: "codex" }); + console.log(gate.state === "ok" ? "ok" : gate.reason); + ' "file://$(cd "$SCRIPT_DIR/daemon/channels" && pwd -W 2>/dev/null || pwd)/windows-codex-queue.mjs" 2>/dev/null | tr -d '\r')" || found="windows_codex_version_unreadable" + if [ "$found" != ok ]; then + echo "agmsgd=unavailable reason=agmsgd needs Codex 0.157 or newer on Windows ($found)" + return 0 + fi + fi + echo "agmsgd=available" +} + case "${1:-}" in start) shift; cmd_start "$@" ;; stop) cmd_stop ;; status) shift; cmd_status "$@" ;; enable) cmd_enable ;; disable) cmd_disable ;; + offer) shift; cmd_offer "$@" ;; *) _usage; exit 2 ;; esac diff --git a/scripts/delivery-cli.sh b/scripts/delivery-cli.sh new file mode 100755 index 000000000..be705aa7a --- /dev/null +++ b/scripts/delivery-cli.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# `agmsg delivery`: how incoming messages reach this agent, in one place. +# +# agmsg delivery [status] [--type ] [--project ] +# agmsg delivery agmsgd|monitor|turn|both|off [--type ] [--project ] +# +# It only calls daemon.sh (the install-wide agmsgd) and delivery.sh (this +# project's hooks). The mode values handed to delivery.sh are unchanged: +# `monitor` is only SHOWN as "monitor (legacy)". Choosing agmsgd runs +# `daemon enable` (when it is not on yet) and leaves the project on `monitor` as +# the fallback for a seat whose destination is not confirmed yet. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/type-registry.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/compat.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/detect-cli-type.sh" + +_usage() { + cat >&2 <<'USAGE' +usage: agmsg delivery [status] [--type ] [--project ] + agmsg delivery agmsgd|monitor|turn|both|off [--type ] [--project ] +USAGE +} + +ACTION="status" TYPE="" PROJECT="" +case "${1:-}" in + status|agmsgd|monitor|turn|both|off) ACTION="$1"; shift ;; + ''|--*) ;; + *) _usage; exit 2 ;; +esac +while [ "$#" -gt 0 ]; do + case "$1" in + --type) TYPE="${2:?--type needs a value}"; shift 2 ;; + --project) PROJECT="${2:?--project needs a value}"; shift 2 ;; + *) _usage; exit 2 ;; + esac +done +[ -n "$PROJECT" ] || PROJECT="$(pwd)" +[ -n "$TYPE" ] || TYPE="$(agmsg_detect_cli_type)" + +offer="$(bash "$SCRIPT_DIR/daemon.sh" offer "$TYPE" 2>/dev/null || true)" + +_mode_label() { case "$1" in monitor) printf 'monitor (legacy)' ;; *) printf '%s' "$1" ;; esac; } + +case "$ACTION" in + status) + case "$offer" in + agmsgd=on) echo "delivery: agmsgd (on) — notices are delivered directly" ;; + agmsgd=not-running*) echo "delivery: agmsgd (on, but not running: ${offer#*reason=}) — start it with: agmsg delivery agmsgd" ;; + agmsgd=available) echo "delivery: agmsgd (off) — available; turn it on with: agmsg delivery agmsgd" ;; + agmsgd=unavailable*) echo "delivery: agmsgd (unavailable) — ${offer#*reason=}" ;; + *) echo "delivery: agmsgd (unknown)" ;; + esac + out="$(bash "$SCRIPT_DIR/delivery.sh" status "$TYPE" "$PROJECT")" + mode="$(sed -n 's/^mode: *\([a-z]*\).*/\1/p' <<<"$out" | head -1)" + if [ -n "$mode" ]; then + if [ "$offer" = agmsgd=on ]; then + echo "this project's fallback: $(_mode_label "$mode")" + else + echo "this project: $(_mode_label "$mode")" + fi + fi + printf '%s\n' "$out" + ;; + agmsgd) + case "$offer" in + agmsgd=on) ;; + agmsgd=not-running*) + bash "$SCRIPT_DIR/daemon.sh" start || { echo "agmsg delivery: agmsgd is turned on but could not be started; this project's delivery is unchanged" >&2; exit 1; } + ;; + agmsgd=available) + bash "$SCRIPT_DIR/daemon.sh" enable || { echo "agmsg delivery: agmsgd was not turned on; this project's delivery is unchanged" >&2; exit 1; } + ;; + agmsgd=unavailable*) + echo "agmsg delivery: agmsgd is not available here: ${offer#*reason=}" >&2 + exit 1 + ;; + *) echo "agmsg delivery: could not tell whether agmsgd is available" >&2; exit 1 ;; + esac + exec bash "$SCRIPT_DIR/delivery.sh" set monitor "$TYPE" "$PROJECT" + ;; + *) + bash "$SCRIPT_DIR/delivery.sh" set "$ACTION" "$TYPE" "$PROJECT" + if [ "$offer" = agmsgd=on ]; then + echo "note: agmsgd is still on; 'agmsg daemon disable' turns it off." + fi + ;; +esac diff --git a/scripts/drivers/types/claude-code/template.md b/scripts/drivers/types/claude-code/template.md index ee9d63b94..c24caa2bf 100644 --- a/scripts/drivers/types/claude-code/template.md +++ b/scripts/drivers/types/claude-code/template.md @@ -2,8 +2,19 @@ - 5. **REQUIRED — Do NOT skip this step.** Ask the user to pick `monitor`, `turn`, `both`, or `off` delivery. Empty input means `monitor`. - Run `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set __AGENT_TYPE__ "$(pwd)"` and follow the printed `AGMSG-DIRECTIVE` block. + 5. **REQUIRED — Do NOT skip this step.** Choose how incoming messages reach this agent. Show this screen every time (a new join, a new seat added by someone already using agmsg, and when the mode is changed). First run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg daemon offer __AGENT_TYPE__`; it only reads and prints one line: + - `agmsgd=available`: ask the user to pick one, in this order: + 1. `agmsgd` — notices delivered directly by a background service (recommended). Choosing it registers a background service that starts at login; `agmsg daemon disable` stops and removes it. Empty input means this one. + 2. `monitor (legacy)` — the Claude Code Monitor tool streams the inbox (the `monitor` mode value). + 3. `turn`, 4. `both`, 5. `off` — as before. + - `agmsgd=on`: do not ask which to use. Say "agmsgd is on: notices are delivered directly" and ask only whether to keep using it or switch to `monitor (legacy)`. + - `agmsgd=not-running reason=...`: agmsgd is turned on but is not running, so do not say notices are delivered directly. Say so with the reason and ask whether to start it (`agmsg delivery agmsgd` starts it) or switch to `monitor (legacy)`. + - `agmsgd=unavailable reason=...`: do not offer agmsgd; say the reason in one line, then ask for `monitor (legacy)`, `turn`, `both`, or `off` (empty input means `monitor`). + + To apply the choice, run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg delivery --type __AGENT_TYPE__ --project "$(pwd)"` with `` = `agmsgd`, `monitor`, `turn`, `both` or `off` (`monitor (legacy)` is the value `monitor`) and show its output and follow the printed `AGMSG-DIRECTIVE` block: + - `agmsgd` turns the background service on (`daemon enable`) and leaves the project on `monitor` as the fallback for a seat whose destination is not confirmed yet. If it fails, say so and offer the other choices. + - Keep using agmsgd (it is on or turned on but not running): run `agmsg delivery agmsgd` the same way. + - `monitor (legacy)` when agmsgd is on: run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg daemon disable` first, then `agmsg delivery monitor`. @@ -121,7 +132,7 @@ If argument starts with "despawn" (e.g. "despawn reviewer", "despawn alice --for -If argument is "mode", run `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh status __AGENT_TYPE__ "$(pwd)"`. Show the output to the user, and if it says `mode: monitor` (or `both`), say explicitly that this reports project *configuration* only — it does not prove the runtime Monitor task is attached in the current session. To confirm the runtime state, look for a Monitor whose description begins with `agmsg inbox stream` (after an `actas` it reads `agmsg inbox stream (acting as )`). TaskList may list this task, but not every environment does (the desktop app's Code tab runs the Monitor and delivers its events without listing it), so a task missing from TaskList is not a failure: judge by the Monitor call starting and its events arriving. The background-task footer is not a reliable check either. +If argument is "delivery" (or its alias "mode") with nothing after it, run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg delivery status --type __AGENT_TYPE__ --project "$(pwd)"`. It shows both the install-wide agmsgd state (for example "delivery: agmsgd (on) — notices are delivered directly") and this project's own delivery ("this project's fallback: monitor (legacy)"). Show the output to the user, and if it says `mode: monitor` (or `both`), say explicitly that this reports project *configuration* only — it does not prove the runtime Monitor task is attached in the current session. To confirm the runtime state, look for a Monitor whose description begins with `agmsg inbox stream` (after an `actas` it reads `agmsg inbox stream (acting as )`). TaskList may list this task, but not every environment does (the desktop app's Code tab runs the Monitor and delivers its events without listing it), so a task missing from TaskList is not a failure: judge by the Monitor call starting and its events arriving. The background-task footer is not a reliable check either. When agmsgd is off but available, tell the user that `/agmsg delivery agmsgd` turns direct delivery on. -For `mode monitor|turn|both|off`, run `delivery.sh set __AGENT_TYPE__ "$(pwd)"` and follow its `AGMSG-DIRECTIVE` block. Legacy `hook on` maps to `turn`; `hook off` maps to `off`. +For `delivery agmsgd|monitor|turn|both|off` (and the same values after the alias `mode`), run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg delivery --type __AGENT_TYPE__ --project "$(pwd)"` and follow its `AGMSG-DIRECTIVE` block. `agmsgd` runs `daemon enable` and leaves the project on `monitor` as the fallback; the other values are exactly `delivery.sh set `. `monitor` is always shown as `monitor (legacy)` on the choice screen, but its value stays `monitor`. Legacy `hook on` maps to `turn`; `hook off` maps to `off`. diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 631887950..7f97eff14 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -10,9 +10,19 @@ Do not use POSIX `'"'"'` quote splicing in PowerShell, and do not use escaped do - 5. **REQUIRED — Do NOT skip this step.** Ask the user to pick `monitor`, `turn`, or `off` delivery. Empty input means `monitor`. - Run `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set __AGENT_TYPE__ "$(pwd)"`. - Monitor uses the Codex app-server bridge; it changes how `codex` starts and is documented in `docs/codex-monitor-beta.md`. + 5. **REQUIRED — Do NOT skip this step.** Choose how incoming messages reach this agent. Show this screen every time (a new join, a new seat added by someone already using agmsg, and when the mode is changed). First run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg daemon offer __AGENT_TYPE__`; it only reads and prints one line: + - `agmsgd=available`: ask the user to pick one, in this order: + 1. `agmsgd` — notices delivered directly by a background service (recommended). Choosing it registers a background service that starts at login; `agmsg daemon disable` stops and removes it. Empty input means this one. + 2. `monitor (legacy)` — the Codex app-server bridge streams the inbox (the `monitor` mode value). It changes how `codex` starts and is documented in `docs/codex-monitor-beta.md`. + 3. `turn`, 4. `off` — as before. + - `agmsgd=on`: do not ask which to use. Say "agmsgd is on: notices are delivered directly" and ask only whether to keep using it or switch to `monitor (legacy)`. + - `agmsgd=not-running reason=...`: agmsgd is turned on but is not running, so do not say notices are delivered directly. Say so with the reason and ask whether to start it (`agmsg delivery agmsgd` starts it) or switch to `monitor (legacy)`. + - `agmsgd=unavailable reason=...`: do not offer agmsgd; say the reason in one line, then ask for `monitor (legacy)`, `turn`, or `off` (empty input means `monitor`). + + To apply the choice, run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg delivery --type __AGENT_TYPE__ --project "$(pwd)"` with `` = `agmsgd`, `monitor`, `turn` or `off` (`monitor (legacy)` is the value `monitor`) and show its output: + - `agmsgd` turns the background service on (`daemon enable`) and leaves the project on `monitor` as the fallback for a seat whose destination is not confirmed yet. If it fails, say so and offer the other choices. + - Keep using agmsgd (it is on or turned on but not running): run `agmsg delivery agmsgd` the same way. + - `monitor (legacy)` when agmsgd is on: run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg daemon disable` first, then `agmsg delivery monitor`. @@ -45,7 +55,7 @@ If argument starts with "despawn" (e.g. "despawn reviewer", "despawn alice --for -If argument is "mode", run `delivery.sh status __AGENT_TYPE__ "$(pwd)"`. +If argument is "delivery" (or its alias "mode") with nothing after it, run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg delivery status --type __AGENT_TYPE__ --project "$(pwd)"`. It shows both the install-wide agmsgd state (for example "delivery: agmsgd (on) — notices are delivered directly") and this project's own delivery ("this project's fallback: monitor (legacy)"). When agmsgd is off but available, tell the user that `/agmsg delivery agmsgd` turns direct delivery on. -For `mode monitor|turn|off`, run `delivery.sh set __AGENT_TYPE__ "$(pwd)"`; `both` is unsupported. Legacy `hook on` maps to `turn`; `hook off` maps to `off`. +For `delivery agmsgd|monitor|turn|off` (and the same values after the alias `mode`), run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg delivery --type __AGENT_TYPE__ --project "$(pwd)"`; `both` is unsupported. `agmsgd` runs `daemon enable` and leaves the project on `monitor` as the fallback; the other values are exactly `delivery.sh set `. `monitor` is always shown as `monitor (legacy)` on the choice screen, but its value stays `monitor`. Legacy `hook on` maps to `turn`; `hook off` maps to `off`. diff --git a/tests/test_daemon_offer.bats b/tests/test_daemon_offer.bats new file mode 100644 index 000000000..1055b8607 --- /dev/null +++ b/tests/test_daemon_offer.bats @@ -0,0 +1,82 @@ +#!/usr/bin/env bats +# `agmsg daemon offer `: the read-only answer the delivery choice screen +# uses to decide whether to offer agmsgd. Isolated scripts and data; no real +# service is touched and nothing is enabled. + +setup() { + export TEST_SKILL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/daemon-offer.XXXXXX")" + TEST_SKILL_DIR="$(cd "$TEST_SKILL_DIR" && pwd -P)" + cp -R "$BATS_TEST_DIRNAME/../scripts" "$TEST_SKILL_DIR/scripts" + export SCRIPTS="$TEST_SKILL_DIR/scripts" + mkdir -p "$TEST_SKILL_DIR/run" "$TEST_SKILL_DIR/db" "$TEST_SKILL_DIR/bin" + export AGMSG_CONFIG="$TEST_SKILL_DIR/config.json" + export AGMSG_STORAGE_PATH="$TEST_SKILL_DIR/db" + sqlite3 "$TEST_SKILL_DIR/run/install.db" < "$SCRIPTS/daemon/schema.sql" +} + +_mark_daemon_ready() { + local boot + case "$(uname -s)" in + Darwin) boot="$(sysctl -n kern.bootsessionuuid)" ;; + Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; + *) skip 'POSIX beta executor evidence' ;; + esac + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET node_path='$(command -v node)'; UPDATE daemon_intent SET desired='on'; + UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='$boot', executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" +} + +teardown() { rm -rf "$TEST_SKILL_DIR"; } + +@test "offer: a fresh install can enable agmsgd" { + run bash "$SCRIPTS/daemon.sh" offer claude-code + [ "$status" -eq 0 ] + [ "$output" = "agmsgd=available" ] + run bash "$SCRIPTS/daemon.sh" offer codex + [ "$output" = "agmsgd=available" ] +} + +@test "offer: it only reads; the intent stays off" { + bash "$SCRIPTS/daemon.sh" offer claude-code >/dev/null + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT desired FROM daemon_intent;')" != on ] +} + +@test "offer: agmsgd that is on and running is reported as on, not offered again" { + _mark_daemon_ready + run bash "$SCRIPTS/daemon.sh" offer claude-code + [ "$output" = "agmsgd=on" ] +} + +@test "offer: turned on but nothing running is not reported as on (a failed start leaves exactly this)" { + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='on';" + run bash "$SCRIPTS/daemon.sh" offer claude-code + [ "$status" -eq 0 ] + [ "$output" != "agmsgd=on" ] + [[ "$output" == "agmsgd=not-running reason="* ]] +} + +@test "offer: a non-sqlite storage is unavailable, with the reason" { + printf '{"storage":"jsonl"}\n' > "$AGMSG_CONFIG" + run bash "$SCRIPTS/daemon.sh" offer claude-code + [ "$status" -eq 0 ] + [[ "$output" == "agmsgd=unavailable reason="*sqlite* ]] +} + +@test "offer: no usable Node is unavailable, with the reason" { + printf '#!/bin/sh\nexit 1\n' > "$TEST_SKILL_DIR/bin/node" + chmod +x "$TEST_SKILL_DIR/bin/node" + run env PATH="$TEST_SKILL_DIR/bin:/usr/bin:/bin" bash "$SCRIPTS/daemon.sh" offer claude-code + [[ "$output" == "agmsgd=unavailable reason="*Node* ]] +} + +@test "offer: Linux without systemd user services is unavailable, with the reason" { + printf '#!/bin/sh\n[ "$1" = "-s" ] && { echo Linux; exit 0; }\necho Linux\n' > "$TEST_SKILL_DIR/bin/uname" + printf '#!/bin/sh\nexit 1\n' > "$TEST_SKILL_DIR/bin/systemctl" + chmod +x "$TEST_SKILL_DIR/bin/uname" "$TEST_SKILL_DIR/bin/systemctl" + run env PATH="$TEST_SKILL_DIR/bin:$PATH" AGMSGD_SYSTEMCTL="$TEST_SKILL_DIR/bin/systemctl" bash "$SCRIPTS/daemon.sh" offer claude-code + [[ "$output" == "agmsgd=unavailable reason="*systemd* ]] +} + +@test "offer: a type agmsgd does not deliver to is not offered" { + run bash "$SCRIPTS/daemon.sh" offer gemini + [[ "$output" == "agmsgd=unavailable reason="* ]] +} diff --git a/tests/test_delivery_choice_screen.bats b/tests/test_delivery_choice_screen.bats new file mode 100644 index 000000000..ac19c3569 --- /dev/null +++ b/tests/test_delivery_choice_screen.bats @@ -0,0 +1,75 @@ +#!/usr/bin/env bats +# The delivery choice screen offers agmsgd first, names the old monitor +# "monitor (legacy)", asks `agmsg daemon offer` before showing agmsgd, and +# keeps the mode values as they were. + +setup() { + ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + for type in claude-code codex; do + bash -c 'source "$1/scripts/lib/type-registry.sh"; source "$1/scripts/lib/skill-render.sh"; SCRIPT_DIR="$1" agmsg_render_skill "$2" agmsg "$3"' _ "$ROOT" "$type" "$BATS_TEST_TMPDIR/$type.md" + done +} + +_step5() { # : the delivery step of the join flow + sed -n '/^ 5\. \*\*REQUIRED/,/^ 6\. /p' "$1" +} + +@test "choice screen: agmsgd comes first, monitor (legacy) second, for Claude Code and Codex" { + for file in "$BATS_TEST_TMPDIR/claude-code.md" "$BATS_TEST_TMPDIR/codex.md" "$ROOT/SKILL.md"; do + screen="$(_step5 "$file")" + grep -Fq 'agmsg daemon offer' <<<"$screen" + first="$(grep -n 'agmsgd` — notices delivered directly by a background service (recommended)' <<<"$screen" | head -1 | cut -d: -f1)" + second="$(grep -n '`monitor (legacy)` —' <<<"$screen" | head -1 | cut -d: -f1)" + [ -n "$first" ] && [ -n "$second" ] && [ "$first" -lt "$second" ] + grep -Fq 'agmsg daemon disable' <<<"$screen" + done +} + +@test "choice screen: every offer answer is handled (on, not-running, unavailable with a reason)" { + for file in "$BATS_TEST_TMPDIR/claude-code.md" "$BATS_TEST_TMPDIR/codex.md"; do + screen="$(_step5 "$file")" + grep -Fq 'agmsgd=on' <<<"$screen" + grep -Fq 'agmsgd is on: notices are delivered directly' <<<"$screen" + grep -Fq 'agmsgd=unavailable' <<<"$screen" + grep -Fq 'agmsgd=not-running' <<<"$screen" + grep -Fq 'do not say notices are delivered directly' <<<"$screen" + done +} + +@test "choice screen: the choice is applied through agmsg delivery; agmsgd enables the service and keeps monitor as the fallback" { + for file in "$BATS_TEST_TMPDIR/claude-code.md" "$BATS_TEST_TMPDIR/codex.md" "$ROOT/SKILL.md"; do + screen="$(_step5 "$file")" + grep -Fq 'agmsg delivery ' <<<"$screen" + grep -Fq 'daemon enable' <<<"$screen" + grep -Fq 'leaves the project on `monitor`' <<<"$screen" + grep -Fq 'agmsg daemon disable' <<<"$screen" + done +} + +@test "delivery command: delivery is the name, mode stays an alias, mode values keep their names" { + for file in "$BATS_TEST_TMPDIR/claude-code.md" "$BATS_TEST_TMPDIR/codex.md" "$ROOT/SKILL.md"; do + grep -Fq 'If argument is "delivery" (or its alias "mode")' "$file" + grep -Fq 'agmsg delivery status' "$file" + grep -Fq "this project's fallback: monitor (legacy)" "$file" + done + grep -Fq 'delivery agmsgd|monitor|turn|both|off' "$BATS_TEST_TMPDIR/claude-code.md" + grep -Fq 'delivery agmsgd|monitor|turn|off' "$BATS_TEST_TMPDIR/codex.md" + grep -Fq 'exactly `delivery.sh set `' "$BATS_TEST_TMPDIR/claude-code.md" +} + +@test "choice screen: other types are unchanged (no agmsgd offer)" { + for type in gemini copilot cursor; do + bash -c 'source "$1/scripts/lib/type-registry.sh"; source "$1/scripts/lib/skill-render.sh"; SCRIPT_DIR="$1" agmsg_render_skill "$2" agmsg "$3"' _ "$ROOT" "$type" "$BATS_TEST_TMPDIR/$type.md" + ! grep -Fq 'agmsg daemon offer' "$BATS_TEST_TMPDIR/$type.md" + done +} + +@test "README: the delivery modes section names agmsgd and monitor (legacy), in both languages" { + for readme in README.md README.ja.md; do + case "$readme" in README.md) heading='^## Delivery modes' ;; *) heading='^## 配信モード' ;; esac + section="$(sed -n "/$heading/,/^## [^D配]/p" "$ROOT/$readme")" + grep -Fq 'monitor (legacy)' <<<"$section" + grep -Fq 'agmsg daemon disable' <<<"$section" + grep -Fq '/agmsg delivery agmsgd' <<<"$section" + done +} diff --git a/tests/test_delivery_cli.bats b/tests/test_delivery_cli.bats new file mode 100644 index 000000000..c7e7c6daa --- /dev/null +++ b/tests/test_delivery_cli.bats @@ -0,0 +1,122 @@ +#!/usr/bin/env bats +# `agmsg delivery`: agmsgd (install-wide) and this project's delivery together. +# Isolated scripts and data; nothing is registered with a real service manager, +# and agmsgd is never enabled here. + +refute() { + if "$@"; then return 1; fi + return 0 +} + +setup() { + export TEST_SKILL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/delivery-cli.XXXXXX")" + TEST_SKILL_DIR="$(cd "$TEST_SKILL_DIR" && pwd -P)" + cp -R "$BATS_TEST_DIRNAME/../scripts" "$TEST_SKILL_DIR/scripts" + export SCRIPTS="$TEST_SKILL_DIR/scripts" + mkdir -p "$TEST_SKILL_DIR/run" "$TEST_SKILL_DIR/db" "$TEST_SKILL_DIR/project" + export AGMSG_CONFIG="$TEST_SKILL_DIR/config.json" + export AGMSG_STORAGE_PATH="$TEST_SKILL_DIR/db" + sqlite3 "$TEST_SKILL_DIR/run/install.db" < "$SCRIPTS/daemon/schema.sql" + export PROJECT="$TEST_SKILL_DIR/project" +} + +_mark_daemon_ready() { + local boot + case "$(uname -s)" in + Darwin) boot="$(sysctl -n kern.bootsessionuuid)" ;; + Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; + *) skip 'POSIX beta executor evidence' ;; + esac + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET node_path='$(command -v node)'; UPDATE daemon_intent SET desired='on'; + UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='$boot', executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" +} + +teardown() { rm -rf "$TEST_SKILL_DIR"; } + +@test "delivery: the shell command is public and listed in help" { + run bash "$SCRIPTS/agmsg" help + grep -Fq "delivery" <<<"$output" +} + +@test "delivery status: agmsgd on shows the install state and the project's fallback as monitor (legacy)" { + _mark_daemon_ready + bash "$SCRIPTS/delivery.sh" set monitor claude-code "$PROJECT" >/dev/null + run bash "$SCRIPTS/agmsg" delivery status --type claude-code --project "$PROJECT" + [ "$status" -eq 0 ] + grep -Fq "delivery: agmsgd (on) — notices are delivered directly" <<<"$output" + grep -Fq "this project's fallback: monitor (legacy)" <<<"$output" +} + +@test "delivery status: agmsgd off but available says how to turn it on; no arguments mean status" { + bash "$SCRIPTS/delivery.sh" set turn claude-code "$PROJECT" >/dev/null + run bash "$SCRIPTS/agmsg" delivery --type claude-code --project "$PROJECT" + grep -Fq "delivery: agmsgd (off) — available; turn it on with: agmsg delivery agmsgd" <<<"$output" + grep -Fq "this project: turn" <<<"$output" +} + +@test "delivery agmsgd: where daemon enable would refuse, it says why and changes nothing" { + printf '{"storage":"jsonl"}\n' > "$AGMSG_CONFIG" + bash "$SCRIPTS/delivery.sh" set turn claude-code "$PROJECT" >/dev/null + run bash "$SCRIPTS/agmsg" delivery agmsgd --type claude-code --project "$PROJECT" + [ "$status" -eq 1 ] + grep -Fq "agmsgd is not available here" <<<"$output" + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT desired FROM daemon_intent;')" != on ] + run bash "$SCRIPTS/delivery.sh" status claude-code "$PROJECT" + grep -Fq "mode: turn" <<<"$output" +} + +@test "delivery agmsgd: with agmsgd already on it leaves the project on monitor and does not enable again" { + _mark_daemon_ready + run bash "$SCRIPTS/agmsg" delivery agmsgd --type claude-code --project "$PROJECT" + [ "$status" -eq 0 ] + run bash "$SCRIPTS/delivery.sh" status claude-code "$PROJECT" + grep -Fq "mode: monitor" <<<"$output" +} + +@test "delivery : the other modes are exactly delivery.sh set, and an agmsgd that is on is noted" { + _mark_daemon_ready + run bash "$SCRIPTS/agmsg" delivery turn --type claude-code --project "$PROJECT" + [ "$status" -eq 0 ] + grep -Fq "agmsgd is still on" <<<"$output" + run bash "$SCRIPTS/delivery.sh" status claude-code "$PROJECT" + grep -Fq "mode: turn" <<<"$output" +} + +@test "delivery: an unknown value is refused" { + run bash "$SCRIPTS/agmsg" delivery sometimes --type claude-code --project "$PROJECT" + [ "$status" -eq 2 ] +} + +@test "delivery status: turned on but not running never claims direct delivery, and says how to start it" { + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='on';" + bash "$SCRIPTS/delivery.sh" set monitor claude-code "$PROJECT" >/dev/null + run bash "$SCRIPTS/agmsg" delivery status --type claude-code --project "$PROJECT" + [ "$status" -eq 0 ] + refute grep -Fq "notices are delivered directly" <<<"$output" + grep -Fq "agmsgd (on, but not running" <<<"$output" + grep -Fq "start it with: agmsg delivery agmsgd" <<<"$output" + grep -Fq "this project: monitor (legacy)" <<<"$output" +} + +@test "delivery agmsgd: turned on but not running starts it instead of only setting monitor; a failed start changes nothing" { + # daemon.sh is replaced by a stand-in so no real service or process is started. + cat > "$SCRIPTS/daemon.sh" <<'STUB' +#!/usr/bin/env bash +case "$1" in + offer) echo "agmsgd=not-running reason=executor unavailable" ;; + start) echo start >> "$STUB_LOG"; [ -z "${STUB_START_FAILS:-}" ] ;; +esac +STUB + export STUB_LOG="$TEST_SKILL_DIR/stub.log" + bash "$SCRIPTS/delivery.sh" set turn claude-code "$PROJECT" >/dev/null + STUB_START_FAILS=1 run bash "$SCRIPTS/agmsg" delivery agmsgd --type claude-code --project "$PROJECT" + [ "$status" -eq 1 ] + grep -Fq "could not be started" <<<"$output" + run bash "$SCRIPTS/delivery.sh" status claude-code "$PROJECT" + grep -Fq "mode: turn" <<<"$output" + run bash "$SCRIPTS/agmsg" delivery agmsgd --type claude-code --project "$PROJECT" + [ "$status" -eq 0 ] + [ "$(grep -c start "$STUB_LOG")" -eq 2 ] + run bash "$SCRIPTS/delivery.sh" status claude-code "$PROJECT" + grep -Fq "mode: monitor" <<<"$output" +} From e8d370260a0bb7d5e874a4b16e34c2fc230f6211 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 16:07:41 +0900 Subject: [PATCH 91/97] Make monitor (legacy) a real opt-out from agmsgd for the project (#1719) [skip ci] agmsg delivery records the project's choice in run/delivery-choice. A project that chose something other than agmsgd gets no notices from the Claude Code and Codex channels, stays on the Monitor path in the hooks and actas, and gets the bridge from the Codex shim and monitor. A project with no recorded choice behaves as before. --- README.ja.md | 2 +- README.md | 2 +- SKILL.md | 4 +- scripts/actas-claim.sh | 2 + scripts/daemon/channels/claude-code-queue.mjs | 12 +++++ scripts/daemon/channels/codex-queue.mjs | 7 +++ scripts/daemon/channels/delivery-choice.mjs | 27 +++++++++++ scripts/daemon/status.mjs | 7 +++ scripts/delivery-cli.sh | 46 +++++++++++++++++-- .../types/claude-code/_session-start.sh | 2 + scripts/drivers/types/claude-code/template.md | 4 +- scripts/drivers/types/codex/_delivery.sh | 1 + scripts/drivers/types/codex/codex-monitor.sh | 1 + scripts/drivers/types/codex/codex-shim.sh | 2 + scripts/drivers/types/codex/template.md | 4 +- scripts/lib/daemon-seats.sh | 12 ++++- scripts/lib/daemon-state.sh | 27 +++++++++++ scripts/watch.sh | 1 + tests/agmsgd_claude_code_queue.test.mjs | 16 +++++++ tests/test_agmsgd_switch.bats | 16 +++++++ tests/test_delivery_cli.bats | 38 ++++++++++++++- ...sion_start_claude_code_native_channel.bats | 18 ++++++++ 22 files changed, 235 insertions(+), 16 deletions(-) create mode 100644 scripts/daemon/channels/delivery-choice.mjs diff --git a/README.ja.md b/README.ja.md index af544920d..67e3548d4 100644 --- a/README.ja.md +++ b/README.ja.md @@ -239,7 +239,7 @@ despawnは指定されたメンバーにのみ作用する — `despawn` を実 | **`both`** | monitorを主に、turnをセッションごとの安全網として | 約5秒。ウォッチャー障害時はturn相当にフォールバック | 二重の保険をかけたい場合 | | **`off`** | 自動配信なし | 手動の `/agmsg` のみ | ミニマリスト | -`agmsg daemon enable` が断る環境(Node 22.13未満、JSONLのメッセージストレージ、Linuxでsystemdのユーザーサービスが使えない、WindowsのCodexが0.157未満)ではagmsgdは出さず、理由を1行添えて他のモードを並べる。agmsgdがすでにオンのときはどれを使うかを聞かず、そう伝えたうえで、そのまま使うか `monitor (legacy)` に切り替えるかだけ尋ねる。agmsgdを選ぶと `agmsg daemon enable` を実行し、届け先がまだ確かめられないセッションのために、プロジェクトのモードは `monitor` のままにしておく。 +`agmsg daemon enable` が断る環境(Node 22.13未満、JSONLのメッセージストレージ、Linuxでsystemdのユーザーサービスが使えない、WindowsのCodexが0.157未満)ではagmsgdは出さず、理由を1行添えて他のモードを並べる。agmsgdがすでにオンのときはどれを使うかを聞かず、そう伝えたうえで、そのまま使うか `monitor (legacy)` に切り替えるかだけ尋ねる。agmsgdを選ぶと `agmsg daemon enable` を実行し、届け先がまだ確かめられないセッションのために、プロジェクトのモードは `monitor` のままにしておく。`monitor (legacy)`(や `turn`、`both`、`off`)を選ぶとそのプロジェクトについては本当にagmsgdを使わない: agmsgdはそのプロジェクトの席に届けなくなり(`agmsg daemon status` には選択済みと出る)、agmsgd自体はインストール内の他のプロジェクト向けに動き続ける(全部止めるなら `agmsg daemon disable`)。一度も選んでいないプロジェクトは変わらない。 ### モードの選択 diff --git a/README.md b/README.md index 38f3c5212..7405b80b5 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ How incoming messages reach your agent. Pick one at first join via the prompt, o | **`both`** | monitor primary, turn as per-session safety net | ~5s; falls back to turn-end on watcher failure | belt-and-suspenders | | **`off`** | no automatic delivery | manual `/agmsg` only | minimalists | -agmsgd is not offered where `agmsg daemon enable` would refuse (Node older than 22.13, a JSONL message storage, no systemd user services on Linux, Codex older than 0.157 on Windows); the prompt says why in one line and lists the other modes. When agmsgd is already on, the prompt does not ask which to use: it says so and asks only whether to keep it or switch to `monitor (legacy)`. Choosing agmsgd runs `agmsg daemon enable` and leaves the project on `monitor` as the fallback for a session whose destination is not confirmed yet. +agmsgd is not offered where `agmsg daemon enable` would refuse (Node older than 22.13, a JSONL message storage, no systemd user services on Linux, Codex older than 0.157 on Windows); the prompt says why in one line and lists the other modes. When agmsgd is already on, the prompt does not ask which to use: it says so and asks only whether to keep it or switch to `monitor (legacy)`. Choosing agmsgd runs `agmsg daemon enable` and leaves the project on `monitor` as the fallback for a session whose destination is not confirmed yet. Choosing `monitor (legacy)` (or `turn`, `both`, `off`) is a real opt-out for that project: agmsgd stops delivering to its seats (`agmsg daemon status` shows them as chosen), and agmsgd itself stays on for the install's other projects (`agmsg daemon disable` turns it off for all of them). A project that never chose is unchanged. ### Picking a mode diff --git a/SKILL.md b/SKILL.md index 490eb8223..fafbc439b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -98,8 +98,8 @@ Four possible outputs: To apply the choice, run `~/.agents/skills/agmsg/scripts/agmsg delivery --type claude-code --project "$(pwd)"` with `` = `agmsgd`, `monitor`, `turn`, `both` or `off` (`monitor (legacy)` is the value `monitor`) and show its output and follow the printed `AGMSG-DIRECTIVE` block: - `agmsgd` turns the background service on (`daemon enable`) and leaves the project on `monitor` as the fallback for a seat whose destination is not confirmed yet. If it fails, say so and offer the other choices. - - Keep using agmsgd (it is on or turned on but not running): run `agmsg delivery agmsgd` the same way. - - `monitor (legacy)` when agmsgd is on: run `~/.agents/skills/agmsg/scripts/agmsg daemon disable` first, then `agmsg delivery monitor`. + - Keep using agmsgd (it is on or turned on but not running): run `agmsg delivery agmsgd` the same way; it also records that this project uses agmsgd. + - `monitor (legacy)` (and `turn`, `both`, `off`) is a real opt-out: the CLI records that this project does not use agmsgd, so agmsgd stops delivering here and the project keeps the old path. agmsgd stays on for the install's other projects; when it says so, pass on its line that `agmsg daemon disable` turns agmsgd off for all of them. 6. Then check inbox for the newly joined team. diff --git a/scripts/actas-claim.sh b/scripts/actas-claim.sh index 8fd6c105c..53dd6f30f 100755 --- a/scripts/actas-claim.sh +++ b/scripts/actas-claim.sh @@ -155,6 +155,8 @@ if [ "$TYPE" = claude-code ] && [ -n "${SKILL_DIR:-}" ] \ command -v _agmsg_detect_platform >/dev/null 2>&1 || . "$SKILL_DIR/scripts/lib/compat.sh" _agmsg_detect_platform agmsg_daemon_read_state + # A project that chose something other than agmsgd keeps the Monitor path. + agmsg_daemon_apply_project_choice claude-code "$PROJECT_PHYS" if [ "${AGMSGD_HEALTH:-}" = ready ] && agmsg_claude_code_native_storage_ready; then NATIVE_DELIVERY=1 while IFS= read -r team; do diff --git a/scripts/daemon/channels/claude-code-queue.mjs b/scripts/daemon/channels/claude-code-queue.mjs index d40f0d086..9e01be8c5 100644 --- a/scripts/daemon/channels/claude-code-queue.mjs +++ b/scripts/daemon/channels/claude-code-queue.mjs @@ -12,6 +12,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { logLine } from "../log.mjs"; +import { optedOutOfAgmsgd } from "./delivery-choice.mjs"; import { withImmediateTransaction } from "../db.mjs"; import { messageStorePath, @@ -164,6 +165,17 @@ export function createClaudeCodeQueueChannel({ const { team, agent, teamConfig, registered = true } = registration; const seat = seatKey(team, agent); const pending = readPending(db, seat); + // A project that chose something other than agmsgd gets no nudges, including + // a re-send of one already waiting. + const chosen = readSession(join(installRoot, "run"), team, agent); + const optedOut = chosen.state === "present" && chosen.record.type === "claude-code" + ? optedOutOfAgmsgd(installRoot, "claude-code", chosen.record.project) + : ""; + if (optedOut) { + if (pending) setQueueState(db, pending.id, "expired"); + saveSeat(db, { seat, messagingSocket: chosen.record.messaging_socket, state: "unaddressable", reason: `delivery_chosen_${optedOut}` }, now); + return; + } let retryMode = ""; let carriedModes = []; // At most one nudge is outstanding per seat, and an outstanding one is not diff --git a/scripts/daemon/channels/codex-queue.mjs b/scripts/daemon/channels/codex-queue.mjs index cb85a8585..3e108014f 100644 --- a/scripts/daemon/channels/codex-queue.mjs +++ b/scripts/daemon/channels/codex-queue.mjs @@ -6,6 +6,7 @@ import { homedir, hostname } from "node:os"; import { delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path"; import { accessSync, constants, lstatSync, readFileSync, readdirSync, statSync } from "node:fs"; import { logLine } from "../log.mjs"; +import { optedOutOfAgmsgd } from "./delivery-choice.mjs"; import { withImmediateTransaction } from "../db.mjs"; import { listCodexRegistrations, @@ -410,6 +411,12 @@ export function createCodexQueueChannel({ saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "role_session_project_mismatch" }, now); return; } + // A project that chose something other than agmsgd gets no new notices. + const optedOut = optedOutOfAgmsgd(installRoot, "codex", record.project); + if (optedOut) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "unaddressable", reason: `delivery_chosen_${optedOut}` }, now); + return; + } let storePath; try { storePath = messageStorePath({ storageDir: paths.storageDir, team, teamConfig }); diff --git a/scripts/daemon/channels/delivery-choice.mjs b/scripts/daemon/channels/delivery-choice.mjs new file mode 100644 index 000000000..4e146afc5 --- /dev/null +++ b/scripts/daemon/channels/delivery-choice.mjs @@ -0,0 +1,27 @@ +// The project's explicit delivery choice (see agmsg_delivery_choice_get in +// scripts/lib/daemon-state.sh): lines "\t\t" in +// run/delivery-choice, written only by `agmsg delivery `. A value other +// than "agmsgd" is the project's decision not to use agmsgd. No line, or an +// unreadable file, means no choice was made and agmsgd delivers as before. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +export function readDeliveryChoice(installRoot, type, project) { + let text; + try { + text = readFileSync(join(installRoot, "run", "delivery-choice"), "utf8"); + } catch { + return ""; + } + let choice = ""; + for (const line of text.split(/\r?\n/)) { + const [lineType, lineProject, value] = line.split("\t"); + if (lineType === type && lineProject === project && /^[a-z]+$/.test(value ?? "")) choice = value; + } + return choice; +} + +export function optedOutOfAgmsgd(installRoot, type, project) { + const choice = readDeliveryChoice(installRoot, type, project); + return choice && choice !== "agmsgd" ? choice : ""; +} diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs index d466e0d6f..63104e9dd 100644 --- a/scripts/daemon/status.mjs +++ b/scripts/daemon/status.mjs @@ -250,6 +250,13 @@ function formatClaudeSeats(inventory, all) { let omitted = 0; const lines = []; for (const seat of inventory?.seats ?? []) { + const chosen = /^delivery_chosen_([a-z]+)$/.exec(seat.reason ?? "")?.[1]; + if (chosen) { + let label; + try { label = JSON.parse(seat.seat).join("/"); } catch { label = "unidentified seat"; } + lines.push(`Claude Code ${label}: ${chosen === "monitor" ? "monitor (legacy)" : chosen} chosen; agmsgd does not deliver here`); + continue; + } if (!all && (seat.state === "unaddressable" || !seat.messaging_socket)) { omitted += 1; continue; diff --git a/scripts/delivery-cli.sh b/scripts/delivery-cli.sh index be705aa7a..1620c9ee9 100755 --- a/scripts/delivery-cli.sh +++ b/scripts/delivery-cli.sh @@ -18,6 +18,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$SCRIPT_DIR/lib/compat.sh" # shellcheck disable=SC1091 . "$SCRIPT_DIR/lib/detect-cli-type.sh" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +export SKILL_DIR +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/resolve-project.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/role-session.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/daemon-state.sh" _usage() { cat >&2 <<'USAGE' @@ -43,6 +51,27 @@ done [ -n "$TYPE" ] || TYPE="$(agmsg_detect_cli_type)" offer="$(bash "$SCRIPT_DIR/daemon.sh" offer "$TYPE" 2>/dev/null || true)" +CANON_PROJECT="$(agmsg_canonical_path "$PROJECT")" + +# Record what this project chose, in run/delivery-choice (" +# "): the daemon, the shim and the session hooks read it, so a project that +# chose something other than agmsgd is not delivered to by agmsgd. Only this +# command writes it; a project that never chose has no line and is unchanged. +_choice_write_unlocked() { # + local file="$SKILL_DIR/run/delivery-choice" tmp + mkdir -p "$SKILL_DIR/run" 2>/dev/null || return 1 + tmp="$(mktemp "$SKILL_DIR/run/.delivery-choice.XXXXXX")" || return 1 + { + [ ! -f "$file" ] || awk -F'\t' -v t="$TYPE" -v p="$CANON_PROJECT" '!($1 == t && $2 == p)' "$file" + printf '%s\t%s\t%s\n' "$TYPE" "$CANON_PROJECT" "$1" + } > "$tmp" || { rm -f "$tmp"; return 1; } + chmod 600 "$tmp" 2>/dev/null || true + mv -f "$tmp" "$file" +} +_record_choice() { # + case "$CANON_PROJECT" in *$'\t'*|*$'\n'*) echo "agmsg delivery: this project path cannot be recorded; the choice was not saved" >&2; return 1 ;; esac + _agmsg_role_session_write _choice_write_unlocked "$1" || { echo "agmsg delivery: could not record the choice for this project" >&2; return 1; } +} _mode_label() { case "$1" in monitor) printf 'monitor (legacy)' ;; *) printf '%s' "$1" ;; esac; } @@ -57,7 +86,10 @@ case "$ACTION" in esac out="$(bash "$SCRIPT_DIR/delivery.sh" status "$TYPE" "$PROJECT")" mode="$(sed -n 's/^mode: *\([a-z]*\).*/\1/p' <<<"$out" | head -1)" - if [ -n "$mode" ]; then + chosen="$(agmsg_delivery_choice_get "$TYPE" "$PROJECT")" + if [ -n "$chosen" ] && [ "$chosen" != agmsgd ]; then + echo "this project: $(_mode_label "$chosen") chosen — agmsgd does not deliver here" + elif [ -n "$mode" ]; then if [ "$offer" = agmsgd=on ]; then echo "this project's fallback: $(_mode_label "$mode")" else @@ -81,12 +113,16 @@ case "$ACTION" in ;; *) echo "agmsg delivery: could not tell whether agmsgd is available" >&2; exit 1 ;; esac - exec bash "$SCRIPT_DIR/delivery.sh" set monitor "$TYPE" "$PROJECT" + bash "$SCRIPT_DIR/delivery.sh" set monitor "$TYPE" "$PROJECT" + _record_choice agmsgd ;; *) bash "$SCRIPT_DIR/delivery.sh" set "$ACTION" "$TYPE" "$PROJECT" - if [ "$offer" = agmsgd=on ]; then - echo "note: agmsgd is still on; 'agmsg daemon disable' turns it off." - fi + _record_choice "$ACTION" + case "$offer" in + agmsgd=on|agmsgd=not-running*) + echo "agmsgd does not deliver to this project now ($(_mode_label "$ACTION") chosen). agmsgd itself stays on for the other projects of this install; to turn it off for all of them: agmsg daemon disable" + ;; + esac ;; esac diff --git a/scripts/drivers/types/claude-code/_session-start.sh b/scripts/drivers/types/claude-code/_session-start.sh index 4f02584c6..ae51979df 100644 --- a/scripts/drivers/types/claude-code/_session-start.sh +++ b/scripts/drivers/types/claude-code/_session-start.sh @@ -47,6 +47,8 @@ agmsg_session_start() { . "${SKILL_DIR:-}/scripts/lib/daemon-state.sh" fi agmsg_daemon_read_state + # A project that chose something other than agmsgd keeps the Monitor path. + agmsg_daemon_apply_project_choice claude-code "${PROJECT:-}" [ "${AGMSGD_HEALTH:-}" = ready ] || return 0 if ! agmsg_claude_code_native_storage_ready; then echo "AGMSG delivery: agmsgd is running, but its Claude channel only reads the sqlite storage and this install's storage config is not usable for it, so the native channel is not used. Next: arm the Monitor below." diff --git a/scripts/drivers/types/claude-code/template.md b/scripts/drivers/types/claude-code/template.md index c24caa2bf..adfa42b0f 100644 --- a/scripts/drivers/types/claude-code/template.md +++ b/scripts/drivers/types/claude-code/template.md @@ -13,8 +13,8 @@ To apply the choice, run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg delivery --type __AGENT_TYPE__ --project "$(pwd)"` with `` = `agmsgd`, `monitor`, `turn`, `both` or `off` (`monitor (legacy)` is the value `monitor`) and show its output and follow the printed `AGMSG-DIRECTIVE` block: - `agmsgd` turns the background service on (`daemon enable`) and leaves the project on `monitor` as the fallback for a seat whose destination is not confirmed yet. If it fails, say so and offer the other choices. - - Keep using agmsgd (it is on or turned on but not running): run `agmsg delivery agmsgd` the same way. - - `monitor (legacy)` when agmsgd is on: run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg daemon disable` first, then `agmsg delivery monitor`. + - Keep using agmsgd (it is on or turned on but not running): run `agmsg delivery agmsgd` the same way; it also records that this project uses agmsgd. + - `monitor (legacy)` (and `turn`, `both`, `off`) is a real opt-out: the CLI records that this project does not use agmsgd, so agmsgd stops delivering here and the project keeps the old path. agmsgd stays on for the install's other projects; when it says so, pass on its line that `agmsg daemon disable` turns agmsgd off for all of them. diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh index 6732e0032..d02d55782 100644 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -220,6 +220,7 @@ agmsg_delivery_status() { # shellcheck disable=SC1091 source "${SKILL_DIR:-}/scripts/lib/role-session.sh" agmsg_daemon_read_state + agmsg_daemon_apply_project_choice codex "$project" if [ "${AGMSGD_DESIRED:-}" = on ] && [ "${AGMSGD_HEALTH:-}" = ready ]; then pairs="$("${SCRIPT_DIR:-}/identities.sh" "$project" "$type" 2>/dev/null || true)" while IFS=$'\t' read -r team name _rest; do diff --git a/scripts/drivers/types/codex/codex-monitor.sh b/scripts/drivers/types/codex/codex-monitor.sh index 640898b01..2bf2502f8 100755 --- a/scripts/drivers/types/codex/codex-monitor.sh +++ b/scripts/drivers/types/codex/codex-monitor.sh @@ -108,6 +108,7 @@ PROJECT="$(cd "$PROJECT" && pwd)" # shellcheck source=../../../lib/daemon-state.sh source "$SCRIPT_DIR/../../../lib/daemon-state.sh" agmsg_daemon_read_state +agmsg_daemon_apply_project_choice codex "$PROJECT" if [ "${AGMSGD_DESIRED:-unknown}" = on ]; then echo 'agmsgd handles Codex notices; starting plain Codex without a bridge.' >&2 agmsg_daemon_warn_if_stopped always diff --git a/scripts/drivers/types/codex/codex-shim.sh b/scripts/drivers/types/codex/codex-shim.sh index 9780f8f0a..8561d8a0d 100755 --- a/scripts/drivers/types/codex/codex-shim.sh +++ b/scripts/drivers/types/codex/codex-shim.sh @@ -188,6 +188,8 @@ if [ -f "$daemon_state_lib" ]; then # shellcheck disable=SC1090 source "$daemon_state_lib" agmsg_daemon_read_state + # A project that chose something other than agmsgd is launched as before. + agmsg_daemon_apply_project_choice codex "$project" if [ "${AGMSGD_DESIRED:-unknown}" = on ]; then case "$command_name" in app-server|exec|e|login|logout|mcp|plugin|remote-control|completion|update|doctor|cloud|exec-server|features|debug|apply|a|review|sandbox|help|--help|-h|version|--version|-V) diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 7f97eff14..e8d945d4e 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -21,8 +21,8 @@ Do not use POSIX `'"'"'` quote splicing in PowerShell, and do not use escaped do To apply the choice, run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg delivery --type __AGENT_TYPE__ --project "$(pwd)"` with `` = `agmsgd`, `monitor`, `turn` or `off` (`monitor (legacy)` is the value `monitor`) and show its output: - `agmsgd` turns the background service on (`daemon enable`) and leaves the project on `monitor` as the fallback for a seat whose destination is not confirmed yet. If it fails, say so and offer the other choices. - - Keep using agmsgd (it is on or turned on but not running): run `agmsg delivery agmsgd` the same way. - - `monitor (legacy)` when agmsgd is on: run `~/.agents/skills/__SKILL_NAME__/scripts/agmsg daemon disable` first, then `agmsg delivery monitor`. + - Keep using agmsgd (it is on or turned on but not running): run `agmsg delivery agmsgd` the same way; it also records that this project uses agmsgd. + - `monitor (legacy)` (and `turn`, `both`, `off`) is a real opt-out: the CLI records that this project does not use agmsgd, so agmsgd stops delivering here and the project keeps the old path. agmsgd stays on for the install's other projects; when it says so, pass on its line that `agmsg daemon disable` turns agmsgd off for all of them. diff --git a/scripts/lib/daemon-seats.sh b/scripts/lib/daemon-seats.sh index f635913ac..43e0fd5c5 100644 --- a/scripts/lib/daemon-seats.sh +++ b/scripts/lib/daemon-seats.sh @@ -11,7 +11,7 @@ source "$SKILL_DIR/scripts/lib/role-session.sh" agmsg_daemon_seat_report() { local action="${1:-status}" cfg cfg_sql roster team agent project pid meta_type meta_project meta_pid local bridge record_status cached key driver record_type record_home label latest - local seen='|' omitted=0 pending owner updated recent active display + local seen='|' omitted=0 pending owner updated recent active display chosen local active_lines='' recent_lines='' older_lines='' recovery source "$SKILL_DIR/scripts/lib/type-registry.sh" source "$SKILL_DIR/scripts/lib/boot-command.sh" @@ -82,6 +82,12 @@ agmsg_daemon_seat_report() { updated="$(printf '%s' "$updated" | sed "s/'/''/g")" recent="$(sqlite3 :memory: "SELECT COALESCE(strftime('%s','$updated') >= strftime('%s','now','-1 day'),0);" 2>/dev/null)" || recent=0 active=0 + chosen="" + if declare -F agmsg_delivery_choice_get >/dev/null 2>&1; then + chosen="$(agmsg_delivery_choice_get codex "$project")" + [ "$chosen" != agmsgd ] || chosen="" + [ -z "$chosen" ] || active=1 + fi [ "$bridge" != running ] || active=1 [ "$pending" = 0 ] || active=1 # Bare historical thread IDs have no process witness. Do not scan all @@ -127,6 +133,10 @@ agmsg_daemon_seat_report() { *:unknown) display="bridge could not be verified; inspect delivery.sh status codex ($record_status)" ;; *) display="$record_status" ;; esac + if [ -n "$chosen" ]; then + [ "$chosen" != monitor ] || chosen='monitor (legacy)' + display="$chosen chosen; agmsgd does not deliver here" + fi if [ "$driver" != sqlite ]; then local storage_label="$driver" [ "$driver" != jsonl ] || storage_label=JSONL diff --git a/scripts/lib/daemon-state.sh b/scripts/lib/daemon-state.sh index 835fc81c9..4bd03e954 100644 --- a/scripts/lib/daemon-state.sh +++ b/scripts/lib/daemon-state.sh @@ -210,3 +210,30 @@ agmsg_daemon_warn_if_stopped() { done return 0 } + +# A project's explicit delivery choice, written only by `agmsg delivery ` +# as a line "" in run/delivery-choice. +# No line means nobody chose (an install updated from before, or a project that +# never saw the choice screen): agmsgd delivers as it always has. A value other +# than "agmsgd" (monitor, turn, both, off) is a decision not to use agmsgd for +# that project. +agmsg_delivery_choice_get() { # + local file="$_AGMSG_DAEMON_ROOT/run/delivery-choice" project="$2" + [ -r "$file" ] && [ -n "$project" ] || return 0 + project="$(cd -- "$project" 2>/dev/null && pwd -P)" || project="$2" + awk -F'\t' -v t="$1" -v p="$project" '$1 == t && $2 == p { c = $3 } END { if (c != "") print c }' "$file" 2>/dev/null +} + +# Make this project read as agmsgd-off when it chose something other than agmsgd, +# so every caller that asks "does agmsgd deliver here" gets the project's answer. +# Call right after agmsg_daemon_read_state, in the same scope. +agmsg_daemon_apply_project_choice() { # + local choice + choice="$(agmsg_delivery_choice_get "$1" "${2:-}")" + case "$choice" in + ''|agmsgd) return 0 ;; + esac + AGMSGD_DESIRED=off + AGMSGD_HEALTH=stopped + AGMSGD_REASON="this project chose $choice, not agmsgd" +} diff --git a/scripts/watch.sh b/scripts/watch.sh index fdbcee171..908982d6f 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -1254,6 +1254,7 @@ _agmsg_watch_native_delivery_active() { || return 1 local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON agmsg_daemon_read_state + agmsg_daemon_apply_project_choice claude-code "$PROJECT_PATH" [ "${AGMSGD_DESIRED:-}" = on ] && [ "${AGMSGD_HEALTH:-}" = ready ] || return 1 declare -F _agmsg_detect_platform >/dev/null 2>&1 && _agmsg_detect_platform declare -F agmsg_claude_code_native_storage_ready >/dev/null 2>&1 || return 1 diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs index 301facf6f..970a6792d 100644 --- a/tests/agmsgd_claude_code_queue.test.mjs +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -444,3 +444,19 @@ test('a seat whose hook could not publish a destination is reported with that re writeFileSync(path.join(root, 'run', 'role-session.test__alice'), record('messaging_blocked=bad value;\n')); assert.equal(readClaudeRoleSession(path.join(root, 'run'), 'test', 'alice').record.messaging_blocked, ''); }); + +test('a project that chose something other than agmsgd gets no nudge, a waiting one is dropped, and a project that never chose is unchanged', async (t) => { + const { db, channel, state } = channelFixture(t); + const root = state.options.installRoot; + await channel.pollOnce(); + assert.equal(state.sent, 1, 'no choice recorded: agmsgd delivers as before'); + writeFileSync(path.join(root, 'run', 'delivery-choice'), `claude-code\t${root}\tmonitor\n`); + for (let i = 0; i < 3; i += 1) await channel.pollOnce(); + assert.equal(state.sent, 1, 'no further nudge for an opted-out project'); + assert.equal(db.prepare("SELECT state || '/' || reason AS r FROM beta_claude_seat").get().r, 'unaddressable/delivery_chosen_monitor'); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM beta_claude_queue WHERE state = 'pending'").get().n, 0); + writeFileSync(path.join(root, 'run', 'delivery-choice'), `claude-code\t${root}\tagmsgd\n`); + state.hold = false; + await channel.pollOnce(); + assert.equal(db.prepare("SELECT state FROM beta_claude_seat").get().state === 'unaddressable', false, 'choosing agmsgd again puts the seat back'); +}); diff --git a/tests/test_agmsgd_switch.bats b/tests/test_agmsgd_switch.bats index cc1589a06..7a742c2fa 100644 --- a/tests/test_agmsgd_switch.bats +++ b/tests/test_agmsgd_switch.bats @@ -48,6 +48,22 @@ setup() { refute grep -q '^monitor' "$CALL_LOG" } +@test "a project that chose monitor (legacy) still gets the bridge while agmsgd is on; one that chose agmsgd or never chose gets none" { + printf 'codex\t%s\tmonitor\n' "$TEST_SKILL_DIR/project" > "$TEST_SKILL_DIR/run/delivery-choice" + : > "$CALL_LOG" + run bash "$SCRIPTS/drivers/types/codex/codex-shim.sh" -C "$TEST_SKILL_DIR/project" resume --last + [ "$status" -eq 0 ] + grep -q '^monitor' "$CALL_LOG" + : > "$CALL_LOG" + printf 'codex\t%s\tagmsgd\n' "$TEST_SKILL_DIR/project" > "$TEST_SKILL_DIR/run/delivery-choice" + run bash "$SCRIPTS/drivers/types/codex/codex-shim.sh" -C "$TEST_SKILL_DIR/project" resume --last + refute grep -q '^monitor' "$CALL_LOG" + : > "$CALL_LOG" + rm -f "$TEST_SKILL_DIR/run/delivery-choice" + run bash "$SCRIPTS/drivers/types/codex/codex-shim.sh" -C "$TEST_SKILL_DIR/project" resume --last + refute grep -q '^monitor' "$CALL_LOG" +} + @test "off, missing and unreadable install records retain the existing monitor route" { for condition in off missing unreadable; do case "$condition" in diff --git a/tests/test_delivery_cli.bats b/tests/test_delivery_cli.bats index c7e7c6daa..80fcc4301 100644 --- a/tests/test_delivery_cli.bats +++ b/tests/test_delivery_cli.bats @@ -73,13 +73,47 @@ teardown() { rm -rf "$TEST_SKILL_DIR"; } grep -Fq "mode: monitor" <<<"$output" } -@test "delivery : the other modes are exactly delivery.sh set, and an agmsgd that is on is noted" { +@test "delivery : the other modes are delivery.sh set plus a recorded choice; agmsgd stays on for the install" { _mark_daemon_ready run bash "$SCRIPTS/agmsg" delivery turn --type claude-code --project "$PROJECT" [ "$status" -eq 0 ] - grep -Fq "agmsgd is still on" <<<"$output" + grep -Fq "agmsgd does not deliver to this project now (turn chosen)" <<<"$output" + grep -Fq "agmsg daemon disable" <<<"$output" run bash "$SCRIPTS/delivery.sh" status claude-code "$PROJECT" grep -Fq "mode: turn" <<<"$output" + [ "$(cat "$TEST_SKILL_DIR/run/delivery-choice")" = "$(printf 'claude-code\t%s\tturn' "$(cd "$PROJECT" && pwd -P)")" ] +} + +@test "delivery monitor: choosing monitor (legacy) is a real opt-out that status shows and a later agmsgd choice undoes" { + _mark_daemon_ready + run bash "$SCRIPTS/agmsg" delivery monitor --type claude-code --project "$PROJECT" + [ "$status" -eq 0 ] + run bash "$SCRIPTS/agmsg" delivery status --type claude-code --project "$PROJECT" + grep -Fq "this project: monitor (legacy) chosen — agmsgd does not deliver here" <<<"$output" + refute grep -Fq "fallback" <<<"$output" + run bash "$SCRIPTS/agmsg" delivery agmsgd --type claude-code --project "$PROJECT" + [ "$status" -eq 0 ] + [ "$(grep -c . "$TEST_SKILL_DIR/run/delivery-choice")" -eq 1 ] + run bash "$SCRIPTS/agmsg" delivery status --type claude-code --project "$PROJECT" + grep -Fq "this project's fallback: monitor (legacy)" <<<"$output" +} + +@test "delivery choice: a project that never chose is untouched; agmsgd and other choices apply to their own project only" { + other="$TEST_SKILL_DIR/other" + mkdir -p "$other" + _mark_daemon_ready + bash "$SCRIPTS/agmsg" delivery monitor --type claude-code --project "$PROJECT" >/dev/null + run bash -c ' + export SKILL_DIR="$1"; . "$1/scripts/lib/daemon-state.sh" + for p in "$2" "$3"; do + AGMSGD_DESIRED=on; AGMSGD_HEALTH=ready; agmsg_daemon_apply_project_choice claude-code "$p" + echo "$p $AGMSGD_DESIRED $AGMSGD_HEALTH" + done + AGMSGD_DESIRED=on; AGMSGD_HEALTH=ready; agmsg_daemon_apply_project_choice codex "$2" + echo "codex $AGMSGD_DESIRED"' _ "$TEST_SKILL_DIR" "$PROJECT" "$other" + grep -Fq "$PROJECT off stopped" <<<"$output" + grep -Fq "$other on ready" <<<"$output" + grep -Fq "codex on" <<<"$output" } @test "delivery: an unknown value is refused" { diff --git a/tests/test_session_start_claude_code_native_channel.bats b/tests/test_session_start_claude_code_native_channel.bats index 7d164532a..7a71b014b 100644 --- a/tests/test_session_start_claude_code_native_channel.bats +++ b/tests/test_session_start_claude_code_native_channel.bats @@ -150,6 +150,24 @@ _mark_daemon_ready() { refute grep -q "^messaging_blocked=" "$RUN_DIR/role-session.team__alice" } +@test "session-start: a project that chose monitor (legacy) keeps the Monitor directive even with agmsgd ready" { + _mark_daemon_ready + printf 'claude-code\t%s\tmonitor\n' "$(cd "$PROJ" 2>/dev/null && pwd -P || printf "%s" "$PROJ")" > "$RUN_DIR/delivery-choice" + run env AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-legacy.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-legacy"}' + [ "$status" -eq 0 ] + grep -qF "AGMSG monitor mode" <<<"$output" + refute grep -q "agmsgd is running and will deliver" <<<"$output" + refute grep -q "^messaging_socket=" "$RUN_DIR/role-session.team__alice" 2>/dev/null + # actas-claim keeps the same project on the Monitor path. + run env AGMSG_AGENT_PID=$$ CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-legacy.sock" bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "sid-legacy" + refute grep -qx "delivery=native" <<<"$output" + # Choosing agmsgd for the project brings the direct path back. + printf 'claude-code\t%s\tagmsgd\n' "$(cd "$PROJ" 2>/dev/null && pwd -P || printf "%s" "$PROJ")" > "$RUN_DIR/delivery-choice" + run env AGMSG_AGENT_PID=$$ CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-legacy.sock" bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "sid-legacy" + grep -qx "delivery=native" <<<"$output" +} + @test "session-start: agmsgd not running -> the usual Monitor directive still fires" { run _run_session_start "sid-no-daemon" [ "$status" -eq 0 ] From 801d666aa61e21e46dacc0faa9576054f343647c Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 16:09:26 +0900 Subject: [PATCH 92/97] docs/design: note what changed in 1.6.0 at the top of the agmsgd records (#1721) [skip ci] --- docs/design/agmsgd-architecture.ja.md | 2 ++ docs/design/agmsgd-architecture.md | 2 ++ docs/design/agmsgd-rfc.ja.md | 2 ++ docs/design/agmsgd-rfc.md | 2 ++ scripts/drivers/terminals/plain/README.md | 3 +++ 5 files changed, 11 insertions(+) diff --git a/docs/design/agmsgd-architecture.ja.md b/docs/design/agmsgd-architecture.ja.md index 6c323258f..99d9f7968 100644 --- a/docs/design/agmsgd-architecture.ja.md +++ b/docs/design/agmsgd-architecture.ja.md @@ -1,5 +1,7 @@ # agmsgd — アーキテクチャ設計 +> **1.6.0 での変更:** この記録は最初の beta の内容です。出荷された agmsgd は Claude Code と Codex の両方のセッションに届け、Windows でも動きます(Claude Code 2.1.302 以上、Codex 0.157 以上)。配達方法の選択画面でおすすめとして出され、本人が選んだときだけ有効になります。以下の「Codex のみ」「macOS/Linux のみ」「既定でオフ」「Windows 未対応」は最初の beta の記述です。 + [RFC](agmsgd-rfc.ja.md) で承認した方向を、各部分がどう振る舞うかまで詰めた設計書です。今こうなっている、だけを書いています。それぞれの選択の理由は、discussion と、このあと書く ADR に残します。実装はまだ始めていません。次は技術設計(ソースの置き場所、ビルドと配布の方法、主要な部品)です。 状態: 2026-09-28。[English](agmsgd-architecture.md) diff --git a/docs/design/agmsgd-architecture.md b/docs/design/agmsgd-architecture.md index 72cc850b1..f330db2a1 100644 --- a/docs/design/agmsgd-architecture.md +++ b/docs/design/agmsgd-architecture.md @@ -1,5 +1,7 @@ # agmsgd — architecture design +> **Changed in 1.6.0.** This record describes the first beta. As shipped, agmsgd delivers to both Claude Code and Codex sessions, works on Windows too (Claude Code 2.1.302 or later, Codex 0.157 or later), and is offered in the delivery-method screen as the recommended choice; it is turned on only when the person picks it. Statements below that it is Codex-only, macOS/Linux-only, default-off, or unsupported on Windows describe the first beta. + This is the architecture design for agmsgd: the approved direction in [the RFC](agmsgd-rfc.md), worked down to how each part behaves. It describes the current design only; the reasoning behind each choice lives in the discussion and in the ADRs that will follow. Implementation has not started. The technical design (where the source lives, how it is built and shipped, the main components) comes next. **1.6.0 beta scope:** the optional, default-off daemon queues inbox notices for Codex sessions against the existing SQLite store, before the 2.0.0 migration. Existing bridges and remote sync processes remain separate; a live bridge continues until its Codex session restarts. `agmsg daemon enable` changes new Codex launches, while `agmsg daemon disable` lists sessions that need restarting to restore bridge notices. If `agmsg` is not found, use `/scripts/agmsg daemon enable|disable`. macOS and Linux are supported. Windows delivery remains unsupported pending measurement, and JSONL storage is unsupported. The beta does not implement the 2.0.0 storage or delivery changes described below. diff --git a/docs/design/agmsgd-rfc.ja.md b/docs/design/agmsgd-rfc.ja.md index 9813eb4d8..d8f7ef321 100644 --- a/docs/design/agmsgd-rfc.ja.md +++ b/docs/design/agmsgd-rfc.ja.md @@ -1,5 +1,7 @@ # RFC: agmsgd — 常駐配達デーモン +> **1.6.0 での変更:** この記録は最初の beta の内容です。出荷された agmsgd は Claude Code と Codex の両方のセッションに届け、Windows でも動きます(Claude Code 2.1.302 以上、Codex 0.157 以上)。配達方法の選択画面でおすすめとして出され、本人が選んだときだけ有効になります。以下の「Codex のみ」「macOS/Linux のみ」「既定でオフ」「Windows 未対応」は最初の beta の記述です。 + これは RFC ---- 設計への意見を求める文書であって、決定の記録ではない。決まったことは従来どおり ADR に落ちる。**変えるのがまだ安いうちに形を変えられるよう**、実装の前に公開する。 > **現在の状態(2026-09-28)。** ここに書いた方向は承認され、この note はそれに合わせてある。最初の版から変わった点は[最初の版からの変更](#最初の版からの変更)にまとめた。アーキテクチャ設計(各部分がどう動くか)は済み、この note の隣に [agmsgd-architecture.ja.md](agmsgd-architecture.ja.md) として置いた。次は技術設計(ソースの置き場所、ビルドと配布の方法)で、実装はその後になる。どのリリースも止められるものにし、一気には作らない。discussion で問いに答えてくれた方々に感謝する ---- 答えは以下に反映してある。 diff --git a/docs/design/agmsgd-rfc.md b/docs/design/agmsgd-rfc.md index ae3029fab..bb52f08bc 100644 --- a/docs/design/agmsgd-rfc.md +++ b/docs/design/agmsgd-rfc.md @@ -1,5 +1,7 @@ # RFC: agmsgd — a resident delivery daemon +> **Changed in 1.6.0.** This record describes the first beta. As shipped, agmsgd delivers to both Claude Code and Codex sessions, works on Windows too (Claude Code 2.1.302 or later, Codex 0.157 or later), and is offered in the delivery-method screen as the recommended choice; it is turned on only when the person picks it. Statements below that it is Codex-only, macOS/Linux-only, default-off, or unsupported on Windows describe the first beta. + This is an RFC: a request for comments on a design, not a decision record. Decisions land as ADRs as usual once settled — this is published first, so the shape can change while changing it is still cheap. > **Status (2026-09-28).** The direction described here has been approved, and this note now matches it. What changed since the first version is listed in [Changes since the first version](#changes-since-the-first-version). The architecture design (how each part works) is done and published next to this note as [agmsgd-architecture.md](agmsgd-architecture.md). Next is the technical design (where the source lives, how it is built and shipped); implementation follows after that. Every release has to be something that can be stopped, and none is built all at once. Thank you to everyone who answered the open questions in the discussion — the answers are reflected below. diff --git a/scripts/drivers/terminals/plain/README.md b/scripts/drivers/terminals/plain/README.md index 449b745d9..b8df5e7cf 100644 --- a/scripts/drivers/terminals/plain/README.md +++ b/scripts/drivers/terminals/plain/README.md @@ -51,6 +51,9 @@ There is no pane to type into, but that is not automatically a dead end: path to this channel (its wire protocol is internal and undocumented, #1229) — called anyway for this pair, it refuses (still exit 13) and names this section rather than attempting anything. + This is only a way to nudge a session that has no pane to type into; it is + not a second route for agmsg messages. The message itself still goes through + agmsg (`send.sh`), so it is recorded in the team's history like any other. - **Otherwise** (the caller is not Claude Code, or the target's type has no native channel of its own): `poke.sh` delivers the poke text as an ordinary agmsg message instead of typing it — the same store `send.sh` From 039ad28a38554919da845890fe92431925a7c25b Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 22 Oct 2026 16:11:27 +0900 Subject: [PATCH 93/97] docs: align wording on Monitor, agmsgd and the daemon; fix stale details (#1720) [skip ci] --- ARCHITECTURE.ja.md | 22 +++++++----- ARCHITECTURE.md | 16 ++++----- CONTRIBUTING.ja.md | 8 ++--- CONTRIBUTING.md | 8 ++--- README.md | 36 ++++++++++--------- RELEASING.md | 10 +++--- SKILL.md | 8 ++--- app/README.md | 16 +++++---- docs/actas.md | 6 ++-- docs/agent-types.md | 8 +++-- docs/antigravity-monitor-beta.md | 4 ++- docs/building-on-agmsg.md | 13 +++---- docs/codex-monitor-beta.md | 16 ++++++--- docs/design.ja.md | 15 ++++---- docs/design.md | 26 ++++++++------ docs/ext-tool-beta.ja.md | 3 +- docs/ext-tool-beta.md | 7 ++-- docs/opencode.md | 6 ++-- llms.txt | 24 ++++++++----- scripts/drivers/ext-tools/README.md | 1 + .../terminals/claude-desktop/README.md | 5 +-- scripts/drivers/types/claude-code/template.md | 4 +-- scripts/drivers/types/codex/template.md | 4 +-- scripts/skill-base.md | 8 ++--- 24 files changed, 159 insertions(+), 115 deletions(-) diff --git a/ARCHITECTURE.ja.md b/ARCHITECTURE.ja.md index b597eca3e..d708f7366 100644 --- a/ARCHITECTURE.ja.md +++ b/ARCHITECTURE.ja.md @@ -6,21 +6,24 @@ ## ゴール -Claude Code、Codex、Gemini CLI、Antigravity、そして将来登場するエージェントランタイムの、あらゆる組み合わせの間で動作するクロスエージェントメッセージングのプリミティブ — デーモンなし、ネットワークなし、共有クラウドなし。メッセージは単一マシン上のローカルファイルを通じて移動し、受信側はホストランタイムが提供するフックやストリーミング機構を通じて通知を受ける。 +Claude Code、Codex、Gemini CLI、Antigravity、そして将来登場するエージェントランタイムの、あらゆる組み合わせの間で動作するクロスエージェントメッセージングのプリミティブ — 必須のデーモンなし、ネットワークなし、共有クラウドなし。agmsg 本体はデーモンなしで動く。任意の agmsgd(1.6.0 beta)は、ユーザーが選んだときだけ入る常駐サービスである。メッセージは単一マシン上のローカルファイルを通じて移動し、受信側はホストランタイムが提供するフックやストリーミング機構を通じて通知を受ける。 デフォルトのインストールは **bash + sqlite3 のみ**で動作しなければならない。それを超える機能はすべてオプトインであり、ユーザーが同意した場合に限り追加の依存関係を要求してよい。 -## 3軸ドライバーモデル +## 4軸ドライバーモデル -agmsg は3つの直交する軸を中心に構築されており、各軸には常にちょうど1つの**ドライバー (driver)** がアクティブになる。ドライバーとは、固定されたプロトコルの背後にある差し替え可能な実装である。 +agmsg は4つの直交する軸を中心に構築されており、各軸には常にちょうど1つの**ドライバー (driver)** がアクティブになる(terminal 軸はメンバーごと。下記参照)。ドライバーとは、固定されたプロトコルの背後にある差し替え可能な実装である。 | 軸 | 抽象化する対象 | バンドルされているドライバー | |---|---|---| -| **storage(ストレージ)** | メッセージとチームの状態がどこに存在し、どのようにクエリされるか | `sqlite`(デフォルト)、`jsonl-duckdb` | -| **agent(エージェント)** | ランタイムごとの差異(フック形式、設定ファイルの場所、monitor ツールの有無) | `claude-code`、`codex`、`gemini`、`antigravity`、`copilot` | -| **delivery(配信)** | 受信者にメッセージの到着をどう通知するか | `monitor`、`turn`、`both`、`off` | +| **storage(ストレージ)** | メッセージとチームの状態がどこに存在し、どのようにクエリされるか | `sqlite`(デフォルト)、`jsonl`、`sqlite-sync` | +| **agent(エージェント)** | ランタイムごとの差異(フック形式、設定ファイルの場所、monitor ツールの有無) | `claude-code`、`codex`、`gemini`、`antigravity`、`copilot`、`opencode`、`grok-build`、`cursor`、`devin`、`hermes`(CLI でないメンバー用に `agmsg-app` と `ext-tool`) | +| **delivery(配信)** | 受信者にメッセージの到着をどう通知するか | `monitor`(legacy)、`turn`、`both`、`off`。どれに対応するかは agent タイプごとに決まる(`type.conf` の `delivery_modes=`)。任意の agmsgd デーモンは、Claude Code と Codex のセッションに直接届ける別の手段である | +| **terminal(ターミナル)** | メンバーのホストエージェント CLI が動くペイン、ウィンドウ、プロセスと、その作成・読み取り・信号送信の方法 | `herdr`、`tmux`、`plain`、`orca`、`claude-desktop`、`codex-app` | -3つの軸は独立している。どの storage ドライバーも、どの agent ドライバー・どの delivery モードとも組み合わせ可能である。これらは共通の discovery/config/dependency-check プロトコル(仕様参照)を共有しつつ、軸ごとに固有の操作を公開する。 +3つの設定用の軸は独立している。どの storage ドライバーも、どの agent ドライバーとも、その agent ドライバーが対応する delivery モードとも組み合わせ可能である。これらは共通の discovery/config/dependency-check プロトコル(仕様参照)を共有しつつ、軸ごとに固有の操作を公開する。 + +**terminal は設定ではなく検出される** — セッションは、自分がどの terminal ドライバーの下で動いているかを(どれにも一致しなければ最後の手段である `plain` に落ちて)、指示されるのではなく自分で判断する。それ以外は同じ種類のドライバーであり、固定されたプロトコル(`terminal_*` 関数。[`docs/spec/driver-interface.md`](docs/spec/driver-interface.md) §6 参照)の背後で `herdr`、`tmux`、`orca`、通常の OS ターミナルが交換可能であり、他の3軸と同じ discovery/trust プロトコルを共有する。他の軸と違って、メンバーは terminal の機能が一切なくても機能する — `plain` は「アドレス指定できるペインなし」に縮退するだけで、メッセージングは続く。terminal が加えるのは、他のメンバーのペインを探し、読み、入力する能力だけである。 ## ドライバー (driver) と プラグイン (plugin) @@ -29,7 +32,7 @@ agmsg は3つの直交する軸を中心に構築されており、各軸には バンドルされたドライバーは `scripts/drivers//` 配下に存在する。`scripts/` の中では、トップレベルの `.sh` ファイルは直接実行可能なコマンドであり、サブディレクトリは実装コードをカテゴリ別にグループ化する(軸ドライバー用の `drivers/`、共有ヘルパー用の `lib/`)。 -プラグインパス(`~/.agents/agmsg/plugins///`)は将来の拡張のために予約されており、v1 では未実装である。具体的な外部ドライバーが必要になるまでは、すべてのドライバーがバンドル形式で提供される。いずれも同じプロトコルを通じて発見・ロードされる。 +外部ドライバーは `/plugins///`、または `$AGMSG_PLUGIN_DIRS` に列挙したディレクトリに置く。ユーザーが `agmsg plugin trust /` でオプトインするまでは無視される。現時点で外部ドライバーを読み込めるのは `types` 軸だけである。バンドルされたドライバーも外部ドライバーも同じプロトコルで発見・ロードされる。[`docs/plugins.md`](docs/plugins.md) と [ADR 0002](docs/adr/0002-driver-discovery-and-plugin-opt-in.md) を参照。 agmsg では *backend*、*extension*、*provider*、*adapter* という用語は使用しない。ユーザー向けドキュメントや CLI において `storage` は「storage driver(ストレージドライバー)」の同義語として書かれる。 @@ -44,7 +47,7 @@ agmsg では *backend*、*extension*、*provider*、*adapter* という用語は これにより agmsg 自体は最小限かつプラットフォームに依存しない状態を保てる。ホストエージェントはすでにファイルアクセスやシェルアクセスを信頼されているため、依存関係のインストールはホストエージェントの責務とするのが自然である。 -同じ `AGMSG-DIRECTIVE` の仕組みは、他のホストエージェント連携アクション(例: delivery モードが `monitor` に設定されたときに Claude Code に Monitor ツールを呼び出すよう伝える、など)にも使われる。 +同じ `AGMSG-DIRECTIVE` の仕組みは、他のホストエージェント連携アクション(例: delivery モードが monitor (legacy) に設定されたときに Claude Code に Monitor ツールを呼び出すよう伝える、など。agmsgd が直接届けるセッションにはこのディレクティブは要らない)にも使われる。 ## 失敗時の挙動 (Failure semantics) @@ -72,6 +75,7 @@ sqlite ドライバーは、インライン `read` カラムを持つレガシ | **storage(ストレージ)** | ユーザー向けの文脈における storage 軸、またはそのアクティブなドライバー | | **AGMSG-DIRECTIVE** | ホストエージェントに特定のアクションを取るよう指示するために stdout に出力される JSON 行 | | **host agent(ホストエージェント)** | agmsg スクリプトを呼び出すランタイム(Claude Code、Codex、Gemini CLI、Antigravity、…) | +| **placement(配置)** | 1 人のメンバーのペイン/ウィンドウへの `:` 参照(例: `tmux:/path/to/socket:%3`) | | **event log(イベントログ)** | バンドルされた storage ドライバーがクエリの射影元とする、メッセージのライフサイクルイベントの追記専用記録 | ## 関連ドキュメント diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6dc02cbbb..89bc93785 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,7 +6,7 @@ This document describes the internal shape of agmsg — the mental model a contr ## Goal -A cross-agent messaging primitive that works between any combination of Claude Code, Codex, Gemini CLI, Antigravity, and future agent runtimes — with no daemon, no network, no shared cloud. Messages move through local files on a single machine; receivers are notified through whatever hook or streaming mechanism their host runtime provides. +A cross-agent messaging primitive that works between any combination of Claude Code, Codex, Gemini CLI, Antigravity, and future agent runtimes — with no required daemon, no network, no shared cloud. agmsg itself runs without a daemon; the optional agmsgd (1.6.0 beta) is a resident service that is installed only when the user picks it. Messages move through local files on a single machine; receivers are notified through whatever hook or streaming mechanism their host runtime provides. The default install must work with **bash + sqlite3 only**. Any feature beyond that is opt-in and may require additional dependencies that the user agrees to install. @@ -16,12 +16,12 @@ agmsg is built around four orthogonal axes, each of which has exactly one **driv | Axis | What it abstracts | Bundled drivers | |---|---|---| -| **storage** | Where messages and team state live, and how they are queried | `sqlite` (default), `jsonl-duckdb` | -| **agent** | Per-runtime differences (hook formats, settings file locations, monitor tool availability) | `claude-code`, `codex`, `gemini`, `antigravity`, `copilot` | -| **delivery** | How a recipient is notified that a message arrived | `monitor`, `turn`, `both`, `off` | -| **terminal** | The pane, window, or process a member's host-agent CLI runs under, and how to create, read, and signal it | `herdr`, `tmux`, `plain`, `orca` | +| **storage** | Where messages and team state live, and how they are queried | `sqlite` (default), `jsonl`, `sqlite-sync` | +| **agent** | Per-runtime differences (hook formats, settings file locations, monitor tool availability) | `claude-code`, `codex`, `gemini`, `antigravity`, `copilot`, `opencode`, `grok-build`, `cursor`, `devin`, `hermes` (plus `agmsg-app` and `ext-tool` for non-CLI members) | +| **delivery** | How a recipient is notified that a message arrived | `monitor` (legacy), `turn`, `both`, `off`; each agent type declares which of these it supports (`delivery_modes=` in its `type.conf`). The optional agmsgd daemon is a further way to deliver to Claude Code and Codex sessions | +| **terminal** | The pane, window, or process a member's host-agent CLI runs under, and how to create, read, and signal it | `herdr`, `tmux`, `plain`, `orca`, `claude-desktop`, `codex-app` | -The three configuration axes are independent: any storage driver can be paired with any agent driver and any delivery mode. They share a common discovery/config/dependency-check protocol (see the spec) but expose axis-specific operations. +The three configuration axes are independent: any storage driver can be paired with any agent driver and any delivery mode that agent driver supports. They share a common discovery/config/dependency-check protocol (see the spec) but expose axis-specific operations. **terminal is detected, not configured** — a session determines which terminal driver it is running under (or falls back to `plain`, the driver of last resort, if none matches) rather than being told. It is otherwise the same kind of driver: a fixed protocol (`terminal_*` functions, see [`docs/spec/driver-interface.md`](docs/spec/driver-interface.md) §6) behind which `herdr`, `tmux`, `orca`, and a plain OS terminal are interchangeable, and it shares the same discovery/trust protocol as the other three. Unlike them, a member can function with no terminal capability at all — `plain` degrades to "no addressable pane," and messaging keeps working; terminal only adds the ability to locate, read, or type into another member's pane. @@ -32,7 +32,7 @@ The three configuration axes are independent: any storage driver can be paired w Bundled drivers live under `scripts/drivers//`. Inside `scripts/`, the top-level `.sh` files are directly-invokable commands; subdirectories group implementation code by category (`drivers/` for axis drivers, `lib/` for shared helpers). -The plugin path (`~/.agents/agmsg/plugins///`) is reserved for a future enhancement and is not implemented in v1. Until a concrete external driver is wanted, all drivers ship bundled. Both are discovered and loaded through the same protocol. +External drivers are dropped into `/plugins///` or into a directory listed in `$AGMSG_PLUGIN_DIRS`, and are ignored until the user opts in with `agmsg plugin trust /`. Today only the `types` axis loads external drivers. Bundled and external drivers are discovered and loaded through the same protocol; see [`docs/plugins.md`](docs/plugins.md) and [ADR 0002](docs/adr/0002-driver-discovery-and-plugin-opt-in.md). agmsg does not use the terms *backend*, *extension*, *provider*, or *adapter*. `storage`, when written in user-facing documentation and CLI, is a synonym for "storage driver". @@ -47,7 +47,7 @@ Drivers may require external tools (e.g. the `jsonl-duckdb` storage driver needs This keeps agmsg itself minimal and platform-agnostic. The host agent is already trusted with file and shell access, so dependency installation is naturally its responsibility. -The same `AGMSG-DIRECTIVE` mechanism is used for other host-agent-coordinated actions (e.g. telling Claude Code to invoke its Monitor tool when delivery mode is set to `monitor`). +The same `AGMSG-DIRECTIVE` mechanism is used for other host-agent-coordinated actions (e.g. telling Claude Code to invoke its Monitor tool when delivery mode is set to monitor (legacy); a session that agmsgd delivers to directly needs no such directive). ## Failure semantics diff --git a/CONTRIBUTING.ja.md b/CONTRIBUTING.ja.md index b118aa1e9..ab89ace75 100644 --- a/CONTRIBUTING.ja.md +++ b/CONTRIBUTING.ja.md @@ -7,13 +7,13 @@ ## まず読むもの - agmsg が何をするものか、どうインストールするかは [`README.md`](README.md) を読むこと。 -- メンタルモデル(3軸ドライバ、用語、依存関係管理の考え方)は [`ARCHITECTURE.md`](ARCHITECTURE.md) を読むこと。 +- メンタルモデル(4軸ドライバ、用語、依存関係管理の考え方)は [`ARCHITECTURE.md`](ARCHITECTURE.md) を読むこと。 - 設計判断の経緯は [`docs/adr/`](docs/adr/) を眺めること — *なぜ*今の形になっているかが分かる。 - [`docs/spec/`](docs/spec/) ディレクトリには正式な契約(コントラクト)がある。ドライバを実装・拡張する際はこれを参照すること。 ## バグ報告と機能要望 -[`fujibee/agmsg`](https://github.com/fujibee/agmsg/issues) に issue を立てる。agmsg のバージョン、ホストエージェント(Claude Code / Codex / Gemini CLI / Antigravity)、可能なら最小の再現手順を含めること。 +[`fujibee/agmsg`](https://github.com/fujibee/agmsg/issues) に issue を立てる。agmsg のバージョン、ホストエージェント(Claude Code、Codex、Gemini CLI、Antigravity、Copilot CLI、OpenCode、Cursor、Grok Build、Devin、Hermes)、可能なら最小の再現手順を含めること。 ## プルリクエスト @@ -26,7 +26,7 @@ ## リリース -[`RELEASING.md`](RELEASING.md) を参照。手短に言うと: [`VERSION`](VERSION) を上げ、`./scripts/release/sync-version.sh` を実行し、コミットし、`v$(cat VERSION)` としてタグを打ち、push する。あとは CI がやってくれる。 +[`RELEASING.md`](RELEASING.md) を参照。手短に言うと: `scripts/release/cut-release.sh ` を実行するとリリース PR が用意される。PR とタグの push はそれぞれメンテナーの承認が要り、タグのあとに CI が公開する。 ## Architecture Decision Records @@ -58,7 +58,7 @@ agmsg は重要な設計判断を記録するために ADR([Architecture Decisio ## ドライバの追加 -バンドルされているドライバは `scripts/drivers//.sh` にある。(`scripts/` 直下の `.sh` ファイルは直接実行可能なコマンドであり、サブディレクトリは実装コードをまとめたもの。)サードパーティのプラグインドライバは `~/.agents/agmsg/plugins///` にある。どちらも [`docs/spec/driver-interface.md`](docs/spec/driver-interface.md) にある契約を実装する必要がある。 +バンドルされているドライバは `scripts/drivers//` 配下にある(storage は `.sh`、agent タイプと terminal はマニフェストを含む `/` ディレクトリ)。`scripts/` 直下の `.sh` ファイルは直接実行可能なコマンドであり、サブディレクトリは実装コードをまとめたもの。サードパーティのプラグインドライバは `/plugins///` か `$AGMSG_PLUGIN_DIRS` のディレクトリにあり、`agmsg plugin trust` したあとにだけ動く([`docs/plugins.md`](docs/plugins.md) 参照)。どちらも [`docs/spec/driver-interface.md`](docs/spec/driver-interface.md) にある契約を実装する必要がある。 新しいバンドルドライバには以下が揃っていることが望ましい: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a86a607b3..3bd2f5323 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,13 +7,13 @@ Thanks for considering a contribution! agmsg is a small project; this guide is i ## Where to start - Read [`README.md`](README.md) for what agmsg does and how to install it. -- Read [`ARCHITECTURE.md`](ARCHITECTURE.md) for the mental model (3-axis driver, vocabulary, dependency-management philosophy). +- Read [`ARCHITECTURE.md`](ARCHITECTURE.md) for the mental model (4-axis driver model, vocabulary, dependency-management philosophy). - Browse [`docs/adr/`](docs/adr/) for the history of design decisions — they explain *why* things are the way they are. - The [`docs/spec/`](docs/spec/) directory contains the formal contracts; consult these when implementing or extending a driver. ## Reporting bugs and requesting features -Open an issue on [`fujibee/agmsg`](https://github.com/fujibee/agmsg/issues). Include the agmsg version, the host agent (Claude Code / Codex / Gemini CLI / Antigravity), and a minimal reproduction if possible. +Open an issue on [`fujibee/agmsg`](https://github.com/fujibee/agmsg/issues). Include the agmsg version, the host agent (Claude Code, Codex, Gemini CLI, Antigravity, Copilot CLI, OpenCode, Cursor, Grok Build, Devin, or Hermes), and a minimal reproduction if possible. ## Pull requests @@ -26,7 +26,7 @@ Open an issue on [`fujibee/agmsg`](https://github.com/fujibee/agmsg/issues). Inc ## Releases -See [`RELEASING.md`](RELEASING.md). The short version: bump [`VERSION`](VERSION), run `./scripts/release/sync-version.sh`, commit, tag `v$(cat VERSION)`, push. CI handles the rest. +See [`RELEASING.md`](RELEASING.md). The short version: run `scripts/release/cut-release.sh `, which prepares the release PR; the PR and the tag push each need a maintainer's approval, and CI publishes after the tag. ## Architecture Decision Records @@ -58,7 +58,7 @@ If you spot a design choice in the code whose rationale is unclear, an ADR retro ## Adding a driver -Bundled drivers live under `scripts/drivers//.sh`. (Top-level `.sh` files in `scripts/` are directly-invokable commands; subdirectories group implementation code.) Third-party plugin drivers live under `~/.agents/agmsg/plugins///`. Both must implement the contract in [`docs/spec/driver-interface.md`](docs/spec/driver-interface.md). +Bundled drivers live under `scripts/drivers//` (a `.sh` file for storage, a `/` directory with its manifest for agent types and terminals). Top-level `.sh` files in `scripts/` are directly-invokable commands; subdirectories group implementation code. Third-party plugin drivers live under `/plugins///` or a directory in `$AGMSG_PLUGIN_DIRS`, and run only after `agmsg plugin trust` (see [`docs/plugins.md`](docs/plugins.md)). Both must implement the contract in [`docs/spec/driver-interface.md`](docs/spec/driver-interface.md). A new bundled driver should arrive with: diff --git a/README.md b/README.md index 7405b80b5..a39ce25fd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![release](https://img.shields.io/github/v/release/fujibee/agmsg?label=release)](https://github.com/fujibee/agmsg/releases/latest) [![license](https://img.shields.io/github/license/fujibee/agmsg)](LICENSE) -Cross-agent messaging for CLI AI agents. No daemon, no network, no complexity. +Cross-agent messaging for CLI AI agents. No daemon required, no network, no complexity. > **For AI agents:** see [`/llms.txt`](llms.txt) for a quick, machine-friendly orientation. @@ -34,7 +34,7 @@ You stop being the copy-paste courier between your agents. Claude Code, Codex, G ## Demo -Two `monitor`-mode Claude Code instances, left alone in the same team, play tic-tac-toe against each other with no human in the loop — each picks up the other's move in real time: +Two Claude Code instances in `monitor` (legacy) mode, left alone in the same team, play tic-tac-toe against each other with no human in the loop — each picks up the other's move in real time: ![Two Claude Code agents autonomously playing tic-tac-toe over agmsg](docs/agmsg-demo.gif) @@ -68,7 +68,7 @@ To sync a team between two installs through the self-hosted reference server, fo ## How it works -agmsg is a thin transport. Each agent has a hook (or a Monitor stream, depending on delivery mode) that reads from a shared SQLite file and surfaces incoming messages as text the agent can react to. Sending is a `send.sh` call that appends a row. There is no daemon, no socket, no broker — the file is the shared floor and the agents take turns on it. +agmsg is a thin transport. Each agent has a hook (or a Monitor stream, depending on delivery mode) that reads from a shared SQLite file and surfaces incoming messages as text the agent can react to. Sending is a `send.sh` call that appends a row. agmsg itself runs with no daemon, no socket and no broker — the file is the shared floor and the agents take turns on it. The optional [agmsgd](#agmsgd-beta-160) is a resident service that is installed only if you choose it. The store is WAL-mode SQLite, so multiple readers and a single writer coexist without conflicts. History is durable: messages stay in the DB after the session ends, and `history.sh` can replay an old room into a fresh agent. @@ -154,7 +154,7 @@ Git Bash PATH). There is no PowerShell reimplementation. ### agmsgd beta (1.6.0) -The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue, and sends Claude Code sessions a notice directly through that session's own cross-session messaging socket. It detects whether that Claude Code session is running in bypass-permissions mode and labels notices for that mode automatically, with no action from you. A Claude Code seat delivered this way never needs its Monitor watch re-armed. On Windows, Claude Code must be 2.1.302 or later to receive notices this way (2.1.198 has no endpoint for them). A Claude Code seat whose delivery destination cannot be confirmed, such as one on an older Claude Code, keeps using Monitor as before, even with agmsgd enabled and running. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, take over remote sync, or change how any agent other than Claude Code and Codex receives messages. macOS, Linux, and Windows are supported for Codex. Windows requires native Codex 0.157.0 or later; earlier versions are blocked with a reason. Native queue execution and delivery to a logged-in live Windows session were measured with Codex 0.157.0 and 0.160.0. Unreadable version or delivery evidence keeps the existing conservative handling. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. +The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue, and sends Claude Code sessions a notice directly through that session's own cross-session messaging socket. It detects whether that Claude Code session is running in bypass-permissions mode and labels notices for that mode automatically, with no action from you. A Claude Code seat delivered this way never needs its Monitor watch re-armed. On Windows, Claude Code must be 2.1.302 or later to receive notices this way (2.1.198 has no endpoint for them). A Claude Code seat whose delivery destination cannot be confirmed, such as one on an older Claude Code, keeps using Monitor as before, even with agmsgd enabled and running. It is offered as the recommended choice in the delivery-method screen and is turned on only if you pick it: install never asks, and `--update` never changes it. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, take over remote sync, or change how any agent other than Claude Code and Codex receives messages. macOS, Linux, and Windows are supported for Codex. Windows requires native Codex 0.157.0 or later; earlier versions are blocked with a reason. Native queue execution and delivery to a logged-in live Windows session were measured with Codex 0.157.0 and 0.160.0. Unreadable version or delivery evidence keeps the existing conservative handling. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. Requires Node >= 22.13.0 with `node:sqlite` (the experimental SQLite warning is expected). Install Node from [nodejs.org](https://nodejs.org/en/download). Enable and inspect the beta with: @@ -216,9 +216,9 @@ Where `actas` switches *this* session to a different role, `spawn` brings up a * `spawn ` pre-joins ``, then launches the target CLI with the actas slash command (`/ actas `, matching your install command name) as its initial prompt. If the current session is inside **tmux**, it opens in a new pane (or `--window` for a new window, `--split h|v` for the direction); otherwise it opens a new **OS terminal** window. -Pass `--boot-prompt ` to hand the new agent an initial task: the boot prompt becomes the actas slash command followed (newline-separated) by your text, so the agent claims its identity **and** acts on the task in the same first turn. This is the only way to give a one-shot goal to a **codex** peer, which has no Monitor and so never notices a message you `send` after it goes idle. +Pass `--boot-prompt ` to hand the new agent an initial task: the boot prompt becomes the actas slash command followed (newline-separated) by your text, so the agent claims its identity **and** acts on the task in the same first turn. This is the reliable way to give a one-shot goal to a **codex** peer: a Codex session that is not on monitor (legacy) or agmsgd delivery only looks at its inbox on its next turn, so it never notices a message you `send` after it goes idle. -By default `spawn` **blocks until the new agent is actually listening** — its watcher attaches and touches a readiness sentinel — then prints `status=ready`, so you can send work the moment `spawn` returns without losing it to the agent's cold start. Use `--no-wait` for fire-and-forget, or `--ready-timeout ` to bound the wait (default 90; on timeout it prints `status=timeout` and exits 3 so a caller can re-spawn). Codex skips the wait (it has no Monitor). +By default `spawn` **blocks until the new agent is actually listening** — its watcher attaches and touches a readiness sentinel — then prints `status=ready`, so you can send work the moment `spawn` returns without losing it to the agent's cold start. Use `--no-wait` for fire-and-forget, or `--ready-timeout ` to bound the wait (default 90; on timeout it prints `status=timeout` and exits 3 so a caller can re-spawn). Codex skips the wait: it has no spawn-time readiness handshake (this is separate from how it receives messages). Options: `--boot-prompt ` (initial task; see above), `--project ` (default: current project), `--team ` (auto-resolved when the project has a single team), and `--terminal ` / `$AGMSG_TERMINAL` / config `spawn.terminal` to override the terminal command on the non-tmux path (a `{cmd}` placeholder is replaced with the path to the generated boot script). On macOS the default opens whichever terminal you're currently in (iTerm or Terminal, via `$TERM_PROGRAM`) using `open -a` — a plain app launch, so it does **not** trigger the Automation/AppleScript permission prompts that scripting the terminal directly would. @@ -247,7 +247,7 @@ Eight of the ten agent types are spawnable — `claude-code`, `codex`, `grok-bui By default `despawn ` is **graceful**: it sends a `ctrl:despawn` control message to ``, whose watcher drops its own role (releasing the actas lock and registration) and closes its own tmux pane — ending the agent. It blocks until the role is released, up to `--timeout ` (default 30), then prints `status=ok`. If the member's watcher never responds it prints `status=timeout` and exits 3 — retry with `--force`. -`--force` skips the message and tears the member down from the placement recorded at spawn time: it kills the member's tmux pane/window and drops its registration. Use it when the member's watcher can't respond — a dead watcher, or a **codex** member (no Monitor, so graceful has nothing to act on). A member started by hand (no spawn placement record) can't be `--force`d; despawn says so and leaves it for you to close. +`--force` skips the message and tears the member down from the placement recorded at spawn time: it kills the member's tmux pane/window and drops its registration. Use it when the member's watcher can't respond — a dead watcher, or a **codex** member (it never holds an actas lock and runs no Claude Code watcher, so graceful has nothing to confirm). A member started by hand (no spawn placement record) can't be `--force`d; despawn says so and leaves it for you to close. Despawn only acts on the named member — the session running `despawn` is never torn down, and a broad-subscription watcher ignores a `ctrl:despawn` aimed at another role. @@ -334,7 +334,7 @@ The command updates `db/config.yaml`, rewrites the project's hook entries, and p /agmsg mode — show current mode /agmsg actas — switch to another role in this project (create if needed) /agmsg drop — remove a role from this project -/agmsg spawn — launch a new agent (claude-code/codex) that takes +/agmsg spawn — launch a new agent that takes (supported types: see "Spawn a new agent") /agmsg despawn [--force] — tear down a member you spawned (graceful, or --force) /agmsg hook on | off — legacy aliases (mode turn | off) /agmsg version — show the installed version (git-describe provenance) @@ -349,7 +349,7 @@ $agmsg — or /skills → agmsg Codex supports `mode monitor` through an app-server bridge, plus `mode turn` and `mode off`. -> ⚠️ **Monitor mode changes how Codex starts — enable it knowing that.** Codex has no Monitor tool, so `mode monitor` prints a shell function that makes `codex` route through agmsg's monitor shim in your interactive shell. In monitor-mode projects the shim routes interactive launches through a bridge that turns incoming agmsg messages into turns on the current Codex thread; `codex exec` and non-monitor projects pass straight through to the real Codex. It depends on Codex app-server behavior and has a known limitation (orphans on TUI close — #149). +> ⚠️ **Monitor mode changes how Codex starts — enable it knowing that.** Codex has no Claude Code Monitor tool, so monitor (legacy) mode (`mode monitor`) prints a shell function that makes `codex` route through agmsg's monitor shim in your interactive shell. In monitor-mode projects the shim routes interactive launches through a bridge that turns incoming agmsg messages into turns on the current Codex thread; `codex exec` and non-monitor projects pass straight through to the real Codex. It depends on Codex app-server behavior and has a known limitation (orphans on TUI close — #149). If you prefer a global PATH shim, run `~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` before the real Codex binary on PATH. You can also launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). @@ -387,7 +387,7 @@ See [docs/opencode.md](docs/opencode.md) for full setup instructions. ~/.agents/skills//scripts/reset.sh [agent_id] ``` -`team.sh` combines the roster with terminal placement, activity, delivery mode, and identity consistency. Verified identity is collapsed to `identity=ok`; mismatches and values that could not be observed are expanded with their evidence. `--json` emits every field for every registration. The repair flags report each action per identity cell as `changed`, `skipped`, `failed`, or (for a session name that could not be read back) `poked_unverified`. They are two different kinds of act: `--fix-pane-names` repairs the pane label and agent key through the terminal's own API and never types into a session; `--rename-sessions` repairs the CLI session name by typing the type's rename command (`/rename -` for Claude Code, whatever the type's manifest declares otherwise) into the pane, and only after positively identifying a ready process there. `--fix` does both, unconditionally, including the keystroke. Pane liveness will join this view when the pane-state contract lands; until then the unavailable column is omitted rather than filled with `unknown`. +`team.sh` combines the roster with terminal placement, activity, delivery mode, and identity consistency. Verified identity is collapsed to `identity=ok`; mismatches and values that could not be observed are expanded with their evidence. `--json` emits every field for every registration. `team.sh` is read-only with respect to other seats: it reports an inconsistent or unresponsive seat but never repairs it from outside (the former `--fix`, `--fix-pane-names` and `--rename-sessions` flags were removed in #1152). A seat repairs its own identity cells by running `fix` from itself; one that cannot is despawned and restarted, or taken over by a person. Pane liveness will join this view when the pane-state contract lands; until then the unavailable column is omitted rather than filled with `unknown`. `--delete` removes a team once every member has left (`leave.sh` each remaining one first) and the team is not actively synced (`binding_state=active` in `team-list.sh --json` refuses; `disconnected` proceeds with an extra warning) — its config, roster, identity history, and per-agent runtime state (actas locks, role-session records, spawn/placement records), but not its message history. `--force` (combined with `--delete`) removes every remaining member first, the same effect as running `leave.sh` for each, then deletes the team as `--delete` alone does — a team the desktop app created always has its app-user as a member, so it could never be deleted from the app without this; a `--force` confirmation names the members it will remove. `--purge-messages` deletes only a team's rows from the sqlite message store (jsonl is not supported yet) and can be used standalone or together with `--delete`; other teams' data is untouched. Both prompt for confirmation, in the same style as `uninstall.sh`'s, unless `--yes` is given. @@ -401,7 +401,7 @@ Terminal identity has a different number of observable names on each backend. He **Is this MCP? Do I need an MCP server?** -No. agmsg is standalone — `bash` + `sqlite3`, no server, no daemon, no network. The two stacks are orthogonal: you can run agmsg alongside any MCP setup you already have. +No. agmsg is standalone — `bash` + `sqlite3`, no server, no network, and no daemon unless you choose the optional agmsgd. The two stacks are orthogonal: you can run agmsg alongside any MCP setup you already have. **Concurrent writes to the same channel — do they conflict?** @@ -555,6 +555,7 @@ sandbox_mode = "workspace-write" writable_roots = [ "~/.agents/skills/agmsg/db", "~/.agents/skills/agmsg/teams", + "~/.agents/skills/agmsg/run", ] ``` @@ -565,6 +566,7 @@ If you installed agmsg under a custom command name, adjust the path accordingly: writable_roots = [ "~/.agents/skills/m/db", "~/.agents/skills/m/teams", + "~/.agents/skills/m/run", ] ``` @@ -577,9 +579,10 @@ writable_roots = [ ] ``` -Codex only supports `mode turn` and `mode off`; it does not have Claude Code's -Monitor tool. The sandbox allowlist is still required for writes performed by -manual `$agmsg` commands and turn-end inbox checks. +Codex supports `mode monitor` (legacy) through the app-server bridge, plus `mode turn` +and `mode off`; it just has no Claude Code Monitor tool. The sandbox allowlist is +required for writes performed by manual `$agmsg` commands, turn-end inbox checks and +the bridge. Some Codex runtimes or automations may inject a managed permission profile for a single run. In that case, the run-specific writable roots must also include the @@ -603,6 +606,7 @@ bats tests/ # requires bats-core: brew install bats-core │ └── drivers/types// # Built-in agent-type drivers (manifest + runtime) ├── plugins/// # External drivers you opt into (agmsg plugin trust) ├── db/messages.db # SQLite WAL-mode message store +├── run/ # Runtime state: pidfiles, actas locks, agmsgd install record └── teams/ # Team configs (self-contained) └── / └── config.json @@ -611,8 +615,8 @@ bats tests/ # requires bats-core: brew install bats-core - **Storage**: Single SQLite file with WAL mode - **Concurrency**: Multiple readers + 1 writer, no conflicts - **Dependencies**: `bash`, `sqlite3` (no Python required) -- **Auto detection**: Stop hook checks inbox after each response (60s cooldown, configurable via `hook.check_interval`) -- **No daemon**: Direct filesystem access +- **Auto detection**: Stop hook checks inbox after each response (60s cooldown, configurable via `delivery.turn.check_interval`) +- **No daemon required**: direct filesystem access (the optional agmsgd is a resident service you choose) - **No network**: Everything local ## Plugins diff --git a/RELEASING.md b/RELEASING.md index c6770fa42..00a11970d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -28,7 +28,7 @@ stays behind a person. The script prints the remaining commands when it exits. (This paragraph used to say it auto-merged and tagged. It never did.) **Which branch:** whichever one you are on. The PR targets that same branch, so -a prerelease cut from `integration/remote` opens against `integration/remote`. +a prerelease cut from an integration branch (`integration/`) opens against that branch. Releases from a side branch are a real case — 1.2.0-rc.1 through rc.3 were all cut that way — and the script used to refuse them outright (#679). @@ -64,11 +64,11 @@ which: ### Manual steps (if you'd rather not use the script) The base is a variable here for the same reason it is one in the script: an rc -is cut from `integration/remote`, and a copy-paste that says `main` puts the +is cut from an `integration/` branch, and a copy-paste that says `main` puts the release commit on the wrong branch. ```bash -BASE=main # integration/remote for a prerelease +BASE=main # integration/ for a prerelease VER=1.0.4 # 1.2.0-rc.4 for a prerelease # On an up-to-date $BASE, on a release branch: @@ -167,10 +167,10 @@ The pipeline layers four defenses against silent drift and malicious publish: and linked back to this workflow run. A tarball without provenance — or with provenance pointing elsewhere — is distinguishable on npmjs.com. - **`verify-versions.yml`.** Runs `sync-version.sh --check` on every push and - PR to `main` **and `integration/remote`**. A hand-edit of `package.json` or + PR to `main` **and `integration/**`**. A hand-edit of `package.json` or `plugin.json` without a `VERSION` bump fails CI before merge. It was `main`-only until #679, which meant it never ran on any of the three - prereleases cut from `integration/remote` — `release.yml` runs the same check + prereleases cut from an integration branch — `release.yml` runs the same check at tag time, so nothing shipped out of sync, but the failure would have surfaced at publish rather than at review. diff --git a/SKILL.md b/SKILL.md index fafbc439b..cc2eddd97 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: agmsg -description: Cross-agent messaging via SQLite. Send messages between Claude Code, Codex, Gemini CLI, and other agents. No daemon, no network, no dependencies beyond bash and sqlite3. +description: Cross-agent messaging via SQLite. Send messages between Claude Code, Codex, Gemini CLI, and other agents. No network, no dependencies beyond bash and sqlite3, and no daemon required (the optional agmsgd is a local resident service). --- @@ -188,7 +188,7 @@ If argument is "config": 1. Run: `~/.agents/skills/agmsg/scripts/config.sh show` 2. Show the output to the user. -If argument starts with "config set" (e.g. "config set hook.check_interval 30"): +If argument starts with "config set" (e.g. "config set delivery.turn.check_interval 30"): 1. Parse key and value from the arguments. 2. Run: `~/.agents/skills/agmsg/scripts/config.sh set ` @@ -258,7 +258,7 @@ If argument starts with "spawn" (e.g. "spawn codex reviewer", "spawn claude-code 1. Parse `` (a spawnable agent type), ``, and any options (`--boot-prompt `, `--project `, `--team `, `--window`, `--split h|v`, `--terminal