Compare commits
10
Commits
123dca47bb
...
f4ba205c9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ba205c9f | ||
|
|
087eacaf21 | ||
|
|
4805fd4511 | ||
|
|
1667fda8dd | ||
|
|
f7cee00dec | ||
|
|
10a678c010 | ||
|
|
0dfbb2cc8a | ||
|
|
ce802d21c8 | ||
|
|
33b91fe0c8 | ||
|
|
2cd20423ac |
No files matched your search
+191
-45
@@ -71,7 +71,6 @@ done
|
||||
_MAM_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?'
|
||||
_MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue'
|
||||
_MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}"
|
||||
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
|
||||
|
||||
# Workspace-relative defaults with environment overrides (Phase Z)
|
||||
HOME_DIR="${HOME_DIR:-$HOME}"
|
||||
@@ -1385,19 +1384,62 @@ EOF
|
||||
# live session as absent: has-session, kill-session, agent prompt and the
|
||||
# delegate-job preflight all answer "no such session".
|
||||
#
|
||||
# _herdr_caller_server — the name of the server whose pane this process runs
|
||||
# in; empty when it is not in a pane at all.
|
||||
#
|
||||
# HERDR_SESSION is used when present, but herdr 0.8.2 does not export it to a
|
||||
# pane of the *default* server (the orchestrator's usual home), which is
|
||||
# exactly the pane the #23 leak comes from - the live trial of this branch
|
||||
# found every call judged "not crossing" for that reason. HERDR_SOCKET_PATH is
|
||||
# exported to every pane, and its layout names the server:
|
||||
#
|
||||
# $HOME/.config/herdr/herdr.sock -> default
|
||||
# $HOME/.config/herdr/sessions/<name>/herdr.sock -> <name>
|
||||
#
|
||||
# (the same convention lib.sh's shim already relies on to find a named
|
||||
# server's socket). Matched by shape, not by $HOME, so a redirected HOME does
|
||||
# not break it. An unrecognised layout yields "" - treated as "not in a pane",
|
||||
# which keeps today's behaviour rather than guessing.
|
||||
_herdr_caller_server() {
|
||||
if [ -n "${HERDR_SESSION:-}" ]; then
|
||||
printf '%s\n' "$HERDR_SESSION"
|
||||
return 0
|
||||
fi
|
||||
local sock="${HERDR_SOCKET_PATH:-}"
|
||||
case "$sock" in
|
||||
*/.config/herdr/herdr.sock)
|
||||
printf 'default\n'
|
||||
;;
|
||||
*/.config/herdr/sessions/*/herdr.sock)
|
||||
local d="${sock%/herdr.sock}"
|
||||
printf '%s\n' "${d##*/}"
|
||||
;;
|
||||
*)
|
||||
printf '\n'
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# True only for a call aimed at a *named* server other than the caller's own:
|
||||
#
|
||||
# * target unset or `default` (the lib.sh:97 fallback) is the shim's sentinel
|
||||
# for "pass no session flag", so herdr answers on the ambient server, which
|
||||
# for a caller inside a pane is that same caller's server - not a crossing;
|
||||
# * no HERDR_SESSION means the caller is not in a pane at all, so an exported
|
||||
# HERDR_WORKSPACE_ID is a deliberate scope, not a leak (H-18/H-21/H-23);
|
||||
# * no caller server (_herdr_caller_server empty) means the caller is not in
|
||||
# a pane at all, so an exported HERDR_WORKSPACE_ID is a deliberate scope,
|
||||
# not a leak (H-18/H-21/H-23);
|
||||
# * caller == target is the same server.
|
||||
#
|
||||
# A caller in the default server's pane targeting a named server IS a crossing
|
||||
# (the reported #23 scenario): `default` is a real server name on the caller's
|
||||
# side, only as a *target* is it the "ambient" sentinel.
|
||||
_herdr_call_is_cross_server() {
|
||||
local caller="${HERDR_SESSION:-}" target="${HERDR_SESSION_NAME:-}"
|
||||
local caller target="${HERDR_SESSION_NAME:-}"
|
||||
case "$target" in
|
||||
""|default) return 1 ;;
|
||||
esac
|
||||
caller="$(_herdr_caller_server)"
|
||||
if [ -n "$caller" ] && [ "$caller" != "$target" ]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -1413,7 +1455,7 @@ mam_herdr() {
|
||||
if _herdr_call_is_cross_server; then
|
||||
_scope_strip=(env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID)
|
||||
if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then
|
||||
echo "mam_herdr: dropping caller pane scope ids (caller server '${HERDR_SESSION:-}' != target '${HERDR_SESSION_NAME:-}')" >&2
|
||||
echo "mam_herdr: dropping caller pane scope ids (caller server '$(_herdr_caller_server)' != target '${HERDR_SESSION_NAME:-}')" >&2
|
||||
fi
|
||||
fi
|
||||
${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
|
||||
@@ -2131,46 +2173,136 @@ delegate_publish_event() {
|
||||
_delegation_py publish --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
|
||||
}
|
||||
|
||||
# delegate_publish_cmd <job_id> [dispatch_token] [attempt_id]
|
||||
# The publish command an agent is told to run, shell-quoted, with absolute
|
||||
# script/registry/audit-log paths (Issue #21: an agent whose cwd is a worktree
|
||||
# runs it as written). create_session's onboarding uses this; the delegate-job
|
||||
# wrapper still builds its own (_pub_cmd), and S7l pins the two equal
|
||||
# argument-for-argument, so both hand the agent the same shape:
|
||||
# `--expect-dispatch-token T --attempt-id A` make publish_event write
|
||||
# data.dispatch_token / data.attempt_id into the agent's own events, which is
|
||||
# the only way a `started` can name the dispatch it answers (the trial-2
|
||||
# finding: create_session's hand-rolled command omitted them, the agent's
|
||||
# `started` carried no token, and the token-checking waiter could never be
|
||||
# satisfied by a real agent). publish_event also CAS-checks that token against
|
||||
# the record, so a stale dispatch's event is refused at the source.
|
||||
delegate_publish_cmd() {
|
||||
local job_id="$1" tok="${2:-}" att="${3:-}"
|
||||
local py_bin pub_script pub_reg pub_logs
|
||||
py_bin="$(_delegate_py_bin)"
|
||||
pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py"
|
||||
[ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)"
|
||||
pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")"
|
||||
pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}"
|
||||
pub_reg="${pub_reg:-.mam/jobs}"
|
||||
mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)"
|
||||
pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)"
|
||||
local -a argv=("$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$job_id")
|
||||
[ -n "$tok" ] && argv+=(--expect-dispatch-token "$tok")
|
||||
[ -n "$att" ] && argv+=(--attempt-id "$att")
|
||||
local out
|
||||
out="$(
|
||||
if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi
|
||||
printf '%q ' "${argv[@]}"
|
||||
)"
|
||||
printf '%s' "${out% }"
|
||||
}
|
||||
|
||||
# delegate_job_status <job_id> — the record's status, empty if unreadable.
|
||||
delegate_job_status() {
|
||||
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
||||
REC="$reg_dir/$1.json" python3 -c '
|
||||
import json, os
|
||||
try:
|
||||
print(json.load(open(os.environ["REC"])).get("status", "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' 2>/dev/null || true
|
||||
}
|
||||
|
||||
# delegate_started_recorded <job_id> [dispatch_token] [logs_dir]
|
||||
# THE definition of "this dispatch's started is on disk", shared by
|
||||
# delegate_wait_started (create_session's onboarding ack) and the delegate-job
|
||||
# wrapper's _wait_started. Two sources, one rule:
|
||||
#
|
||||
# * <registry>/<job>.events.log - the local publisher's own line, whose
|
||||
# data.dispatch_token must equal <dispatch_token> when one is given (a loop
|
||||
# job keeps one events.log across attempts, and a stale attempt's started
|
||||
# must not vouch for this one - review R1);
|
||||
# * <logs_dir>/<job>/events.ndjson - the subscriber's audit record of a
|
||||
# `started` it received, the only evidence a REMOTE agent (no shared disk)
|
||||
# can leave. The subscriber verifies the HMAC and drops any event whose
|
||||
# data.dispatch_token differs from the job's expected token before it
|
||||
# writes this record (dispatch_verified), and writes dispatch_fingerprint
|
||||
# = SHA-256 of that token. The file is appended to across a job's
|
||||
# attempts, so the flag alone would let an earlier attempt's record
|
||||
# acknowledge a later dispatch (review round 4): with <dispatch_token>
|
||||
# given, a record counts only if it is dispatch_verified AND its
|
||||
# fingerprint equals SHA-256(<dispatch_token>). A record with no
|
||||
# fingerprint (written before this field existed) names no dispatch and
|
||||
# is rejected on that path - fail closed. Without a token, any received
|
||||
# started counts, as before.
|
||||
#
|
||||
# Never the registry status: a record leaves `pending` when another writer
|
||||
# closes it or another dispatch claims it. Lines are parsed as JSON, never
|
||||
# pattern-matched. rc 0 = recorded, 1 = not (yet).
|
||||
delegate_started_recorded() {
|
||||
local job_id="$1" tok="${2:-}" logs_dir="${3:-}"
|
||||
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
||||
[ -n "$logs_dir" ] || logs_dir="${DELEGATE_JOB_LOGS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/delegate_job_logs}"
|
||||
local ev_log="$reg_dir/$job_id.events.log"
|
||||
local audit="$logs_dir/$job_id/events.ndjson"
|
||||
[ -f "$ev_log" ] || [ -f "$audit" ] || return 1
|
||||
EV_LOG="$ev_log" EV_AUDIT="$audit" EV_TOK="$tok" python3 -c '
|
||||
import hashlib, json, os, sys
|
||||
tok = os.environ.get("EV_TOK", "")
|
||||
fp = hashlib.sha256(tok.encode("utf-8")).hexdigest() if tok else ""
|
||||
|
||||
def lines(path):
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(e, dict):
|
||||
yield e
|
||||
|
||||
for e in lines(os.environ["EV_LOG"]):
|
||||
if e.get("event") != "started":
|
||||
continue
|
||||
data = e.get("data") if isinstance(e.get("data"), dict) else {}
|
||||
if not tok or data.get("dispatch_token") == tok:
|
||||
sys.exit(0)
|
||||
for e in lines(os.environ["EV_AUDIT"]):
|
||||
if e.get("event") != "received" or e.get("source_event") != "started":
|
||||
continue
|
||||
if not tok:
|
||||
sys.exit(0)
|
||||
if e.get("dispatch_verified") is True and e.get("dispatch_fingerprint") == fp:
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
# delegate_wait_started <job_id> [ack_timeout_s] [dispatch_token]
|
||||
#
|
||||
# ISSUE-23 / ISSUE-18: delivery is confirmed by the protocol, not by the
|
||||
# screen. `agent prompt --wait --until working` failing two seconds after the
|
||||
# keys were handed over says nothing about a TUI that is still starting up -
|
||||
# that is exactly what made create_session --onboard call a healthy agent a
|
||||
# failure. The evidence is the agent's own `started`: the registry record
|
||||
# leaves `pending` (publish_event syncs it on this host) or a matching started
|
||||
# line lands in the job's events.log, which publish_event writes before its
|
||||
# wire publish so a slow broker cannot delay the verdict.
|
||||
#
|
||||
# Returns 0 on that evidence, 1 if neither appeared within T. Never re-injects:
|
||||
# the single-injection contract is fail-closed.
|
||||
# screen. The evidence is delegate_started_recorded (above); the registry's
|
||||
# status is deliberately NOT evidence - a record leaves `pending` when another
|
||||
# writer closes it or another dispatch claims it (review R1). The local
|
||||
# publisher writes the log line before it syncs the status, so nothing that
|
||||
# really started is missed. Returns 0 on the evidence, 1 if it did not appear
|
||||
# within T. Never re-injects: the single-injection contract is fail-closed.
|
||||
delegate_wait_started() {
|
||||
local job_id="$1" ack_s="${2:-180}" tok="${3:-}"
|
||||
[ -n "$job_id" ] || return 1
|
||||
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
||||
local rec="$reg_dir/$job_id.json"
|
||||
local ev_log="$reg_dir/$job_id.events.log"
|
||||
local started_re='"event": *"started"'
|
||||
if [ -n "$tok" ]; then
|
||||
started_re='"event": *"started".*"dispatch_token": *"'"$tok"'"'
|
||||
fi
|
||||
local deadline st
|
||||
local deadline
|
||||
deadline=$(( $(date +%s) + ack_s ))
|
||||
while :; do
|
||||
st=""
|
||||
if [ -f "$rec" ]; then
|
||||
st=$(REC="$rec" python3 -c "
|
||||
import json, os
|
||||
try:
|
||||
print(json.load(open(os.environ['REC'])).get('status', '') or '')
|
||||
except Exception:
|
||||
print('')
|
||||
" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -n "$st" ] && [ "$st" != "pending" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$ev_log" ] && grep -Eq "$started_re" "$ev_log" 2>/dev/null; then
|
||||
if delegate_started_recorded "$job_id" "$tok"; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
@@ -2752,21 +2884,35 @@ send_keys_safe() {
|
||||
}
|
||||
|
||||
# _mam_load_dialog_facts <agent>
|
||||
# Populate MAM_STARTUP_DIALOGS for <agent>, once per agent per shell. Only that
|
||||
# one fact is taken: `facts` is an eval contract (F8), and evaluating all of it
|
||||
# here would clobber ready/placeholder tokens a caller deliberately set.
|
||||
# An agent the adapters do not know keeps an empty list.
|
||||
# Populate MAM_STARTUP_DIALOGS for <agent>, once per agent per shell, and the
|
||||
# ready tokens (MAM_STRONG_READY_TOKENS / MAM_READY_TOKENS) only when the caller
|
||||
# has not set them. `facts` is an eval contract (F8); evaluating all of it here
|
||||
# would clobber tokens a caller deliberately set, so only these are taken, and
|
||||
# the tokens only fill a gap. Without them the handler could never recognise a
|
||||
# ready screen when called on its own - resume_session.sh does exactly that,
|
||||
# with no wait_for_tui_ready before it - and idled through its whole timeout on
|
||||
# every resume (review B-1). An agent the adapters do not know keeps an empty
|
||||
# list and no tokens.
|
||||
_mam_load_dialog_facts() {
|
||||
local agent="$1" _f=""
|
||||
local agent="$1" _f="" _d="" _s="" _r=""
|
||||
if [ "${_MAM_DIALOGS_AGENT:-}" = "$agent" ]; then
|
||||
return 0
|
||||
fi
|
||||
_f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null | sed -n 's/^MAM_STARTUP_DIALOGS=//p')" || _f=""
|
||||
if [ -n "$_f" ]; then
|
||||
eval "MAM_STARTUP_DIALOGS=$_f"
|
||||
_f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _f=""
|
||||
_d="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STARTUP_DIALOGS=//p')"
|
||||
_s="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STRONG_READY_TOKENS=//p')"
|
||||
_r="$(printf '%s\n' "$_f" | sed -n 's/^MAM_READY_TOKENS=//p')"
|
||||
if [ -n "$_d" ]; then
|
||||
eval "MAM_STARTUP_DIALOGS=$_d"
|
||||
else
|
||||
MAM_STARTUP_DIALOGS='[]'
|
||||
fi
|
||||
if [ -z "${MAM_STRONG_READY_TOKENS:-}" ] && [ -n "$_s" ]; then
|
||||
eval "MAM_STRONG_READY_TOKENS=$_s"
|
||||
fi
|
||||
if [ -z "${MAM_READY_TOKENS:-}" ] && [ -n "$_r" ]; then
|
||||
eval "MAM_READY_TOKENS=$_r"
|
||||
fi
|
||||
_MAM_DIALOGS_AGENT="$agent"
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ Exit codes:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
@@ -279,6 +280,19 @@ class _Watcher:
|
||||
# that survives defensive parsing is recorded here, including ones a
|
||||
# different host published. This is the external-observer record that
|
||||
# backstops the publisher's own "published" line if it never wrote one.
|
||||
# Which dispatch this record vouches for. `dispatch_verified` says the
|
||||
# token check above ran (the record had a dispatch_token and the
|
||||
# event's data.dispatch_token matched it) after HMAC verification; on
|
||||
# its own it names no dispatch, and this file is appended to across a
|
||||
# job's attempts, so a reader waiting for a later dispatch would take
|
||||
# an earlier attempt's record as its acknowledgement (review round 4).
|
||||
# `dispatch_fingerprint` is SHA-256 of the token that was verified -
|
||||
# the same identity the local path compares in events.log, minus the
|
||||
# token itself, which the audit redactor masks by key name and which a
|
||||
# 128-bit uuid4 nonce is not recoverable from. `attempt_id` is kept for
|
||||
# readers; it is not the identity, since a token can be re-minted
|
||||
# within an attempt. A reader that demands a token must match the
|
||||
# fingerprint; a record without one proves nothing about identity.
|
||||
mqtt_common.append_event(jid, {
|
||||
"event": "received",
|
||||
"source_event": payload.get("event"),
|
||||
@@ -286,6 +300,11 @@ class _Watcher:
|
||||
"topic": msg.topic,
|
||||
"timestamp": payload.get("timestamp"),
|
||||
"detail": payload.get("detail", ""),
|
||||
"dispatch_verified": expected_disp_tok is not None,
|
||||
"dispatch_fingerprint": (hashlib.sha256(expected_disp_tok.encode("utf-8")).hexdigest()
|
||||
if expected_disp_tok else None),
|
||||
"attempt_id": (data.get("attempt_id") if isinstance(data, dict) and data.get("attempt_id") is not None
|
||||
else self.attempts.get(jid)),
|
||||
})
|
||||
self.events.put((msg.topic, payload))
|
||||
|
||||
|
||||
@@ -47,15 +47,42 @@ BACKOFF_MAX_S = 16.0
|
||||
CALLER_SCOPE_VARS = ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID")
|
||||
|
||||
|
||||
def caller_server(environ=None):
|
||||
"""The server whose pane this process runs in, "" when not in a pane.
|
||||
|
||||
HERDR_SESSION when present; otherwise the shape of HERDR_SOCKET_PATH, which
|
||||
herdr exports to every pane (HERDR_SESSION is absent in a default-server
|
||||
pane on herdr 0.8.2 - the orchestrator's usual home, and where the #23 leak
|
||||
comes from). Same convention as lib.sh's _herdr_caller_server:
|
||||
|
||||
.../.config/herdr/herdr.sock -> "default"
|
||||
.../.config/herdr/sessions/<name>/herdr.sock -> <name>
|
||||
|
||||
Matched by shape, not by $HOME. Anything else is "" (not in a pane).
|
||||
"""
|
||||
src = os.environ if environ is None else environ
|
||||
sess = (src.get("HERDR_SESSION") or "").strip()
|
||||
if sess:
|
||||
return sess
|
||||
sock = (src.get("HERDR_SOCKET_PATH") or "").strip()
|
||||
parts = sock.split("/")
|
||||
if len(parts) >= 4 and parts[-1] == "herdr.sock":
|
||||
if parts[-3:-1] == [".config", "herdr"]:
|
||||
return "default"
|
||||
if len(parts) >= 5 and parts[-4:-2] == ["herdr", "sessions"] and parts[-5] == ".config":
|
||||
return parts[-2]
|
||||
return ""
|
||||
|
||||
|
||||
def scoped_env(session=None, environ=None):
|
||||
"""The environment for a wait aimed at `session`.
|
||||
|
||||
Unchanged unless this process runs inside a pane of another server: only
|
||||
then are the caller's own workspace/pane/tab ids meaningless for the call
|
||||
and dropped. No pane (no HERDR_SESSION) and same-server keep every value.
|
||||
and dropped. No pane and same-server keep every value.
|
||||
"""
|
||||
src = os.environ if environ is None else environ
|
||||
caller = (src.get("HERDR_SESSION") or "").strip()
|
||||
caller = caller_server(src)
|
||||
target = (session or "").strip()
|
||||
# `default` is the same sentinel build_argv reads: no --session flag, so
|
||||
# herdr answers on the ambient server - the caller's own.
|
||||
|
||||
@@ -60,7 +60,8 @@ If any check fails → abort with a non-zero exit and report the reason (automat
|
||||
- examples: `landing-page-refer-landing-page-creator-claude`, `paper-pdf2md-creator-agy`
|
||||
- never re-derive this by hand — source lib.sh and call the function
|
||||
- **wrapper script** (claude only): `~/.local/bin/<workspace-slug>-creator-claude`
|
||||
- contents: herdr new-session with `claude` inside, auto-handles trust/bypass dialogs
|
||||
- contents: herdr new-session with `claude` inside; trust/bypass dialogs are
|
||||
handled by the skill for every agent (see `--no-auto-trust` below)
|
||||
- see `<workdir>/agent_sessions.md` for the canonical wrapper template
|
||||
|
||||
## Herdr Session Isolation (격리 세션)
|
||||
@@ -97,6 +98,38 @@ The bootstrapped server receives a **sanitized copy of the caller's environment*
|
||||
bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --onboard
|
||||
```
|
||||
|
||||
**Delivery is judged by the agent's own `started` event** (#23), within the
|
||||
job's `ack_timeout_sec` (default 180 s) — not by what the pane looked like two
|
||||
seconds after the keys were sent. The script no longer publishes `started` on
|
||||
the agent's behalf, so a job that shows `started` really was picked up. A
|
||||
send_keys rc 4 ("Enter not seen accepted", where a still-starting TUI lands) is
|
||||
a warning; rc 1/2/3 still abort at once. No `started` within the ack timeout
|
||||
marks the job `error` and warns, but **keeps the session**: the pane is up and
|
||||
the row is valid, only the handshake never landed, and killing a live agent
|
||||
over a slow onboarding would destroy more than it protects (the same rule
|
||||
this script applies to a TUI that never signalled ready). Re-send with
|
||||
`multi-agent-mux-delegate-job submit` once the agent is up; the prompt is
|
||||
never re-injected automatically. `MAM_ONBOARD_ACK_S` overrides how long that
|
||||
wait is — distinct from the wrapper's `MAM_INJECT_ACK_GRACE_S`.
|
||||
|
||||
**Start-up dialogs** are answered for every agent, not only claude (#24). The
|
||||
blocking-modal signatures and their accept keys live in each agent adapter
|
||||
(`lib_py/agents/adapters/*.py`, published as `MAM_STARTUP_DIALOGS`), and they
|
||||
are answered while waiting for the TUI rather than after it. `--no-auto-trust`
|
||||
leaves directory-trust dialogs for a human to accept and still answers other
|
||||
blocking notices (for example codex's update prompt, where Skip changes
|
||||
nothing). Trust matters most for **codex**: trusting a directory makes it load
|
||||
that project's local config, hooks and exec policy, so `--no-auto-trust` is the
|
||||
switch to reach for when the workspace is not yours. Re-sending is rate-limited
|
||||
by `MAM_DIALOG_RESEND_S` (default 5 s) because a `--source visible` snapshot
|
||||
keeps showing a dialog for a while after it was accepted.
|
||||
|
||||
**Exit codes**: 42 = the TUI never became ready and the process is gone;
|
||||
43 = the rollback could not kill the herdr session and it is still running.
|
||||
On 43 the message names the session and the manual cleanup command; the YAML
|
||||
row keeps `status: running` with `last_visible_status: rollback-failed` and no
|
||||
`delegate_job_id`, and the onboarding job is closed as `cancelled`.
|
||||
|
||||
Why use `--herdr-session`?
|
||||
|
||||
- By default, all skills target `default` herdr session socket — fine for single-workspace use.
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
# 2 = invalid args
|
||||
# 3 = herdr session already exists (use multi-agent-mux-resume or delete first)
|
||||
# 4 = agent-sessions.yaml append failure
|
||||
# 42 = agent TUI never became ready and the process is gone
|
||||
# 43 = rollback could not kill the herdr session; it is still running (#23)
|
||||
set -euo pipefail
|
||||
|
||||
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -329,8 +331,12 @@ cleanup_herdr_on_error() {
|
||||
# Release the onboarding job: `cancelled` is a terminal transition the
|
||||
# registry knows, and it says what happened - the job was never really
|
||||
# dispatched. Best effort; a failure here must not stop the rollback.
|
||||
if [ -n "${DELEGATE_JOB_ID:-}" ]; then
|
||||
# `completed`/`error`/`cancelled` are terminal and published exactly once, so
|
||||
# a job this run already closed is left alone - a second one is refused by
|
||||
# the registry anyway ("conflicting cancelled after terminal error").
|
||||
if [ -n "${DELEGATE_JOB_ID:-}" ] && [ "${JOB_TERMINALIZED:-0}" != "1" ]; then
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "rolled back by create_session" "${DELEGATE_JOB_TOKEN:-}" "${DELEGATE_JOB_ATTEMPT:-}" || true
|
||||
JOB_TERMINALIZED=1
|
||||
fi
|
||||
|
||||
if [ "$alive" = "1" ]; then
|
||||
@@ -608,22 +614,12 @@ echo "herdr session: $SESSION_NAME (pane pid $PANE_PID, cmd $PANE_CMD, cwd $PANE
|
||||
if [ -n "$DELEGATE_JOB_ID" ]; then
|
||||
echo "delegate job: $DELEGATE_JOB_ID"
|
||||
|
||||
# Construct instructions matching multi-agent-mux-delegate-job format
|
||||
py_bin="$(_delegate_py_bin)"
|
||||
# Issue #21: absolute script, registry and audit-log paths (shell-quoted), so
|
||||
# an agent whose cwd is a git worktree can run the command as written.
|
||||
pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py"
|
||||
[ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)"
|
||||
pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")"
|
||||
pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}"
|
||||
pub_reg="${pub_reg:-.mam/jobs}"
|
||||
mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)"
|
||||
pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)"
|
||||
pub="$(
|
||||
if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi
|
||||
printf '%q ' "$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$DELEGATE_JOB_ID"
|
||||
)"
|
||||
pub="${pub% }"
|
||||
# The publish command the agent will run: the same shape the delegate-job
|
||||
# wrapper hands its agents, from the one builder in lib.sh. The dispatch
|
||||
# token and attempt id are what make the agent's own `started` name THIS
|
||||
# dispatch (trial-2 finding: without them the token-checking ack below could
|
||||
# never be satisfied by a real agent, and every onboarding sat out 180 s).
|
||||
pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")"
|
||||
instructions="Your job_id is \"$DELEGATE_JOB_ID\" (the one just registered for THIS delegation).
|
||||
|
||||
On start run: $pub --event started.
|
||||
@@ -643,6 +639,7 @@ Task: $SUBMIT_JOB_PROMPT"
|
||||
# text never appeared - none of which a later `started` can repair.
|
||||
if [ "$rc" -ne 0 ] && [ "$rc" -ne 4 ]; then
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" error "instruction injection failed (prompt-lock, rc=$rc)" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
JOB_TERMINALIZED=1
|
||||
exit 1
|
||||
fi
|
||||
if [ "$rc" -eq 4 ]; then
|
||||
@@ -652,10 +649,38 @@ Task: $SUBMIT_JOB_PROMPT"
|
||||
# ISSUE-23: this script no longer publishes `started` on the agent's behalf.
|
||||
# It used to, which made the registry show a started job even when the agent
|
||||
# never saw the prompt, and left any started-based verdict meaningless.
|
||||
if ! delegate_wait_started "$DELEGATE_JOB_ID" "${DELEGATE_JOB_ACK_S:-180}" "$DELEGATE_JOB_TOKEN"; then
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" error "onboarding delivery unconfirmed: no started within ${DELEGATE_JOB_ACK_S:-180}s" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
echo "ERROR: '$SESSION_NAME' did not acknowledge the onboarding prompt within ${DELEGATE_JOB_ACK_S:-180}s" >&2
|
||||
exit 1
|
||||
#
|
||||
# How long to wait for the handshake: MAM_ONBOARD_ACK_S, else the wrapper's
|
||||
# MAM_INJECT_ACK_GRACE_S, else the job's own ack timeout. The create-specific
|
||||
# variable comes first so this wait can be shortened - in a sandbox with no
|
||||
# real agent, for instance - without also retuning the delegate-job
|
||||
# wrapper's injection ack, which is a different wait with its own tests.
|
||||
ONBOARD_ACK_S="${MAM_ONBOARD_ACK_S:-${MAM_INJECT_ACK_GRACE_S:-${DELEGATE_JOB_ACK_S:-180}}}"
|
||||
if ! delegate_wait_started "$DELEGATE_JOB_ID" "$ONBOARD_ACK_S" "$DELEGATE_JOB_TOKEN"; then
|
||||
# The job failed, the session did not: the pane is up and the row is valid,
|
||||
# only the handshake never landed. Killing a live agent over a slow or
|
||||
# silent onboarding would destroy more than it protects, and it would
|
||||
# contradict this script's own rule for the same situation one step
|
||||
# earlier - a TUI that never signalled ready is preserved for diagnosis,
|
||||
# not rolled back. So: mark the job, warn loudly, keep the session.
|
||||
# MULTI_AGENT_RULES §6 has the GM gate on this job's `completed` anyway,
|
||||
# so an unacknowledged onboarding already blocks real work.
|
||||
# A job that is already terminal is not marked again: `error` over a
|
||||
# `completed` is refused by the registry anyway (trial-2 log: "conflicting
|
||||
# error after terminal completed"), and a job the agent has finished is
|
||||
# not an undelivered one - only the evidence was missing.
|
||||
ONBOARD_JOB_ST="$(delegate_job_status "$DELEGATE_JOB_ID")"
|
||||
case "$ONBOARD_JOB_ST" in
|
||||
completed|error|cancelled)
|
||||
echo "WARNING: no started event naming this dispatch for '$SESSION_NAME' within ${ONBOARD_ACK_S}s, but job $DELEGATE_JOB_ID is already '$ONBOARD_JOB_ST'; leaving it as it is." >&2
|
||||
;;
|
||||
*)
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" error "onboarding delivery unconfirmed: no started within ${ONBOARD_ACK_S}s" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
echo "WARNING: '$SESSION_NAME' did not acknowledge the onboarding prompt within ${ONBOARD_ACK_S}s." >&2
|
||||
echo " The session is preserved and job $DELEGATE_JOB_ID is marked error; re-send the onboarding with multi-agent-mux-delegate-job submit once the agent is up." >&2
|
||||
;;
|
||||
esac
|
||||
JOB_TERMINALIZED=1
|
||||
fi
|
||||
# Trigger immediate priority reconcile cycle asynchronously
|
||||
# Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the
|
||||
|
||||
@@ -633,41 +633,33 @@ _disk_status() {
|
||||
# ISSUE-18: delivery is confirmed by the protocol, not the screen. After the
|
||||
# prompt was handed over (herdr accepted the submission, or the dispatch was
|
||||
# published for a remote session) the job's own `started` must arrive within
|
||||
# the ack timeout T: the registry leaves `pending` (the agent's publish_event
|
||||
# syncs it on this host), a started line lands in events.log, or the
|
||||
# subscriber prints a started line (a remote agent - no shared disk). No
|
||||
# `started` within T is a delivery failure: fail closed, never re-inject
|
||||
# (single-injection contract). The status is not pinned to this dispatch and
|
||||
# may come from an external writer; the terminal handling after this still
|
||||
# keeps such an error/cancelled as it is rather than turning it into success.
|
||||
# the ack timeout T. The evidence is lib.sh's delegate_started_recorded - the
|
||||
# same predicate create_session's onboarding ack uses, so the two waits share
|
||||
# one contract (ack-finding req. 2, review R1): the local publisher's line in
|
||||
# events.log, or the subscriber's HMAC-verified, dispatch-attributed audit
|
||||
# record of a `started` it received (a remote agent has no shared disk). The
|
||||
# registry status is not evidence - a record leaves `pending` when another
|
||||
# writer closes it (error, cancelled) or another dispatch claims it, none of
|
||||
# which says this agent saw this prompt. No `started` within T is a delivery
|
||||
# failure: fail closed, never re-inject (single-injection contract). A job
|
||||
# that is already terminal is handled by the terminal logic after this, not
|
||||
# turned into a success here.
|
||||
_wait_started() {
|
||||
local job_id="$1" ack_s="${2:-$ACK_TIMEOUT}" sub_log="${3:-}" deadline wall st
|
||||
local job_id="$1" ack_s="${2:-$ACK_TIMEOUT}" sub_log="${3:-}" deadline wall
|
||||
# sub_log is retained for callers' signatures; the subscriber's evidence is
|
||||
# read from its audit record, which is attributable, not from its console.
|
||||
deadline=$(( $(date +%s) + ack_s ))
|
||||
# The ack wait never outlives this dispatch's wall clock (START_TIME + TIMEOUT).
|
||||
wall=$(( ${START_TIME:-$(date +%s)} + ${TIMEOUT:-3600} ))
|
||||
[ "$deadline" -gt "$wall" ] && deadline=$wall
|
||||
local ev_log="$REGISTRY_DIR/$job_id.events.log"
|
||||
# A loop job keeps one events.log across iterations: only a `started` that
|
||||
# carries THIS attempt's dispatch token counts (review R1).
|
||||
local started_re='"event": *"started"'
|
||||
[ -n "${_disp_tok:-}" ] && started_re='"event": *"started".*"dispatch_token": *"'"$_disp_tok"'"'
|
||||
local budget=$(( deadline - $(date +%s) ))
|
||||
while :; do
|
||||
st="$(_disk_status "$job_id")"
|
||||
if [ -n "$st" ] && [ "$st" != "pending" ]; then
|
||||
echo "inject: job $job_id advanced to '$st' in the registry; treating the prompt as delivered and continuing." >&2
|
||||
return 0
|
||||
fi
|
||||
# publish_event records `started` here before its wire publish and before
|
||||
# the status sync, so a slow broker cannot delay the acknowledgement.
|
||||
if [ -f "$ev_log" ] && grep -Eq "$started_re" "$ev_log" 2>/dev/null; then
|
||||
# publish_event records `started` locally before its wire publish and
|
||||
# before the status sync, so a slow broker cannot delay the acknowledgement.
|
||||
if MAM_JOBS_DIR="$REGISTRY_DIR" delegate_started_recorded "$job_id" "${_disp_tok:-}" "${LOGS_DIR_ABS:-}"; then
|
||||
echo "inject: job $job_id recorded 'started' in events.log; treating the prompt as delivered and continuing." >&2
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$sub_log" ] && [ -f "$sub_log" ] && grep -Eq '^[^ ]+ job=[^ ]+ seq=[^ ]+ started( |$)' "$sub_log" 2>/dev/null; then
|
||||
echo "inject: subscriber received started for job $job_id; treating the prompt as delivered and continuing." >&2
|
||||
return 0
|
||||
fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
ACK_WAITED_S="$budget"
|
||||
return 1
|
||||
|
||||
@@ -101,17 +101,11 @@ case "$AGENT" in
|
||||
esac
|
||||
|
||||
# 4. Spawn new herdr session + run agent with the saved id
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
START_CMD="herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
eval "$START_CMD"
|
||||
# auto-handle trust / bypass dialogs
|
||||
handle_startup_dialogs "$SESSION_NAME" 20
|
||||
;;
|
||||
agy|hermes|grok|opencode|codex)
|
||||
eval "herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
;;
|
||||
esac
|
||||
# Issue #24: start-up dialogs (trust prompts, blocking notices) are answered for
|
||||
# every agent from the adapter's signatures - the same call create makes.
|
||||
# --no-auto-trust leaves a directory-trust dialog to a human (MAM_AUTO_TRUST=0).
|
||||
handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT"
|
||||
|
||||
# 4. Update agent-sessions.yaml: status running, last_visible_status
|
||||
# (Also automatically publishes a `progress --detail "resumed"` event to the multi-agent-mux-delegate-job registry if a delegate_job_id exists)
|
||||
|
||||
@@ -13,6 +13,8 @@ Usage: $0 --workspace <path> --agent <claude|agy|hermes|grok|opencode|codex> --s
|
||||
|
||||
Options:
|
||||
--herdr-session NAME specify isolated herdr session name (alias: --herdr-server)
|
||||
--no-auto-trust do not answer a start-up directory-trust dialog (default: answer it;
|
||||
other blocking notices are always answered) - same switch as create
|
||||
--dry-run Simulates resume flow (resolves binary, environment) without writing
|
||||
any updates to YAML or DB. Safe to execute inside active write transactions.
|
||||
EOF
|
||||
@@ -25,6 +27,10 @@ HERDR_SERVER_OPT=""
|
||||
HERDR_WORKSPACE_OPT=""
|
||||
|
||||
DRY_RUN=0
|
||||
# Issue #24: a resumed CLI shows the same directory-trust dialog as a fresh
|
||||
# one, so resume takes the same switch as create (MAM_AUTO_TRUST is what
|
||||
# handle_startup_dialogs reads).
|
||||
AUTO_TRUST=1
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -33,6 +39,8 @@ while [ $# -gt 0 ]; do
|
||||
--session) SESSION_NAME="$2"; shift 2 ;;
|
||||
--herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
|
||||
--herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;;
|
||||
--auto-trust) AUTO_TRUST=1; shift ;;
|
||||
--no-auto-trust) AUTO_TRUST=0; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||||
@@ -46,6 +54,7 @@ case "$AGENT" in
|
||||
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; }
|
||||
export MAM_AUTO_TRUST="$AUTO_TRUST"
|
||||
|
||||
# Issue #21 phase 2 (A11): a session resumes only in the directory it ran in
|
||||
# (its conversation is keyed by that cwd; a class A agent would otherwise start
|
||||
@@ -226,10 +235,10 @@ fi
|
||||
|
||||
# 4. Spawn new agent session (delegates to herdr translation shim)
|
||||
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
|
||||
if [ "$AGENT" = "claude" ]; then
|
||||
# auto-handle trust / bypass dialogs
|
||||
handle_startup_dialogs "$SESSION_NAME" 20
|
||||
fi
|
||||
# Issue #24: every agent, the same call create makes. The signatures and accept
|
||||
# keys come from the agent adapter; this used to run only for claude, so a
|
||||
# resumed agy/grok/codex sat on its trust prompt.
|
||||
handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT"
|
||||
|
||||
# Wait for TUI readiness or let it settle
|
||||
sleep 2
|
||||
|
||||
@@ -227,6 +227,12 @@ def mam_sandbox(tmp_path, monkeypatch, request):
|
||||
# (`reconcile.sh --subscribe --idle-timeout 0`) from a sandbox. Tests that
|
||||
# exercise the daemon itself override this with "0" or delenv.
|
||||
monkeypatch.setenv("MAM_NO_SUBSCRIBER_DAEMON", "1")
|
||||
# Issue #23: onboarding delivery now waits for the agent's own `started`.
|
||||
# No sandbox has a real agent to publish one, so without a short grace
|
||||
# every create_session test would sit out the full 180 s ack timeout.
|
||||
# Create-specific on purpose: MAM_INJECT_ACK_GRACE_S is the delegate-job
|
||||
# wrapper's knob and its own tests assert on the value they pass.
|
||||
monkeypatch.setenv("MAM_ONBOARD_ACK_S", "3")
|
||||
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
|
||||
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
|
||||
monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False)
|
||||
|
||||
@@ -261,13 +261,21 @@ def test_wrapper_honours_registry_ack_over_unconfirmed_inject(tmp_path, mock_her
|
||||
time.sleep(0.1)
|
||||
assert jid, "job was not registered"
|
||||
|
||||
# The agent acknowledges the job out of band, as codex does via publish_event.
|
||||
ack = subprocess.run(
|
||||
[sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg_dir),
|
||||
"update", "--job", jid, "--status", "running"],
|
||||
capture_output=True, text=True, cwd=str(tmp_path), env=env,
|
||||
# The agent acknowledges the job out of band, as codex does via
|
||||
# publish_event - the real producer, with the dispatch token the wrapper
|
||||
# handed it. (Issue #23 review R1: a bare registry status flip is not an
|
||||
# acknowledgement any more; only the agent's own `started` naming this
|
||||
# dispatch is. publish_event writes that line locally before its wire
|
||||
# publish, so an unreachable broker does not hide it.)
|
||||
tok = json.loads((reg_dir / f"{jid}.json").read_text()).get("dispatch_token") or ""
|
||||
subprocess.run(
|
||||
[sys.executable, str(PUBLISH_PY), "--registry-dir", str(reg_dir), "--job", jid,
|
||||
"--event", "started", "--expect-dispatch-token", tok, "--attempt-id", "1"],
|
||||
capture_output=True, text=True, cwd=str(tmp_path), env=env, timeout=60,
|
||||
)
|
||||
assert ack.returncode == 0, ack.stderr
|
||||
started_lines = [l for l in (reg_dir / f"{jid}.events.log").read_text().splitlines()
|
||||
if '"started"' in l]
|
||||
assert started_lines and tok in started_lines[-1], started_lines
|
||||
|
||||
stdout, stderr = proc.communicate(timeout=90)
|
||||
assert "treating the prompt as delivered and continuing" in stderr, f"ack guard did not fire:\n{stderr}\n{stdout}"
|
||||
|
||||
@@ -1225,11 +1225,11 @@ def test_acceptance5_remote_runner_completes_a_job_over_the_broker_only(tmp_path
|
||||
pytest.skip("broker unreachable when publishing the dispatch (no CONNACK); the runner never saw the job")
|
||||
_skip_if_broker_did_not_deliver(reg, runner_log)
|
||||
assert proc.returncode == 0, f"rc={proc.returncode}\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}"
|
||||
# delivery is confirmed by the protocol: the subscriber saw `started`, or
|
||||
# the registry left pending - to running, or straight to completed when
|
||||
# the broker delivered only the retained terminal event
|
||||
assert ("subscriber received started" in stderr or "advanced to 'running'" in stderr
|
||||
or "advanced to 'completed'" in stderr), stderr
|
||||
# delivery is confirmed by the protocol: the subscriber's attributed
|
||||
# audit record of the remote `started` (review R1: the registry status
|
||||
# is no longer evidence - it can leave pending for reasons that say
|
||||
# nothing about this dispatch)
|
||||
assert "recorded 'started'" in stderr, stderr
|
||||
assert _job(reg, jid)["status"] == "completed", \
|
||||
f"the subscriber mirrors the remote terminal into the registry\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}"
|
||||
assert _job(reg, jid)["transport"] == "remote"
|
||||
|
||||
@@ -17,6 +17,7 @@ agy/grok/codex/opencode TUI; no such TUI can be started here.
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
@@ -34,7 +35,7 @@ def _run_lib(tmp_path, body, env=None):
|
||||
script = 'source "%s"\n_init_herdr_isolation\n%s\n' % (lib_path, body)
|
||||
run_env = dict(os.environ)
|
||||
for var in ("HERDR_SESSION", "HERDR_SESSION_NAME", "HERDR_WORKSPACE_ID",
|
||||
"HERDR_PANE_ID", "HERDR_TAB_ID"):
|
||||
"HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_SOCKET_PATH"):
|
||||
run_env.pop(var, None)
|
||||
run_env.update(env or {})
|
||||
return subprocess.run(["bash", "-c", script], capture_output=True,
|
||||
@@ -109,6 +110,16 @@ CROSS = {"HERDR_SESSION": "default", "HERDR_SESSION_NAME": "mam-isolated",
|
||||
"HERDR_WORKSPACE_ID": "w5", "HERDR_PANE_ID": "w5:p2",
|
||||
"HERDR_TAB_ID": "w5:t1"}
|
||||
|
||||
# The orchestrator pane as the live trial of this branch measured it (herdr
|
||||
# 0.8.2): no HERDR_SESSION at all, the default server's socket, and that
|
||||
# server's own ids. This is the env in which the HERDR_SESSION-based verdict
|
||||
# was always "not crossing" and #23 reproduced unchanged.
|
||||
DEFAULT_SOCK = "/home/someone/.config/herdr/herdr.sock"
|
||||
NAMED_SOCK = "/home/someone/.config/herdr/sessions/%s/herdr.sock"
|
||||
TRIAL_PANE = {"HERDR_SOCKET_PATH": DEFAULT_SOCK, "HERDR_WORKSPACE_ID": "w6",
|
||||
"HERDR_PANE_ID": "w6:p1", "HERDR_TAB_ID": "w6:t1",
|
||||
"HERDR_SESSION_NAME": "multi-agent-mux"}
|
||||
|
||||
|
||||
def test_s1_cross_server_call_drops_the_caller_panes_scope_ids(
|
||||
mam_sandbox, mock_herdr, mock_agents, monkeypatch):
|
||||
@@ -189,12 +200,89 @@ def test_s2b_a_caller_outside_any_pane_keeps_the_explicit_scope(
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
|
||||
|
||||
def test_s1c_the_real_default_server_pane_is_a_crossing(
|
||||
mam_sandbox, mock_herdr, mock_agents, monkeypatch):
|
||||
"""S1c (trial finding, req. 4): the caller's server comes from the socket
|
||||
path when HERDR_SESSION is absent. In the measured orchestrator pane the
|
||||
three ids must be dropped and the agent on the named server found."""
|
||||
tmp_path = mam_sandbox
|
||||
env_log = _install_env_recorder(tmp_path, monkeypatch)
|
||||
_seed_agent(mock_herdr, tmp_path, "trial-agy", "w1", "w1:p5")
|
||||
|
||||
res = _run_lib(tmp_path, 'herdr has-session -t trial-agy', env=TRIAL_PANE)
|
||||
assert res.returncode == 0, "the trial's exact failure: " + res.stdout + res.stderr
|
||||
|
||||
calls = _recorded(env_log)
|
||||
assert calls, "the recorder saw no herdr call"
|
||||
for call in calls:
|
||||
assert call["HERDR_WORKSPACE_ID"] is None, call
|
||||
assert call["HERDR_PANE_ID"] is None, call
|
||||
assert call["HERDR_TAB_ID"] is None, call
|
||||
|
||||
|
||||
def test_s1d_the_verdict_itself_on_the_trial_pane(mam_sandbox):
|
||||
"""S1d: the function the trial printed, in the trial's env: crossing."""
|
||||
res = _run_lib(mam_sandbox,
|
||||
'echo "caller=$(_herdr_caller_server)"\n'
|
||||
'if _herdr_call_is_cross_server; then echo VERDICT=crossing; '
|
||||
'else echo VERDICT=not-crossing; fi',
|
||||
env=TRIAL_PANE)
|
||||
assert "caller=default" in res.stdout, res.stdout + res.stderr
|
||||
assert "VERDICT=crossing" in res.stdout, res.stdout + res.stderr
|
||||
|
||||
|
||||
def test_s1e_a_named_server_pane_calling_its_own_server_keeps_its_scope(
|
||||
mam_sandbox, mock_herdr, mock_agents):
|
||||
"""S1e (req. 4): a caller in a named server's pane (socket under
|
||||
sessions/<name>/) targeting that same server is not a crossing - the
|
||||
exported workspace id still scopes, as in S2."""
|
||||
tmp_path = mam_sandbox
|
||||
_seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5")
|
||||
same = {"HERDR_SOCKET_PATH": NAMED_SOCK % "mam-isolated",
|
||||
"HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"}
|
||||
res = _run_lib(tmp_path, 'echo "caller=$(_herdr_caller_server)"\nherdr has-session -t creator-agy-01',
|
||||
env=same)
|
||||
assert "caller=mam-isolated" in res.stdout, res.stdout + res.stderr
|
||||
assert res.returncode == 1, "same server: the explicit w2 scope must still hide a w1 agent"
|
||||
|
||||
|
||||
def test_s1f_a_named_server_pane_calling_another_named_server_is_a_crossing(
|
||||
mam_sandbox, mock_herdr, mock_agents):
|
||||
"""S1f: named socket, different named target, no HERDR_SESSION - crossing."""
|
||||
tmp_path = mam_sandbox
|
||||
_seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5")
|
||||
other = {"HERDR_SOCKET_PATH": NAMED_SOCK % "mam-other",
|
||||
"HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"}
|
||||
res = _run_lib(tmp_path, 'herdr has-session -t creator-agy-01', env=other)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
|
||||
|
||||
def test_s1g_herdr_session_wins_over_the_socket_when_both_are_present(mam_sandbox):
|
||||
"""S1g (req. 1): HERDR_SESSION, when a herdr exports it, is authoritative
|
||||
over the socket-derived name."""
|
||||
res = _run_lib(mam_sandbox, 'echo "caller=$(_herdr_caller_server)"',
|
||||
env={"HERDR_SESSION": "from-herdr", "HERDR_SOCKET_PATH": DEFAULT_SOCK})
|
||||
assert "caller=from-herdr" in res.stdout, res.stdout + res.stderr
|
||||
|
||||
|
||||
def test_s1h_an_unrecognised_socket_layout_is_not_in_a_pane(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""S1h: a socket path that is neither layout yields no caller server, so
|
||||
an exported scope is respected (today's behaviour), never guessed away."""
|
||||
tmp_path = mam_sandbox
|
||||
_seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5")
|
||||
res = _run_lib(tmp_path, 'echo "caller=[$(_herdr_caller_server)]"\nherdr has-session -t creator-agy-01',
|
||||
env={"HERDR_SOCKET_PATH": "/tmp/odd/herdr.sock",
|
||||
"HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"})
|
||||
assert "caller=[]" in res.stdout, res.stdout + res.stderr
|
||||
assert res.returncode == 1, res.stdout + res.stderr
|
||||
|
||||
|
||||
def test_s3_shim_contract_regressions_still_pass(mam_sandbox):
|
||||
"""S3: H-18 / H-21 / H-23 are the tests that fix today's scoping meaning.
|
||||
Run them explicitly as this change's regression guard."""
|
||||
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
res = subprocess.run(
|
||||
[os.path.join(repo, ".venv", "bin", "python"), "-m", "pytest", "-q",
|
||||
[sys.executable, "-m", "pytest", "-q",
|
||||
"-p", "no:cacheprovider",
|
||||
"tests/test_herdr_shim_contract.py", "-k",
|
||||
"h18 or h21 or h22 or h23"],
|
||||
@@ -270,7 +358,6 @@ def test_s12_mam_herdr_never_aborts_its_caller_under_set_e(
|
||||
def test_s13_the_loop_watcher_applies_the_same_rule(mam_sandbox):
|
||||
"""S13 (pure unit): watcher.py builds its own herdr argv and never goes
|
||||
through mam_herdr, so it needs the same rule."""
|
||||
import sys
|
||||
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(repo, ".agents", "skills"))
|
||||
from lib_py.loop import watcher
|
||||
@@ -291,6 +378,18 @@ def test_s13_the_loop_watcher_applies_the_same_rule(mam_sandbox):
|
||||
none_target = watcher.scoped_env(None, environ=caller_pane)
|
||||
assert none_target["HERDR_WORKSPACE_ID"] == "w5"
|
||||
|
||||
# trial finding: no HERDR_SESSION, the caller's server comes from the socket
|
||||
trial = {"HERDR_SOCKET_PATH": DEFAULT_SOCK, "HERDR_WORKSPACE_ID": "w6",
|
||||
"HERDR_PANE_ID": "w6:p1", "HERDR_TAB_ID": "w6:t1"}
|
||||
assert watcher.caller_server(trial) == "default"
|
||||
assert "HERDR_WORKSPACE_ID" not in watcher.scoped_env("multi-agent-mux", environ=trial)
|
||||
named_same = {"HERDR_SOCKET_PATH": NAMED_SOCK % "iso-x", "HERDR_WORKSPACE_ID": "w2"}
|
||||
assert watcher.caller_server(named_same) == "iso-x"
|
||||
assert watcher.scoped_env("iso-x", environ=named_same)["HERDR_WORKSPACE_ID"] == "w2"
|
||||
assert "HERDR_WORKSPACE_ID" not in watcher.scoped_env("iso-y", environ=named_same)
|
||||
assert watcher.caller_server({"HERDR_SOCKET_PATH": "/tmp/odd/herdr.sock"}) == ""
|
||||
assert watcher.caller_server({"HERDR_SESSION": "s", "HERDR_SOCKET_PATH": DEFAULT_SOCK}) == "s"
|
||||
|
||||
seen = {}
|
||||
|
||||
def _run(argv, **kw):
|
||||
@@ -331,7 +430,11 @@ echo "rc=$?"
|
||||
''' % (lib, pane_file, keys_log, prelude, timeout, agent)
|
||||
run_env = dict(os.environ)
|
||||
run_env.pop("MAM_AUTO_TRUST", None)
|
||||
run_env.update(env or {})
|
||||
for k, v in (env or {}).items():
|
||||
if v == "":
|
||||
run_env.pop(k, None) # "" means: not in the environment at all
|
||||
else:
|
||||
run_env[k] = v
|
||||
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True,
|
||||
cwd=str(tmp_path), env=run_env)
|
||||
sent = keys_log.read_text().splitlines() if keys_log.exists() else []
|
||||
@@ -372,7 +475,7 @@ def test_s4b_every_adapter_publishes_the_fact(mam_sandbox):
|
||||
seen = {}
|
||||
for agent in ("claude", "agy", "grok", "codex", "opencode", "hermes"):
|
||||
out = subprocess.run(
|
||||
[os.path.join(repo, ".venv", "bin", "python"), "-m", "lib_py.agents",
|
||||
[sys.executable, "-m", "lib_py.agents",
|
||||
"facts", agent],
|
||||
capture_output=True, text=True, cwd=repo,
|
||||
env=dict(os.environ, PYTHONPATH=skills)).stdout
|
||||
@@ -417,6 +520,47 @@ def test_s5c_readiness_is_judged_by_the_ready_tokens_not_by_the_dialog_leaving(
|
||||
assert "rc=0" in res.stdout, res.stdout + res.stderr
|
||||
|
||||
|
||||
READY_FRAMES = [
|
||||
("claude", "Welcome to Claude Code\n❯ "),
|
||||
("agy", "Antigravity Ready in /tmp\n> "),
|
||||
("grok", "Grok Ready\n>>> "),
|
||||
("codex", "Codex\nAsk Codex to do anything"),
|
||||
("opencode", "OpenCode\nAsk anything"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent,frame", READY_FRAMES)
|
||||
def test_s5d_a_ready_first_frame_returns_within_one_iteration_with_no_env(
|
||||
mam_sandbox, agent, frame):
|
||||
"""S5d (review B-1): called on its own - as resume_session.sh calls it,
|
||||
with no wait_for_tui_ready before it and no MAM_*READY* in the environment
|
||||
- the handler must recognise the adapter's ready banner on the first frame
|
||||
and return at once, not idle through its timeout. `sleep` is stubbed so
|
||||
the iteration count is observable and the run is fast; base returned in
|
||||
0.03s, the version under review took the full timeout."""
|
||||
res, sent = _run_dialogs(
|
||||
mam_sandbox, agent, frame, timeout=20,
|
||||
env={"MAM_STRONG_READY_TOKENS": "", "MAM_READY_TOKENS": "", "MAM_WEAK_READY_TOKENS": ""},
|
||||
prelude='_iters=0\nsleep() { _iters=$((_iters + 1)); }\n'
|
||||
'trap \'echo "iterations=$_iters"\' EXIT')
|
||||
assert "rc=0" in res.stdout, res.stdout + res.stderr
|
||||
assert sent == [], sent
|
||||
iters = int(res.stdout.split("iterations=")[1].split()[0])
|
||||
assert iters <= 1, "the handler idled %d iterations on a ready screen" % iters
|
||||
|
||||
|
||||
def test_s5e_caller_set_ready_tokens_are_not_overwritten(mam_sandbox):
|
||||
"""S5e: the gap-fill must not clobber tokens a caller set on purpose -
|
||||
wait_for_tui_ready's own evaluation, or a test steering readiness."""
|
||||
res, sent = _run_dialogs(
|
||||
mam_sandbox, "grok", "Grok Ready\n>>> ", timeout=2,
|
||||
env={"MAM_STRONG_READY_TOKENS": "NonExistentStrong", "MAM_READY_TOKENS": "NonExistentStrong"},
|
||||
prelude='_iters=0\nsleep() { _iters=$((_iters + 1)); }\n'
|
||||
'trap \'echo "iterations=$_iters"\' EXIT')
|
||||
iters = int(res.stdout.split("iterations=")[1].split()[0])
|
||||
assert iters >= 2, "the caller's tokens were replaced by the adapter's: " + res.stdout
|
||||
|
||||
|
||||
def test_s6_no_auto_trust_leaves_trust_dialogs_alone_but_answers_notices(
|
||||
mam_sandbox):
|
||||
"""S6 (stubbed): --no-auto-trust. codex trust is left to a human with a
|
||||
@@ -471,27 +615,73 @@ def _extract(path, start_marker, end_marker, close=None):
|
||||
return "".join(lines[start:end + 1])
|
||||
|
||||
|
||||
def _seed_job(tmp_path, job_id, status="pending", started_token=None):
|
||||
SKILLS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
".agents", "skills")
|
||||
REGISTRY_PY = os.path.join(SKILLS, "multi-agent-mux-delegate-job", "scripts", "registry.py")
|
||||
PUBLISH_PY = os.path.join(SKILLS, "multi-agent-mux-delegate-job", "scripts", "publish_event.py")
|
||||
|
||||
|
||||
def _producer_env(tmp_path):
|
||||
env = dict(os.environ)
|
||||
env.pop("MAM_ENV_FILE", None) # PoC mode, no broker: local-first is enough
|
||||
env["HOME"] = str(tmp_path)
|
||||
env["DELEGATE_JOB_LOGS_DIR"] = str(tmp_path / ".mam" / "delegate_job_logs")
|
||||
return env
|
||||
|
||||
|
||||
def _seed_job(tmp_path, job_id="job1", status="pending", started_token=None,
|
||||
publish_args=None):
|
||||
"""A job record made by the real registry and, when asked, a `started`
|
||||
published by the REAL publish_event.py - never a hand-written line.
|
||||
|
||||
Trial 2 found the previous fixture writing `dispatch_token` into the
|
||||
started line itself, a field the real producer only writes when its
|
||||
command line carries --expect-dispatch-token. A fake producer that differs
|
||||
from the real one let five reviewers pass a waiter no real agent could
|
||||
satisfy. `started_token` here is what the *command line* passes; the line
|
||||
is whatever publish_event makes of it. `publish_args` runs an arbitrary
|
||||
publish instead (e.g. the old create_session command shape).
|
||||
"""
|
||||
jobs = tmp_path / ".mam" / "jobs"
|
||||
jobs.mkdir(parents=True, exist_ok=True)
|
||||
(jobs / ("%s.json" % job_id)).write_text(json.dumps(
|
||||
{"job_id": job_id, "status": status, "dispatch_token": "tok-1",
|
||||
"attempt_id": 1, "ack_timeout_sec": 2}))
|
||||
ev = jobs / ("%s.events.log" % job_id)
|
||||
if started_token is None:
|
||||
if ev.exists():
|
||||
ev.unlink()
|
||||
# a seed is a fresh record: the real registry refuses to re-register an
|
||||
# existing id, and several cases seed the same id more than once
|
||||
for stale in (jobs / ("%s.json" % job_id), jobs / ("%s.events.log" % job_id)):
|
||||
if stale.exists():
|
||||
stale.unlink()
|
||||
env = _producer_env(tmp_path)
|
||||
reg = subprocess.run([sys.executable, REGISTRY_PY, "--registry-dir", str(jobs), "register",
|
||||
"--prompt", "onboarding", "--agent", "claude-code",
|
||||
"--agent-session", "herdr:x", "--dispatch-token", "tok-1",
|
||||
"--job-id", job_id, "--ack-timeout", "2"],
|
||||
capture_output=True, text=True, env=env)
|
||||
assert reg.returncode == 0, reg.stdout + reg.stderr
|
||||
if started_token is not None or publish_args is not None:
|
||||
cmd = [sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", job_id,
|
||||
"--event", "started"]
|
||||
if publish_args is not None:
|
||||
cmd += list(publish_args)
|
||||
else:
|
||||
ev.write_text(json.dumps(
|
||||
{"job": job_id, "event": "started", "dispatch_token": started_token}) + "\n")
|
||||
cmd += ["--expect-dispatch-token", started_token, "--attempt-id", "1"]
|
||||
pub = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=60)
|
||||
# a mismatched token is refused by the producer's own CAS check; that
|
||||
# is fine here - the point is what (if anything) reached the log
|
||||
if started_token in (None, "tok-1"):
|
||||
assert pub.returncode == 0, pub.stdout + pub.stderr
|
||||
if status != "pending":
|
||||
rec = jobs / ("%s.json" % job_id)
|
||||
d = json.loads(rec.read_text())
|
||||
d["status"] = status
|
||||
rec.write_text(json.dumps(d))
|
||||
return jobs
|
||||
|
||||
|
||||
def _run_delivery(tmp_path, inject_rc, job_id="job1", **seed):
|
||||
def _run_delivery(tmp_path, inject_rc, job_id="job1", seed_events=True, **seed):
|
||||
"""Run create_session.sh's own delivery block with a stubbed injection."""
|
||||
lib = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
block = _extract(_create_sh(tmp_path), "# Inject instructions into the herdr pane",
|
||||
"delegate_wait_started", close="fi")
|
||||
if seed_events:
|
||||
_seed_job(tmp_path, job_id, **seed)
|
||||
pub_log = tmp_path / "published.txt"
|
||||
script = '''
|
||||
@@ -532,26 +722,437 @@ def test_s7b_delivery_succeeds_on_the_agents_own_started(mam_sandbox):
|
||||
assert published == [], published
|
||||
|
||||
|
||||
def test_s7c_delivery_succeeds_when_the_registry_left_pending(mam_sandbox):
|
||||
"""S7c: publish_event syncs the record on this host, so a status past
|
||||
`pending` is the same evidence."""
|
||||
res, _ = _run_delivery(mam_sandbox, 0, status="running")
|
||||
def test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts(mam_sandbox):
|
||||
"""S7c: a job that started and then finished (or failed) fast still shows
|
||||
its started line - the evidence is the line, whatever the status became."""
|
||||
for status in ("running", "completed", "error"):
|
||||
res, _ = _run_delivery(mam_sandbox, 0, status=status, started_token="tok-1")
|
||||
assert "DELIVERED" in res.stdout, (status, res.stdout + res.stderr)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["error", "cancelled"])
|
||||
def test_s7f_a_job_closed_by_someone_else_is_not_an_acknowledgement(mam_sandbox, status):
|
||||
"""S7f (review R1 + req. 4): a record can leave `pending` because another
|
||||
writer closed it. That is not evidence - the wait runs to its deadline
|
||||
rather than returning early (the 'no started event naming this dispatch'
|
||||
path, never the DELIVERED-without-warning path of S7c) - and, the job
|
||||
being already terminal, nothing is published over it (req. 4)."""
|
||||
res, published = _run_delivery(mam_sandbox, 0, status=status)
|
||||
assert published == [], (status, published, res.stderr)
|
||||
assert "no started event naming this dispatch" in res.stderr, res.stderr
|
||||
assert "already '%s'" % status in res.stderr, res.stderr
|
||||
|
||||
|
||||
def test_s7g_another_dispatch_running_is_not_this_dispatchs_started(mam_sandbox):
|
||||
"""S7g (review R1): `running` set by a different dispatch's claim says
|
||||
nothing about this prompt."""
|
||||
res, published = _run_delivery(mam_sandbox, 0, status="running")
|
||||
assert published == ["error"], (published, res.stderr)
|
||||
|
||||
|
||||
def test_s7h_the_line_is_parsed_not_pattern_matched(mam_sandbox):
|
||||
"""S7h (review N-4), the one SYNTHETIC line in this file, on purpose: the
|
||||
parser must not depend on key order. Every other started line here comes
|
||||
from the real producer (S7b/S7c/S7e/S8, S7i-S7k)."""
|
||||
tmp_path = mam_sandbox
|
||||
jobs = _seed_job(tmp_path, "job1")
|
||||
reordered = {"data": {"attempt_id": 1, "dispatch_token": "tok-1"}, "event": "started",
|
||||
"job_id": "job1"}
|
||||
(jobs / "job1.events.log").write_text(json.dumps(reordered) + "\n")
|
||||
res, published = _run_delivery(mam_sandbox, 0, seed_events=False)
|
||||
assert "DELIVERED" in res.stdout, res.stdout + res.stderr
|
||||
assert published == [], published
|
||||
|
||||
|
||||
def test_s7d_no_started_within_t_is_a_delivery_failure(mam_sandbox):
|
||||
"""S7d: fail closed, and say so on the job."""
|
||||
# ---- trial-2 regression: the real agent must be able to satisfy the ack ----
|
||||
|
||||
def test_s7i_the_old_create_session_command_shape_could_never_be_acknowledged(mam_sandbox):
|
||||
"""S7i (trial 2): the command create_session used to hand the agent had no
|
||||
--expect-dispatch-token, so the REAL producer wrote a started with no
|
||||
token, and a token-checking waiter never accepted it: 180 s idle, then a
|
||||
false 'unconfirmed' over a finished onboarding. Pinned as the negative it
|
||||
is, so the fixture can never drift back to faking the token."""
|
||||
tmp_path = mam_sandbox
|
||||
jobs = _seed_job(tmp_path, "job1", publish_args=[]) # old shape: no token args
|
||||
line = json.loads((jobs / "job1.events.log").read_text().splitlines()[0])
|
||||
assert line["event"] == "started" and "dispatch_token" not in line.get("data", {}), line
|
||||
res, published = _run_delivery(mam_sandbox, 0, seed_events=False)
|
||||
assert published == ["error"], (published, res.stderr)
|
||||
|
||||
|
||||
def test_s7j_the_shared_builders_command_is_acknowledged_by_the_real_producer(mam_sandbox):
|
||||
"""S7j (trial 2, the fix): run exactly the command delegate_publish_cmd
|
||||
hands the agent, through the real publish_event.py, and the waiter is
|
||||
satisfied on the first probe."""
|
||||
tmp_path = mam_sandbox
|
||||
jobs = _seed_job(tmp_path, "job1")
|
||||
lib = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
env = _producer_env(tmp_path)
|
||||
env["MAM_JOBS_DIR"] = str(jobs)
|
||||
env["WORKSPACE_ROOT"] = str(tmp_path)
|
||||
script = ('source "%s"\n'
|
||||
'cmd="$(delegate_publish_cmd job1 tok-1 1)"\n'
|
||||
'echo "CMD=$cmd"\n'
|
||||
'eval "$cmd --event started" >/dev/null\n'
|
||||
'delegate_started_recorded job1 tok-1 && echo RECORDED\n') % lib
|
||||
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True,
|
||||
cwd=str(tmp_path), env=env, timeout=120)
|
||||
assert "RECORDED" in res.stdout, res.stdout + res.stderr
|
||||
cmd = res.stdout.split("CMD=")[1].splitlines()[0]
|
||||
assert "--expect-dispatch-token tok-1" in cmd and "--attempt-id 1" in cmd, cmd
|
||||
line = json.loads((jobs / "job1.events.log").read_text().splitlines()[-1])
|
||||
assert line["data"]["dispatch_token"] == "tok-1", line
|
||||
|
||||
|
||||
def test_s7k_create_session_hands_the_agent_the_builders_command(mam_sandbox):
|
||||
"""S7k: the onboarding instruction is built by delegate_publish_cmd with
|
||||
the job's token and attempt id - not by a hand-rolled copy that can drift."""
|
||||
src = open(_create_sh(mam_sandbox)).read()
|
||||
assert 'pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")"' in src
|
||||
assert 'printf \'%q \' "$py_bin" "$pub_script" --registry-dir' not in src
|
||||
|
||||
|
||||
def test_s7l_the_builder_matches_the_wrappers_pub_cmd_argument_for_argument(mam_sandbox):
|
||||
"""S7l (req. 2, one contract): the wrapper's _pub_cmd and lib.sh's
|
||||
delegate_publish_cmd produce the same argv for the same job."""
|
||||
tmp_path = mam_sandbox
|
||||
jobs = _seed_job(tmp_path, "job1")
|
||||
lib = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
wrapper = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||
env = _producer_env(tmp_path)
|
||||
env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path),
|
||||
"MAM_DELEGATE_JOB_NO_MAIN": "1"})
|
||||
script = ('source "%s"\n'
|
||||
'source "%s"\n'
|
||||
'PY="$(_delegate_py_bin)"; REGISTRY_DIR="%s"; JOB_ID=job1; LOGS_DIR_ABS="$DELEGATE_JOB_LOGS_DIR"\n'
|
||||
'echo "W=$(_pub_cmd --expect-dispatch-token tok-1 --attempt-id 1)"\n'
|
||||
'echo "L=$(delegate_publish_cmd job1 tok-1 1)"\n') % (lib, wrapper, jobs)
|
||||
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True,
|
||||
cwd=str(tmp_path), env=env, timeout=120)
|
||||
w = res.stdout.split("W=")[1].splitlines()[0].split()
|
||||
l = res.stdout.split("L=")[1].splitlines()[0].split()
|
||||
# same script, registry, job and token/attempt flags; the interpreter path
|
||||
# may differ in spelling (the wrapper resolves it relative), so compare from
|
||||
# the script name on
|
||||
def tail(argv):
|
||||
i = next(i for i, a in enumerate(argv) if a.endswith("publish_event.py"))
|
||||
return argv[i:]
|
||||
assert tail(w) == tail(l), (w, l)
|
||||
|
||||
|
||||
def test_s7m_a_job_the_agent_already_finished_is_not_marked_error(mam_sandbox):
|
||||
"""S7m (req. 4): with no started naming this dispatch but the job already
|
||||
`completed`, create_session leaves it alone - no `error` over a terminal
|
||||
state (the trial-2 'refused error ... after terminal completed')."""
|
||||
res, published = _run_delivery(mam_sandbox, 0, status="completed")
|
||||
assert published == [], (published, res.stderr)
|
||||
assert "already 'completed'" in res.stderr, res.stderr
|
||||
assert "DELIVERED" in res.stdout
|
||||
|
||||
|
||||
def _wrapper_wait(tmp_path, jobs, disp_tok, ack_s=2, logs_dir=None):
|
||||
"""Run the wrapper's _wait_started exactly as the wrapper runs it."""
|
||||
lib = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
wrapper = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||
env = _producer_env(tmp_path)
|
||||
env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path),
|
||||
"MAM_DELEGATE_JOB_NO_MAIN": "1"})
|
||||
script = ('source "%s"\nsource "%s"\nset +e\n'
|
||||
'PY="$(_delegate_py_bin)"; REGISTRY_DIR="%s"; ACK_TIMEOUT=%d; START_TIME=$(date +%%s); TIMEOUT=60\n'
|
||||
'LOGS_DIR_ABS="%s"\n'
|
||||
'_disp_tok=%s; _wait_started job1 %d; echo "rc=$?"\n'
|
||||
) % (lib, wrapper, jobs, ack_s, logs_dir or env["DELEGATE_JOB_LOGS_DIR"], disp_tok, ack_s)
|
||||
return subprocess.run(["bash", "-c", script], capture_output=True, text=True,
|
||||
cwd=str(tmp_path), env=env, timeout=120)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("terminal", ["error", "cancelled"])
|
||||
def test_w1_wrapper_a_job_closed_without_a_started_is_not_acknowledged(mam_sandbox, terminal):
|
||||
"""W1 (review R1, real producer, record NOT reset): the agent never
|
||||
published started; someone published `%s`. The record left `pending`.
|
||||
The wrapper's wait must still say no - as create's does."""
|
||||
tmp_path = mam_sandbox
|
||||
jobs = _seed_job(tmp_path, "job1", publish_args=None)
|
||||
env = _producer_env(tmp_path)
|
||||
subprocess.run([sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", "job1",
|
||||
"--event", terminal, "--detail", "closed by another writer",
|
||||
"--expect-dispatch-token", "tok-1", "--attempt-id", "1"],
|
||||
check=True, capture_output=True, text=True, env=env, timeout=60)
|
||||
assert json.loads((jobs / "job1.json").read_text())["status"] == terminal
|
||||
res = _wrapper_wait(tmp_path, jobs, "tok-1")
|
||||
assert "rc=1" in res.stdout, res.stdout + res.stderr
|
||||
assert "treating the prompt as delivered" not in res.stderr, res.stderr
|
||||
|
||||
|
||||
def test_w2_wrapper_another_dispatchs_started_and_running_is_not_this_ones(mam_sandbox):
|
||||
"""W2 (review R1, real producer, record NOT reset): started published
|
||||
under token A (record -> running), then the job was re-dispatched with
|
||||
token B. Waiting for B must not accept A's started, nor the `running`."""
|
||||
tmp_path = mam_sandbox
|
||||
jobs = tmp_path / ".mam" / "jobs"; jobs.mkdir(parents=True, exist_ok=True)
|
||||
env = _producer_env(tmp_path)
|
||||
subprocess.run([sys.executable, REGISTRY_PY, "--registry-dir", str(jobs), "register",
|
||||
"--prompt", "x", "--agent", "claude-code", "--agent-session", "herdr:x",
|
||||
"--dispatch-token", "tok-A", "--job-id", "job1", "--ack-timeout", "2"],
|
||||
check=True, capture_output=True, text=True, env=env)
|
||||
subprocess.run([sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", "job1",
|
||||
"--event", "started", "--expect-dispatch-token", "tok-A", "--attempt-id", "1"],
|
||||
check=True, capture_output=True, text=True, env=env, timeout=60)
|
||||
rec = jobs / "job1.json"; d = json.loads(rec.read_text())
|
||||
assert d["status"] == "running"
|
||||
d["dispatch_token"] = "tok-B"; rec.write_text(json.dumps(d)) # the re-dispatch; status untouched
|
||||
res = _wrapper_wait(tmp_path, jobs, "tok-B")
|
||||
assert "rc=1" in res.stdout, res.stdout + res.stderr
|
||||
|
||||
|
||||
def test_w3_wrapper_this_dispatchs_started_and_running_is_acknowledged(mam_sandbox):
|
||||
"""W3 (real producer, record NOT reset): the positive twin of W2."""
|
||||
tmp_path = mam_sandbox
|
||||
jobs = _seed_job(tmp_path, "job1", started_token="tok-1")
|
||||
assert json.loads((jobs / "job1.json").read_text())["status"] == "running"
|
||||
res = _wrapper_wait(tmp_path, jobs, "tok-1")
|
||||
assert "rc=0" in res.stdout, res.stdout + res.stderr
|
||||
|
||||
|
||||
def test_w4_both_waits_agree_on_every_case(mam_sandbox):
|
||||
"""W4 (ack-finding req. 2): create's delegate_wait_started and the
|
||||
wrapper's _wait_started answer the same on the four reviewer cases."""
|
||||
tmp_path = mam_sandbox
|
||||
lib = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
cases = []
|
||||
for label, status, started_tok, wait_tok in (
|
||||
("error-no-started", "error", None, "tok-1"),
|
||||
("cancelled-no-started", "cancelled", None, "tok-1"),
|
||||
("other-dispatch-started", "running", "tok-1", "tok-B"),
|
||||
("own-started", "running", "tok-1", "tok-1")):
|
||||
jobs = _seed_job(tmp_path, "job1", status=status, started_token=started_tok)
|
||||
env = _producer_env(tmp_path)
|
||||
env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path)})
|
||||
c = subprocess.run(["bash", "-c", 'source "%s"; delegate_wait_started job1 1 %s; echo "rc=$?"' % (lib, wait_tok)],
|
||||
capture_output=True, text=True, cwd=str(tmp_path), env=env, timeout=60)
|
||||
w = _wrapper_wait(tmp_path, jobs, wait_tok, ack_s=1)
|
||||
cases.append((label, c.stdout.strip().splitlines()[-1], w.stdout.strip().splitlines()[-1]))
|
||||
for label, c_rc, w_rc in cases:
|
||||
assert c_rc == w_rc, (label, cases)
|
||||
assert [c for _, c, _ in cases] == ["rc=1", "rc=1", "rc=1", "rc=0"], cases
|
||||
|
||||
|
||||
def test_w5_a_remote_agents_started_counts_only_through_the_subscribers_verified_record(mam_sandbox):
|
||||
"""W5 (remote route): a remote agent leaves no line in events.log; the
|
||||
subscriber writes an audit record after HMAC verification and after
|
||||
dropping any dispatch_token mismatch, and marks it dispatch_verified.
|
||||
SYNTHETIC on purpose: the record is written in exactly the shape
|
||||
mqtt_common.append_event produces (a live subscriber needs a broker).
|
||||
With a token demanded, only a verified record counts."""
|
||||
tmp_path = mam_sandbox
|
||||
jobs = _seed_job(tmp_path, "job1")
|
||||
logs = tmp_path / ".mam" / "delegate_job_logs"
|
||||
(logs / "job1").mkdir(parents=True, exist_ok=True)
|
||||
audit = logs / "job1" / "events.ndjson"
|
||||
|
||||
def rec(**kw):
|
||||
base = {"event": "received", "source_event": "started", "seq": 1, "topic": "t",
|
||||
"timestamp": "2026-09-22T00:00:00Z", "detail": "", "logged_at": "x"}
|
||||
base.update(kw)
|
||||
return json.dumps(base) + "\n"
|
||||
|
||||
import hashlib
|
||||
fp = lambda t: hashlib.sha256(t.encode()).hexdigest()
|
||||
audit.write_text(rec(dispatch_verified=False, dispatch_fingerprint=None)) # untokened: not attributed
|
||||
assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout
|
||||
audit.write_text(rec(source_event="progress", dispatch_verified=True, dispatch_fingerprint=fp("tok-1")))
|
||||
assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout # not a started
|
||||
audit.write_text(rec(dispatch_verified=True)) # verified, no identity: fail closed
|
||||
assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout
|
||||
audit.write_text(rec(dispatch_verified=True, dispatch_fingerprint=fp("tok-OLD"))) # another dispatch's
|
||||
assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout
|
||||
audit.write_text(rec(dispatch_verified=True, dispatch_fingerprint=fp("tok-1")))
|
||||
assert "rc=0" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout
|
||||
|
||||
|
||||
def test_w6_the_subscriber_stamps_dispatch_verified_on_what_it_records(mam_sandbox):
|
||||
"""W6: the real subscriber code path - a _Watcher with an expected
|
||||
dispatch token records a matching started as dispatch_verified, and drops
|
||||
a mismatching one before recording anything."""
|
||||
import types
|
||||
sys.path.insert(0, SKILLS)
|
||||
from lib_py.delegation import job_subscriber, mqtt_common
|
||||
logs = mam_sandbox / ".mam" / "delegate_job_logs"
|
||||
w = job_subscriber._Watcher({"job1"}, {"job1": None}, {"job1": 0}, {"job1": "tok-1"}, {"job1": 1})
|
||||
msg = types.SimpleNamespace(topic="python/mqtt/jobs/job1/events")
|
||||
good = {"schema_version": 1, "seq": 1, "job_id": "job1", "event": "started", "timestamp": "t",
|
||||
"detail": "", "data": {"dispatch_token": "tok-1", "attempt_id": 1}}
|
||||
bad = dict(good, seq=2, data={"dispatch_token": "tok-OLD", "attempt_id": 1})
|
||||
for payload in (good, bad):
|
||||
w._on_event(msg, payload, "job1") if hasattr(w, "_on_event") else w.on_message(None, None, types.SimpleNamespace(topic=msg.topic, payload=json.dumps(payload).encode()))
|
||||
recs = list(mqtt_common.iter_logged_events("job1", str(logs)))
|
||||
started = [r for r in recs if r.get("source_event") == "started"]
|
||||
assert len(started) == 1 and started[0]["dispatch_verified"] is True, recs
|
||||
import hashlib
|
||||
assert started[0]["dispatch_fingerprint"] == hashlib.sha256(b"tok-1").hexdigest(), started[0]
|
||||
assert started[0]["attempt_id"] == 1, started[0]
|
||||
assert "tok-1" not in json.dumps(started[0]), "the token itself must never reach the audit log"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# review round 4 (planner-reviewer-claude-01 B-1, planner-reviewer-codex-01):
|
||||
# the remote audit record must name the dispatch it vouches for. Real signed
|
||||
# producer, real _Watcher.on_message, real audit writer, real re-dispatch
|
||||
# (registry operator write minting a new token) - no record is edited by hand.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _remote_fixture(tmp_path, monkeypatch):
|
||||
import test_issue18_liveness_contract as live
|
||||
jobs = tmp_path / "jobs"
|
||||
env = live._env(tmp_path, dict(live.UNREACHABLE, MAM_DELEGATE_JOB_NO_MAIN="1",
|
||||
MAM_JOBS_DIR=str(jobs)))
|
||||
for key in ("MAM_ENV_FILE", "DELEGATE_JOB_LOGS_DIR", "WORKSPACE_ROOT"):
|
||||
monkeypatch.setenv(key, env[key])
|
||||
sys.path.insert(0, str(live.SKILLS))
|
||||
from lib_py.delegation import job_subscriber, mqtt_common, publish_event, registry
|
||||
return live, env, jobs, job_subscriber, mqtt_common, publish_event, registry
|
||||
|
||||
|
||||
def _receive(publish_event, watcher, jid, token, attempt, seq, secret):
|
||||
"""A signed `started` from the real producer, handed to the real subscriber."""
|
||||
import types
|
||||
payload = publish_event.build_payload(jid, seq, "started", "",
|
||||
{"dispatch_token": token, "attempt_id": attempt}, secret)
|
||||
watcher.on_message(None, None, types.SimpleNamespace(
|
||||
topic="python/mqtt/jobs/%s/events" % jid, payload=json.dumps(payload).encode()))
|
||||
|
||||
|
||||
def _both_waits(live, env, tmp_path, jobs, jid, token):
|
||||
"""create's delegate_wait_started and the wrapper's _wait_started, same inputs."""
|
||||
import shlex
|
||||
script = (
|
||||
'source %s\nset +e\nPY=%s\nREGISTRY_DIR=%s\nACK_TIMEOUT=0\nTIMEOUT=60\nSTART_TIME=$(date +%%s)\n'
|
||||
'LOGS_DIR_ABS=%s\n_disp_tok=%s\n'
|
||||
'delegate_wait_started %s 0 "$_disp_tok"; echo CREATE_RC=$?\n'
|
||||
'_wait_started %s 0; echo WRAPPER_RC=$?\n'
|
||||
) % (shlex.quote(str(live.WRAPPER)), shlex.quote(sys.executable), shlex.quote(str(jobs)),
|
||||
shlex.quote(env["DELEGATE_JOB_LOGS_DIR"]), shlex.quote(token), jid, jid)
|
||||
res = subprocess.run(["bash", "-c", script], env=env, cwd=str(tmp_path),
|
||||
capture_output=True, text=True, timeout=60)
|
||||
out = res.stdout + res.stderr
|
||||
c = int(out.split("CREATE_RC=")[1].split()[0]); w = int(out.split("WRAPPER_RC=")[1].split()[0])
|
||||
return c, w, out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario,expected", [
|
||||
("current", 0), ("old_audit", 1), ("old_audit_and_stale_wire", 1),
|
||||
("new_dispatch_received", 0), ("invalid_hmac", 1),
|
||||
])
|
||||
def test_w7_remote_audit_is_bound_to_the_dispatch_being_waited_for(tmp_path, monkeypatch, scenario, expected):
|
||||
"""W7 (codex's five cases, real paths throughout). Attempt 1 under token A
|
||||
leaves a verified audit record; the operator re-dispatches (attempt 2,
|
||||
new token B, nothing edited by hand); waiting for B must not be answered
|
||||
by A's record, even when B's subscriber correctly dropped A's replayed
|
||||
packet - and B's own verified started must still be accepted. Both waits
|
||||
must agree."""
|
||||
live, env, jobs, job_subscriber, mqtt_common, publish_event, registry = _remote_fixture(tmp_path, monkeypatch)
|
||||
jid, secret = "auditjob", "test-only-hmac-secret"
|
||||
registry.register_job("remote audit", job_id=jid, registry_dir=str(jobs), auth_token=secret,
|
||||
dispatch_token="dispatch-A", transport="remote",
|
||||
broker={"host": "127.0.0.1", "port": 9, "tls": False})
|
||||
w1 = job_subscriber._Watcher({jid}, {jid: secret}, {jid: 0}, {jid: "dispatch-A"}, {jid: 1})
|
||||
_receive(publish_event, w1, jid, "dispatch-A", 1, 1, "wrong-secret" if scenario == "invalid_hmac" else secret)
|
||||
recs = list(mqtt_common.iter_logged_events(jid, env["DELEGATE_JOB_LOGS_DIR"]))
|
||||
assert sum(r.get("source_event") == "started" for r in recs) == (0 if scenario == "invalid_hmac" else 1), recs
|
||||
token = "dispatch-A"
|
||||
if scenario not in ("current", "invalid_hmac"):
|
||||
job_subscriber._sync_remote_status(jid, "completed", str(jobs), token)
|
||||
assert registry._operator_write(jid, str(jobs), status="pending", attempt_id=2, iteration=2) == 0
|
||||
record = mqtt_common.load_job(jid, str(jobs))
|
||||
token = record["dispatch_token"]
|
||||
assert token != "dispatch-A" and record["attempt_id"] == 2, record
|
||||
w2 = job_subscriber._Watcher({jid}, {jid: secret}, {jid: 0}, {jid: token}, {jid: 2})
|
||||
if scenario == "old_audit_and_stale_wire":
|
||||
_receive(publish_event, w2, jid, "dispatch-A", 1, 2, secret) # A's packet replayed to B's subscriber
|
||||
assert w2.events.empty(), "the subscriber must drop the stale dispatch"
|
||||
elif scenario == "new_dispatch_received":
|
||||
_receive(publish_event, w2, jid, token, 2, 2, secret)
|
||||
assert not w2.events.empty()
|
||||
assert not (jobs / ("%s.events.log" % jid)).exists(), "remote evidence comes only from the subscriber's audit"
|
||||
c, w, out = _both_waits(live, env, tmp_path, jobs, jid, token)
|
||||
assert (c, w) == (expected, expected), (scenario, c, w, out)
|
||||
|
||||
|
||||
def test_w8_a_record_without_identity_fails_closed_when_a_token_is_demanded(tmp_path, monkeypatch):
|
||||
"""W8: an audit record from before dispatch_fingerprint existed (verified,
|
||||
no fingerprint) names no dispatch: rejected when a token is demanded,
|
||||
accepted when none is (an untokened legacy job), exactly as before."""
|
||||
live, env, jobs, job_subscriber, mqtt_common, publish_event, registry = _remote_fixture(tmp_path, monkeypatch)
|
||||
jid = "legacyjob"
|
||||
registry.register_job("legacy", job_id=jid, registry_dir=str(jobs), auth_token=None,
|
||||
dispatch_token="dispatch-L", transport="remote",
|
||||
broker={"host": "127.0.0.1", "port": 9, "tls": False})
|
||||
mqtt_common.append_event(jid, {"event": "received", "source_event": "started", "seq": 1,
|
||||
"topic": "t", "timestamp": "t", "detail": "",
|
||||
"dispatch_verified": True}, env["DELEGATE_JOB_LOGS_DIR"])
|
||||
c, w, out = _both_waits(live, env, tmp_path, jobs, jid, "dispatch-L")
|
||||
assert (c, w) == (1, 1), ("token demanded: a record with no identity must not count", out)
|
||||
c, w, out = _both_waits(live, env, tmp_path, jobs, jid, "")
|
||||
assert (c, w) == (0, 0), ("no token demanded: compatibility path", out)
|
||||
|
||||
|
||||
def test_s7n_the_wrappers_wait_started_accepts_the_real_producers_line(mam_sandbox):
|
||||
"""S7n (req. 2): the delegate-job wrapper's _wait_started, on the same
|
||||
real-producer started (token in data), accepts it; on a stale token it
|
||||
does not - via the shared predicate."""
|
||||
tmp_path = mam_sandbox
|
||||
jobs = _seed_job(tmp_path, "job1", started_token="tok-1")
|
||||
lib = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
wrapper = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||
env = _producer_env(tmp_path)
|
||||
env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path),
|
||||
"MAM_DELEGATE_JOB_NO_MAIN": "1"})
|
||||
# the record stays exactly as the real producer left it (`running`):
|
||||
# review R1 found the previous version resetting it to `pending`, which
|
||||
# hid the wrapper's status bypass behind the predicate
|
||||
script = ('source "%s"\nsource "%s"\n'
|
||||
'PY="$(_delegate_py_bin)"; REGISTRY_DIR="%s"; ACK_TIMEOUT=2; START_TIME=$(date +%%s); TIMEOUT=60\n'
|
||||
'_disp_tok=tok-1; _wait_started job1 2 && echo ACCEPT_OWN\n'
|
||||
'_disp_tok=tok-X; _wait_started job1 2 || echo REJECT_STALE\n') % (lib, wrapper, jobs)
|
||||
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True,
|
||||
cwd=str(tmp_path), env=env, timeout=120)
|
||||
assert "ACCEPT_OWN" in res.stdout, res.stdout + res.stderr
|
||||
assert "REJECT_STALE" in res.stdout, res.stdout + res.stderr
|
||||
|
||||
|
||||
def test_s7d_no_started_marks_the_job_but_keeps_the_session(mam_sandbox):
|
||||
"""S7d: the job failed, the session did not. The handshake is marked
|
||||
`error` and warned about loudly, but a live agent is not killed over a
|
||||
slow onboarding - the same rule this script already applies one step
|
||||
earlier to a TUI that never signalled ready."""
|
||||
res, published = _run_delivery(mam_sandbox, 0)
|
||||
assert res.returncode == 1, res.stdout + res.stderr
|
||||
assert "DELIVERED" not in res.stdout
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert published == ["error"], published
|
||||
assert "did not acknowledge the onboarding prompt" in res.stderr
|
||||
assert "preserved" in res.stderr
|
||||
|
||||
|
||||
def test_s7e_a_started_from_another_dispatch_is_not_evidence(mam_sandbox):
|
||||
"""S7e: a loop job keeps one events.log across attempts, so only a started
|
||||
carrying THIS dispatch token counts."""
|
||||
res, _ = _run_delivery(mam_sandbox, 0, started_token="tok-OTHER")
|
||||
assert res.returncode == 1, res.stdout + res.stderr
|
||||
carrying THIS dispatch token counts. The stale line is made by the real
|
||||
producer under the record's *previous* token, then the record is
|
||||
re-dispatched (token rotated) before the waiter looks."""
|
||||
tmp_path = mam_sandbox
|
||||
jobs = tmp_path / ".mam" / "jobs"
|
||||
jobs.mkdir(parents=True, exist_ok=True)
|
||||
env = _producer_env(tmp_path)
|
||||
subprocess.run([sys.executable, REGISTRY_PY, "--registry-dir", str(jobs), "register",
|
||||
"--prompt", "x", "--agent", "claude-code", "--agent-session", "herdr:x",
|
||||
"--dispatch-token", "tok-OLD", "--job-id", "job1", "--ack-timeout", "2"],
|
||||
check=True, capture_output=True, text=True, env=env)
|
||||
subprocess.run([sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", "job1",
|
||||
"--event", "started", "--expect-dispatch-token", "tok-OLD", "--attempt-id", "1"],
|
||||
check=True, capture_output=True, text=True, env=env, timeout=60)
|
||||
rec = jobs / "job1.json"
|
||||
d = json.loads(rec.read_text()); d["dispatch_token"] = "tok-1" # the re-dispatch; status untouched
|
||||
rec.write_text(json.dumps(d))
|
||||
res, published = _run_delivery(mam_sandbox, 0, seed_events=False)
|
||||
assert published == ["error"], (published, res.stderr)
|
||||
|
||||
|
||||
def test_s8_rc4_is_a_warning_and_the_started_wait_decides(mam_sandbox):
|
||||
@@ -564,8 +1165,8 @@ def test_s8_rc4_is_a_warning_and_the_started_wait_decides(mam_sandbox):
|
||||
assert published == [], published
|
||||
|
||||
res, published = _run_delivery(mam_sandbox, 4)
|
||||
assert res.returncode == 1, res.stdout + res.stderr
|
||||
assert published == ["error"], published
|
||||
assert "did not acknowledge the onboarding prompt" in res.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rc", [1, 2, 3])
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Issue #24, resume path: start-up dialogs are answered for every agent.
|
||||
|
||||
resume_session.sh called handle_startup_dialogs only for claude and passed no
|
||||
agent, so a resumed agy/grok/codex sat on its directory-trust prompt exactly as
|
||||
the create path did before this branch. These fix the call contract resume
|
||||
now shares with create.
|
||||
|
||||
What is real: the shipped create/stop/resume scripts run end to end against
|
||||
the mock herdr in a sandbox. What is not: the mock's pane always shows a
|
||||
ready banner, so no test here drives a real trust dialog - the create-side
|
||||
tests (test_issue23_24_startup_path.py, S4-S6) fix which keys each dialog
|
||||
gets, and R4 shows the same shipped function is now reached from resume.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = ("multi-agent-mux-create/scripts/create_session.sh",
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh")
|
||||
|
||||
|
||||
def _script(tmp_path, rel):
|
||||
return str(tmp_path / ".agents" / "skills" / rel)
|
||||
|
||||
|
||||
def _create_and_stop(tmp_path, agent, name):
|
||||
ws = str(tmp_path / ("ws_" + name))
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
create, stop, _ = (_script(tmp_path, s) for s in SCRIPTS)
|
||||
subprocess.run(["bash", create, "--workspace", ws, "--agent", agent,
|
||||
"--role", "creator", "--session", name, "--no-onboard"],
|
||||
check=True, cwd=str(tmp_path), capture_output=True, text=True)
|
||||
subprocess.run(["bash", stop, "--session", name, "--agent", agent],
|
||||
check=True, cwd=str(tmp_path), capture_output=True, text=True)
|
||||
return ws
|
||||
|
||||
|
||||
def _resume(tmp_path, agent, name, ws, extra=()):
|
||||
_, _, resume = (_script(tmp_path, s) for s in SCRIPTS)
|
||||
return subprocess.run(["bash", resume, "--workspace", ws, "--agent", agent,
|
||||
"--session", name, *extra],
|
||||
cwd=str(tmp_path), capture_output=True, text=True)
|
||||
|
||||
|
||||
def _record_dialog_calls(tmp_path):
|
||||
"""Override handle_startup_dialogs in the SANDBOX copy of lib.sh (never the
|
||||
repo's) so the call resume makes - session, timeout, agent, MAM_AUTO_TRUST
|
||||
- is written down. Bash keeps the last definition, so appending wins."""
|
||||
log = tmp_path / "dialog_calls.log"
|
||||
lib = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
with open(lib, "a") as f:
|
||||
f.write('\nhandle_startup_dialogs() {\n'
|
||||
' printf \'%%s\\n\' "sess=$1 timeout=${2:-} agent=${3:-<none>} '
|
||||
'auto_trust=${MAM_AUTO_TRUST:-<unset>}" >> "%s"\n'
|
||||
'}\n' % log)
|
||||
return log
|
||||
|
||||
|
||||
def _calls(log):
|
||||
return log.read_text().splitlines() if log.exists() else []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent", ["agy", "codex"])
|
||||
def test_r1_resume_answers_dialogs_for_a_non_claude_agent_and_names_it(
|
||||
mam_sandbox, mock_herdr, mock_agents, agent):
|
||||
"""R1: the gate is gone and the agent is passed - the two halves of the
|
||||
create contract resume was missing."""
|
||||
tmp_path = mam_sandbox
|
||||
name = "test-r1-" + agent
|
||||
ws = _create_and_stop(tmp_path, agent, name)
|
||||
log = _record_dialog_calls(tmp_path) # armed after create, so only resume is recorded
|
||||
res = _resume(tmp_path, agent, name, ws)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert _calls(log) == ["sess=%s timeout=20 agent=%s auto_trust=1" % (name, agent)]
|
||||
|
||||
|
||||
def test_r2_claude_keeps_its_dialog_handling(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""R2: claude is still handled, now by the same call as everyone else."""
|
||||
tmp_path = mam_sandbox
|
||||
ws = _create_and_stop(tmp_path, "claude", "test-r2-claude")
|
||||
log = _record_dialog_calls(tmp_path)
|
||||
res = _resume(tmp_path, "claude", "test-r2-claude", ws)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert _calls(log) == ["sess=test-r2-claude timeout=20 agent=claude auto_trust=1"]
|
||||
|
||||
|
||||
def test_r3_no_auto_trust_reaches_the_dialog_handler(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""R3: --no-auto-trust on resume sets MAM_AUTO_TRUST=0 for the handler,
|
||||
the same switch with the same meaning as create's."""
|
||||
tmp_path = mam_sandbox
|
||||
ws = _create_and_stop(tmp_path, "codex", "test-r3-codex")
|
||||
log = _record_dialog_calls(tmp_path)
|
||||
res = _resume(tmp_path, "codex", "test-r3-codex", ws, extra=("--no-auto-trust",))
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert _calls(log) == ["sess=test-r3-codex timeout=20 agent=codex auto_trust=0"]
|
||||
|
||||
|
||||
def test_r4_the_shipped_handler_really_runs_for_a_non_claude_resume(
|
||||
mam_sandbox, mock_herdr, mock_agents):
|
||||
"""R4 (no override): with the real handle_startup_dialogs, a codex resume
|
||||
completes and the pane is read after the spawn - the handler was reached
|
||||
and returned on the ready banner. Before this change no pane read happened
|
||||
between new-session and the YAML update for a non-claude agent."""
|
||||
tmp_path = mam_sandbox
|
||||
ws = _create_and_stop(tmp_path, "codex", "test-r4-codex")
|
||||
before = len(json.load(open(mock_herdr)).get("calls", []))
|
||||
t0 = time.monotonic()
|
||||
res = _resume(tmp_path, "codex", "test-r4-codex", ws)
|
||||
elapsed = time.monotonic() - t0
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
# Review B-1: the handler used to idle its whole 20 s timeout here because
|
||||
# it had no ready tokens on the resume path. rc 0 alone cannot tell early
|
||||
# return from timeout; the clock can.
|
||||
assert elapsed < 12, "resume took %.1fs: the dialog handler idled to its timeout" % elapsed
|
||||
calls = json.load(open(mock_herdr)).get("calls", [])[before:]
|
||||
|
||||
def _has(c, *verbs): # the mock records argv with a leading `--session <name>`
|
||||
return any(tuple(c[i:i + len(verbs)]) == verbs for i in range(len(c)))
|
||||
|
||||
spawn = next(i for i, c in enumerate(calls) if _has(c, "agent", "start"))
|
||||
reads = [c for c in calls[spawn + 1:] if _has(c, "agent", "read") or _has(c, "pane", "read")]
|
||||
assert reads, "no pane read after the spawn: handle_startup_dialogs never ran\n%s" % calls
|
||||
@@ -692,22 +692,90 @@ def test_g9_loop_package_imports_only_delegation_among_siblings():
|
||||
assert not offenders, offenders
|
||||
|
||||
|
||||
# The G9 guard reads CODE, not prose. Its claim is that no frozen lib.sh
|
||||
# helper is *invoked* from lib_py/loop; a comment or docstring that names
|
||||
# lib.sh (say, to point at the shell twin of a function so the two stay in
|
||||
# sync) invokes nothing. Every way Python could actually reach a lib.sh helper
|
||||
# - a path or `source` inside a subprocess argv, an identifier - lives in code
|
||||
# text, which is still scanned in full, so the guard loses none of its reach.
|
||||
# History: it first fired on `hashlib.sha256` (a word boundary fixed that),
|
||||
# then on a docstring cross-reference in watcher.py; both were mentions.
|
||||
_G9_PATTERNS = {
|
||||
"ensure_subscriber_daemon": r"ensure_subscriber_daemon",
|
||||
"preexec_fn": r"preexec_fn",
|
||||
"lib.sh": r"\blib\.sh\b", # word boundary: `hashlib.sha256` is not lib.sh
|
||||
}
|
||||
|
||||
|
||||
def _code_only(src):
|
||||
"""`src` as code only: the docstring expression statements are removed from
|
||||
the AST and the rest is unparsed, which also drops comments (they are not
|
||||
in the AST). Only the docstring *node* goes, so a statement that shares
|
||||
its line - a docstring followed by `; call()` - is kept, as are string literals in code
|
||||
(a path or `source` in a subprocess argv is exactly what must be caught).
|
||||
Review R2: blanking the docstring's whole line range hid same-line code."""
|
||||
import ast
|
||||
|
||||
tree = ast.parse(src)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
body = getattr(node, "body", None) or []
|
||||
first = body[0] if body else None
|
||||
if (isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant)
|
||||
and isinstance(first.value.value, str)):
|
||||
del body[0]
|
||||
if not body:
|
||||
body.append(ast.Pass())
|
||||
return ast.unparse(tree)
|
||||
|
||||
|
||||
def _g9_offenders(src):
|
||||
import re
|
||||
code = _code_only(src)
|
||||
return [name for name, pat in _G9_PATTERNS.items() if re.search(pat, code)]
|
||||
|
||||
|
||||
def test_g9_phase2a_spawns_no_daemon_and_calls_no_frozen_helper():
|
||||
"""The two phase 1 handoffs stay dormant: preexec_fn is unreachable because
|
||||
nothing here spawns, and no frozen lib.sh helper is invoked."""
|
||||
import re
|
||||
|
||||
# `lib.sh` needs a word boundary: a bare substring search also matches
|
||||
# hashlib.sha256, which is how this guard first fired on innocent code.
|
||||
patterns = {
|
||||
"ensure_subscriber_daemon": re.compile(r"ensure_subscriber_daemon"),
|
||||
"preexec_fn": re.compile(r"preexec_fn"),
|
||||
"lib.sh": re.compile(r"\blib\.sh\b"),
|
||||
}
|
||||
for path in sorted((SKILLS / "lib_py" / "loop").glob("*.py")):
|
||||
src = path.read_text(encoding="utf-8")
|
||||
for name, pattern in patterns.items():
|
||||
assert not pattern.search(src), "{} references {}".format(path.name, name)
|
||||
hits = _g9_offenders(src)
|
||||
assert not hits, "{} references {}".format(path.name, ", ".join(hits))
|
||||
|
||||
|
||||
def test_g9b_the_guard_still_catches_an_invocation_and_ignores_prose():
|
||||
"""The narrowing must not cost the guard its purpose: an invocation-shaped
|
||||
reference in code text still trips it; the same words in a docstring or a
|
||||
comment do not."""
|
||||
invoked = (
|
||||
'import subprocess\n'
|
||||
'def go():\n'
|
||||
' return subprocess.run(["bash", "-c", "source .agents/skills/lib.sh; ensure_subscriber_daemon x"])\n'
|
||||
)
|
||||
assert set(_g9_offenders(invoked)) == {"lib.sh", "ensure_subscriber_daemon"}
|
||||
path_only = 'CMD = ["bash", "/x/.agents/skills/lib.sh"]\n'
|
||||
assert _g9_offenders(path_only) == ["lib.sh"]
|
||||
prose = (
|
||||
'"""Module doc: same convention as lib.sh\'s _herdr_caller_server."""\n'
|
||||
'import hashlib\n'
|
||||
'def f():\n'
|
||||
' """See lib.sh; never call ensure_subscriber_daemon from here."""\n'
|
||||
' # preexec_fn is documented in lib.sh\n'
|
||||
' return hashlib.sha256(b"x").hexdigest()\n'
|
||||
)
|
||||
assert _g9_offenders(prose) == []
|
||||
# Review R2: code sharing a line with a docstring must still be seen -
|
||||
# module, one-line body, one-line def, and the last line of a multi-line
|
||||
# docstring. Only the docstring node is removed, never its line.
|
||||
same_line = [
|
||||
'"""module doc"""; ensure_subscriber_daemon()\n',
|
||||
'def f():\n """function doc"""; ensure_subscriber_daemon()\n',
|
||||
'def f(): """function doc"""; ensure_subscriber_daemon()\n',
|
||||
'def f():\n """multi\n line"""; ensure_subscriber_daemon()\n',
|
||||
]
|
||||
for snippet in same_line:
|
||||
assert _g9_offenders(snippet) == ["ensure_subscriber_daemon"], snippet
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
Reference in new issue
Block a user