feat(issue-50): create --onboard waits for the onboarding job to end (A)
The handshake only said the agent saw the prompt (#23). create_session.sh now waits, after `started`, for the onboarding job to reach a terminal status and makes that its own result, so the orchestrator learns it from create's exit (MULTI_AGENT_RULES §6) instead of guessing from herdr's agent_status: - lib_py/delegation/wrapper.py: `wait-terminal` (completed|error|cancelled, `timeout` after --timeout, `missing` when the record is gone; stops with its caller via --watch-pid) and `onboard-exit` (completed 0, error or cancelled 44, timeout or missing 45). bash branches on the printed answer. - lib.sh: delegate_wait_terminal / delegate_onboard_exit, thin calls. - create_session.sh: `onboarding completed: job <id> (<n>s)` on stdout, or `last_visible_status: onboarding-<status>` on the row, the reason on stderr and `exit 44` / `exit 45` after `trap - EXIT` - the session is kept, as for a missing handshake. The "no started" path (error, exit 0 before) now ends with 44. MAM_ONBOARD_WAIT_S (default 600, floor 1) bounds the wait. - worktree_prepare.sh: 44/45 pass through with "session and worktree kept". - create SKILL.md exit table (44, 45) and the knob; the #42 inventory follows. - tests/conftest.py: --onboard is the default, so ~60 sandbox create tests reach the new wait. The mock herdr now acts as the agent for create's onboarding prompt - it runs the publish commands the prompt names through the real publish_event.py - and MAM_ONBOARD_WAIT_S=3 caps a test that turns that off (MAM_MOCK_ONBOARD_HANDSHAKE=0 -> 44, `started` -> 45). The delegate-job wrapper's prompt has another shape and is never answered. - tests/test_issue50_onboard.py: the decision tables, create in the sandbox (0 / 44 / 45, session kept, row note, trap window), the knob's integer rule, worktree_prepare's pass-through. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
40293841e7
commit
c29c5b2142
9 files changed
+367
-14
No files matched your search
@@ -2381,6 +2381,23 @@ delegate_wait_started() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# delegate_wait_terminal <job_id> <timeout_s>
|
||||||
|
# Issue #50: prints how the job ended - completed|error|cancelled - or
|
||||||
|
# `timeout` when it has not ended after <timeout_s>, or `missing` when its
|
||||||
|
# record is gone. The decision is lib_py.delegation.wrapper wait-terminal.
|
||||||
|
delegate_wait_terminal() {
|
||||||
|
local job_id="$1" timeout_s="$2"
|
||||||
|
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
||||||
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.wrapper wait-terminal \
|
||||||
|
--job "$job_id" --registry-dir "$reg_dir" --timeout "$timeout_s" --watch-pid "$$"
|
||||||
|
}
|
||||||
|
|
||||||
|
# delegate_onboard_exit <status>
|
||||||
|
# Issue #50: create's exit code for an onboarding status (0, 44 or 45).
|
||||||
|
delegate_onboard_exit() {
|
||||||
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.wrapper onboard-exit "$1"
|
||||||
|
}
|
||||||
|
|
||||||
# ensure_subscriber_daemon <job_id> [workdir]
|
# ensure_subscriber_daemon <job_id> [workdir]
|
||||||
# Start the monitor subscriber for this workspace unless one is already running.
|
# Start the monitor subscriber for this workspace unless one is already running.
|
||||||
# Prints the PID(s) on stdout, exactly as start_watchdog always did.
|
# Prints the PID(s) on stdout, exactly as start_watchdog always did.
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ no answer) cannot pass for "no". The wrapper uses --print.
|
|||||||
finalize-begin / finalize-end ... finalize_unfinished, split around the one
|
finalize-begin / finalize-end ... finalize_unfinished, split around the one
|
||||||
herdr diagnostic probe bash still runs
|
herdr diagnostic probe bash still runs
|
||||||
|
|
||||||
|
Issue #50 (create --onboard waits for the onboarding to end):
|
||||||
|
wait-terminal --job J --registry-dir R --timeout T [--watch-pid P]
|
||||||
|
prints the job's terminal status, or
|
||||||
|
`timeout` / `missing`
|
||||||
|
onboard-exit <status> create's exit code for it: 0, 44, 45
|
||||||
|
|
||||||
finalize-end answers on fd 3 (stdout when fd 3 is closed): its stdout carries
|
finalize-end answers on fd 3 (stdout when fd 3 is closed): its stdout carries
|
||||||
registry.py's close output to the wrapper's stdout, as before. It runs as a
|
registry.py's close output to the wrapper's stdout, as before. It runs as a
|
||||||
child of the wrapper and must not outlive it (review R1): with --watch-pid it
|
child of the wrapper and must not outlive it (review R1): with --watch-pid it
|
||||||
@@ -48,6 +54,10 @@ SUB_RC_COMPLETED, SUB_RC_ERROR, SUB_RC_NO_EVENT, SUB_RC_BROKER_UNAVAILABLE, SUB_
|
|||||||
|
|
||||||
TERMINAL = ("completed", "error", "cancelled")
|
TERMINAL = ("completed", "error", "cancelled")
|
||||||
EXIT_CODES = {"completed": 0, "error": 1, "timeout": 2, "broker_unavailable": 3, "cancelled": 5}
|
EXIT_CODES = {"completed": 0, "error": 1, "timeout": 2, "broker_unavailable": 3, "cancelled": 5}
|
||||||
|
# Issue #50: create --onboard's exit code for how the onboarding job ended.
|
||||||
|
# 44 = the agent ended it with error (or it was cancelled), 45 = it did not end
|
||||||
|
# within MAM_ONBOARD_WAIT_S (or its record is gone). The session is kept either way.
|
||||||
|
ONBOARD_EXIT_CODES = {"completed": 0, "error": 44, "cancelled": 44, "timeout": 45, "missing": 45}
|
||||||
|
|
||||||
|
|
||||||
def _now() -> int:
|
def _now() -> int:
|
||||||
@@ -169,6 +179,30 @@ def _tail(path: str, n: int) -> List[str]:
|
|||||||
return fh.read().splitlines()[-n:]
|
return fh.read().splitlines()[-n:]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Issue #50 A: create --onboard waits for the onboarding job to end
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def onboard_exit(status: str) -> int:
|
||||||
|
return ONBOARD_EXIT_CODES.get(status, 45)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_terminal(a: argparse.Namespace) -> str:
|
||||||
|
"""The job's terminal status once it has one; `timeout` after --timeout s,
|
||||||
|
`missing` when the record is gone. Reads the disk every second."""
|
||||||
|
deadline = _now() + a.timeout
|
||||||
|
record = os.path.join(a.registry_dir, f"{a.job}.json")
|
||||||
|
while True:
|
||||||
|
disk = disk_status(a.registry_dir, a.job)
|
||||||
|
if disk in TERMINAL:
|
||||||
|
return disk
|
||||||
|
if not os.path.isfile(record):
|
||||||
|
return "missing"
|
||||||
|
if _now() >= deadline:
|
||||||
|
return "timeout"
|
||||||
|
_sleep(a, 1)
|
||||||
|
|
||||||
|
|
||||||
def finalize_begin(a: argparse.Namespace) -> str:
|
def finalize_begin(a: argparse.Namespace) -> str:
|
||||||
"""`keep` (FINAL_JOB_STATUS untouched), `done <final>`, or
|
"""`keep` (FINAL_JOB_STATUS untouched), `done <final>`, or
|
||||||
`go|probe <final> <evidence_until> <deadline>`."""
|
`go|probe <final> <evidence_until> <deadline>`."""
|
||||||
@@ -307,6 +341,12 @@ def _parser() -> argparse.ArgumentParser:
|
|||||||
s.add_argument("session")
|
s.add_argument("session")
|
||||||
s.add_argument("agent")
|
s.add_argument("agent")
|
||||||
s.add_argument("--print", action="store_true", help="print yes/no and exit 0")
|
s.add_argument("--print", action="store_true", help="print yes/no and exit 0")
|
||||||
|
s = sub.add_parser("wait-terminal")
|
||||||
|
s.add_argument("--job", required=True)
|
||||||
|
s.add_argument("--registry-dir", required=True)
|
||||||
|
s.add_argument("--timeout", type=int, required=True)
|
||||||
|
s.add_argument("--watch-pid", type=int, default=None, help="the caller's pid; gone -> stop waiting")
|
||||||
|
sub.add_parser("onboard-exit").add_argument("status")
|
||||||
for name in ("finalize-begin", "finalize-end"):
|
for name in ("finalize-begin", "finalize-end"):
|
||||||
s = sub.add_parser(name)
|
s = sub.add_parser(name)
|
||||||
s.add_argument("--job", required=True)
|
s.add_argument("--job", required=True)
|
||||||
@@ -353,6 +393,13 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
return _yes_no(stall_watch(a.session, a.agent), a.print)
|
return _yes_no(stall_watch(a.session, a.agent), a.print)
|
||||||
elif a.cmd == "finalize-begin":
|
elif a.cmd == "finalize-begin":
|
||||||
print(finalize_begin(a))
|
print(finalize_begin(a))
|
||||||
|
elif a.cmd == "wait-terminal":
|
||||||
|
try:
|
||||||
|
print(wait_terminal(a))
|
||||||
|
except OwnerGone:
|
||||||
|
print(f"wait-terminal: caller pid {a.watch_pid} is gone; stopped waiting on job {a.job}", file=sys.stderr)
|
||||||
|
elif a.cmd == "onboard-exit":
|
||||||
|
print(onboard_exit(a.status))
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
_answer(finalize_end(a))
|
_answer(finalize_end(a))
|
||||||
|
|||||||
@@ -91,7 +91,12 @@ The bootstrapped server receives a **sanitized copy of the caller's environment*
|
|||||||
this script applies to a TUI that never signalled ready). Re-send with
|
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
|
`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
|
never re-injected automatically. `MAM_ONBOARD_ACK_S` overrides how long that
|
||||||
wait is — distinct from the wrapper's `MAM_INJECT_ACK_GRACE_S`.
|
wait is — distinct from the wrapper's `MAM_INJECT_ACK_GRACE_S`. After the
|
||||||
|
handshake the script waits for the onboarding job to **end** (Issue #50,
|
||||||
|
`MAM_ONBOARD_WAIT_S`, default 600 s) and prints `onboarding completed: job <id>
|
||||||
|
(<n>s)`; an onboarding that ends with `error` exits 44 and one that does not
|
||||||
|
end in time exits 45, both keeping the session, with
|
||||||
|
`last_visible_status: onboarding-<status>` on the row.
|
||||||
|
|
||||||
**Start-up dialogs** are answered for every agent, not only claude (#24). The
|
**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
|
blocking-modal signatures and their accept keys live in each agent adapter
|
||||||
@@ -171,6 +176,8 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace
|
|||||||
| `--auto-trust` / `--no-auto-trust` | Answer a start-up directory-trust dialog or leave it to a human (`MAM_AUTO_TRUST`; default: answer) |
|
| `--auto-trust` / `--no-auto-trust` | Answer a start-up directory-trust dialog or leave it to a human (`MAM_AUTO_TRUST`; default: answer) |
|
||||||
| `--isolate` / `--no-isolate` | Legacy, no-op |
|
| `--isolate` / `--no-isolate` | Legacy, no-op |
|
||||||
|
|
||||||
|
`MAM_ONBOARD_WAIT_S` (default 600): how long `--onboard` waits for the onboarding job to end after the `started` handshake.
|
||||||
|
|
||||||
| Exit | Meaning |
|
| Exit | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 0 | Created; or `--dry-run` |
|
| 0 | Created; or `--dry-run` |
|
||||||
@@ -180,6 +187,8 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace
|
|||||||
| 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back |
|
| 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back |
|
||||||
| 42 | The TUI never became ready and the process is gone, or it is alive and `MAM_KILL_ON_READY_TIMEOUT=1` (rolled back) |
|
| 42 | The TUI never became ready and the process is gone, or it is alive and `MAM_KILL_ON_READY_TIMEOUT=1` (rolled back) |
|
||||||
| 43 | The rollback could not kill the herdr session; it is still running |
|
| 43 | The rollback could not kill the herdr session; it is still running |
|
||||||
|
| 44 | The onboarding job ended with `error` (or was cancelled); the session is kept (Issue #50) |
|
||||||
|
| 45 | The onboarding job did not end within `MAM_ONBOARD_WAIT_S`, or its record is gone; the session is kept |
|
||||||
| other | A later step failed and its code is passed through. The EXIT trap (`cleanup_herdr_on_error`) rolls back the herdr session and closes the onboarding job as `cancelled`; if that kill fails, the exit is 43 instead |
|
| other | A later step failed and its code is passed through. The EXIT trap (`cleanup_herdr_on_error`) rolls back the herdr session and closes the onboarding job as `cancelled`; if that kill fails, the exit is 43 instead |
|
||||||
|
|
||||||
## Registering the session in agent-sessions.yaml
|
## Registering the session in agent-sessions.yaml
|
||||||
@@ -201,7 +210,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \
|
|||||||
- `--creator-agent` creates the creator through this skill with the control root's binding and `MAM_NO_SUBSCRIBER_DAEMON=1`; the row's `pane.cwd` is the worktree.
|
- `--creator-agent` creates the creator through this skill with the control root's binding and `MAM_NO_SUBSCRIBER_DAEMON=1`; the row's `pane.cwd` is the worktree.
|
||||||
- Prints `BASE=<sha>`: record it **before** delegating the implementation, then review with `run_loop.sh --worktree <WT> --base "$BASE" --review-only`.
|
- Prints `BASE=<sha>`: record it **before** delegating the implementation, then review with `run_loop.sh --worktree <WT> --base "$BASE" --review-only`.
|
||||||
- A submodule path that is not initialised yet but already holds files refuses `--submodules` up front (exit 3, nothing changed): the clone would fail on it and a rollback could not tell those files from its own.
|
- A submodule path that is not initialised yet but already holds files refuses `--submodules` up front (exit 3, nothing changed): the clone would fail on it and a rollback could not tell those files from its own.
|
||||||
- Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule, 3 refused to protect existing content (nothing changed).
|
- Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule, 3 refused to protect existing content (nothing changed); 44/45 are create's (onboarding error / timeout with `--onboard`; session and worktree kept).
|
||||||
- create itself: a control-plane binding that cannot be computed is refused (exit 2) for a `--workspace` other than the control root and warned about for the control root; the immediate reconcile and the watchdog run the control root's code.
|
- create itself: a control-plane binding that cannot be computed is refused (exit 2) for a `--workspace` other than the control root and warned about for the control root; the immediate reconcile and the watchdog run the control root's code.
|
||||||
|
|
||||||
## Pitfalls
|
## Pitfalls
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
# 4 = agent-sessions.yaml append failure
|
# 4 = agent-sessions.yaml append failure
|
||||||
# 42 = agent TUI never became ready and the process is gone
|
# 42 = agent TUI never became ready and the process is gone
|
||||||
# 43 = rollback could not kill the herdr session; it is still running (#23)
|
# 43 = rollback could not kill the herdr session; it is still running (#23)
|
||||||
|
# 44 = the onboarding job ended with error (or was cancelled); the session is kept (#50)
|
||||||
|
# 45 = the onboarding job did not end within MAM_ONBOARD_WAIT_S; the session is kept (#50)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -316,6 +318,21 @@ for s in d.get('herdr_sessions', []):
|
|||||||
PYEOF
|
PYEOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# _mark_row_note <note>
|
||||||
|
# Issue #50: last_visible_status only. The row stays `running` with its
|
||||||
|
# delegate_job_id: the session is alive and the job record is the evidence.
|
||||||
|
_mark_row_note() {
|
||||||
|
local note="$1"
|
||||||
|
[ "${ROW_APPENDED:-0}" = "1" ] || return 0
|
||||||
|
atomic_dump_yaml "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" ROW_NOTE="$note" <<'PYEOF' || true
|
||||||
|
name = os.environ['SESSION_NAME']
|
||||||
|
for s in d.get('herdr_sessions', []):
|
||||||
|
if s.get('name') == name:
|
||||||
|
s['last_visible_status'] = os.environ['ROW_NOTE']
|
||||||
|
break
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
# Trap for rolling back/cleaning up herdr session if script exits due to error
|
# Trap for rolling back/cleaning up herdr session if script exits due to error
|
||||||
cleanup_herdr_on_error() {
|
cleanup_herdr_on_error() {
|
||||||
local exit_code=$?
|
local exit_code=$?
|
||||||
@@ -698,12 +715,38 @@ If you delegate any part of it with multi-agent-mux-delegate-job submit, add --p
|
|||||||
esac
|
esac
|
||||||
JOB_TERMINALIZED=1
|
JOB_TERMINALIZED=1
|
||||||
fi
|
fi
|
||||||
|
# Issue #50: the handshake only said the agent saw the prompt. Wait for the
|
||||||
|
# onboarding itself to end; its result is this script's exit code, so the
|
||||||
|
# orchestrator learns it from create (MULTI_AGENT_RULES §6) instead of
|
||||||
|
# guessing from herdr's agent_status. A job marked `error` just above is
|
||||||
|
# terminal and returns at once.
|
||||||
|
ONBOARD_WAIT_S=$(mam_int_knob MAM_ONBOARD_WAIT_S 600 1)
|
||||||
|
ONBOARD_T0=$(date +%s)
|
||||||
|
ONBOARD_STATUS="$(delegate_wait_terminal "$DELEGATE_JOB_ID" "$ONBOARD_WAIT_S")"
|
||||||
|
ONBOARD_STATUS="${ONBOARD_STATUS:-missing}"
|
||||||
# Trigger immediate priority reconcile cycle asynchronously
|
# Trigger immediate priority reconcile cycle asynchronously
|
||||||
# Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the
|
# Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the
|
||||||
# (unreviewed) copy in a worktree --workspace; identical paths by default.
|
# (unreviewed) copy in a worktree --workspace; identical paths by default.
|
||||||
(bash "$SKILL_DIR/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true
|
(bash "$SKILL_DIR/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true
|
||||||
WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE_ROOT")
|
WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE_ROOT")
|
||||||
echo "watchdog PID: $WD_PID"
|
echo "watchdog PID: $WD_PID"
|
||||||
|
case "$ONBOARD_STATUS" in
|
||||||
|
completed)
|
||||||
|
echo "onboarding completed: job $DELEGATE_JOB_ID ($(( $(date +%s) - ONBOARD_T0 ))s)"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# The session is kept, as for a missing handshake above: the row says
|
||||||
|
# what happened and the exit code tells the caller. The trap is cleared
|
||||||
|
# first - a non-zero exit must not roll a live session back.
|
||||||
|
_mark_row_note "onboarding-$ONBOARD_STATUS"
|
||||||
|
echo "ERROR: onboarding $ONBOARD_STATUS for '$SESSION_NAME': job $DELEGATE_JOB_ID; the session is kept" >&2
|
||||||
|
trap - EXIT
|
||||||
|
case "$(delegate_onboard_exit "$ONBOARD_STATUS")" in
|
||||||
|
44) exit 44 ;;
|
||||||
|
*) exit 45 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
# Clear cleanup trap as registration was fully successful
|
# Clear cleanup trap as registration was fully successful
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
# creator session registered in the control root's .mam -> summary with BASE=.
|
# creator session registered in the control root's .mam -> summary with BASE=.
|
||||||
#
|
#
|
||||||
# Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule,
|
# Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule,
|
||||||
# 3 refused to protect existing content (nothing changed).
|
# 3 refused to protect existing content (nothing changed); 44/45 are create's
|
||||||
|
# (onboarding error / timeout; session and worktree kept, Issue #50).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -239,7 +240,11 @@ if [ -n "$CREATOR_AGENT" ]; then
|
|||||||
rc=0
|
rc=0
|
||||||
env "${env_add[@]}" bash "$SKILL_DIR/multi-agent-mux-create/scripts/create_session.sh" "${args[@]}" || rc=$?
|
env "${env_add[@]}" bash "$SKILL_DIR/multi-agent-mux-create/scripts/create_session.sh" "${args[@]}" || rc=$?
|
||||||
if [ "$rc" -ne 0 ]; then
|
if [ "$rc" -ne 0 ]; then
|
||||||
echo "ERROR: create_session.sh failed (rc=$rc); the worktree is kept - remove it with worktree_remove.sh --path '$WT'" >&2
|
case "$rc" in
|
||||||
|
44) echo "ERROR: onboarding error for '$SESSION' (create_session.sh rc=44); session and worktree kept" >&2 ;;
|
||||||
|
45) echo "ERROR: onboarding timeout for '$SESSION' (create_session.sh rc=45); session and worktree kept" >&2 ;;
|
||||||
|
*) echo "ERROR: create_session.sh failed (rc=$rc); the worktree is kept - remove it with worktree_remove.sh --path '$WT'" >&2 ;;
|
||||||
|
esac
|
||||||
exit "$rc"
|
exit "$rc"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -281,6 +281,12 @@ def mam_sandbox(tmp_path, monkeypatch, request):
|
|||||||
# Create-specific on purpose: MAM_INJECT_ACK_GRACE_S is the delegate-job
|
# 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.
|
# wrapper's knob and its own tests assert on the value they pass.
|
||||||
monkeypatch.setenv("MAM_ONBOARD_ACK_S", "3")
|
monkeypatch.setenv("MAM_ONBOARD_ACK_S", "3")
|
||||||
|
# Issue #50: create now waits for the onboarding job to end as well. The
|
||||||
|
# mock herdr acts as the agent for create's onboarding prompt (it runs the
|
||||||
|
# publish commands the prompt names, through the real publish_event.py),
|
||||||
|
# so the wait ends at once; this cap is for a test that turns that off
|
||||||
|
# (MAM_MOCK_ONBOARD_HANDSHAKE=0 / started) and expects 44 / 45.
|
||||||
|
monkeypatch.setenv("MAM_ONBOARD_WAIT_S", "3")
|
||||||
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
|
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
|
||||||
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
|
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
|
||||||
monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False)
|
monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False)
|
||||||
@@ -356,6 +362,27 @@ if os.path.exists(state_file):
|
|||||||
pass
|
pass
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
def _onboard_handshake(text):
|
||||||
|
# Issue #50: create's onboarding prompt names the publish commands it
|
||||||
|
# expects the agent to run. This fake agent runs them - through the real
|
||||||
|
# publish_event.py, as a real agent would - so create's wait for the
|
||||||
|
# onboarding to end is satisfied. MAM_MOCK_ONBOARD_HANDSHAKE=0 publishes
|
||||||
|
# nothing (create exits 44: no started), `started` publishes only the
|
||||||
|
# first (create exits 45: never completed). The delegate-job wrapper's
|
||||||
|
# prompt has another shape and is never answered here.
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
mode = os.environ.get("MAM_MOCK_ONBOARD_HANDSHAKE", "1")
|
||||||
|
if mode == "0" or "On start run:" not in text:
|
||||||
|
return
|
||||||
|
for label in ("On start run:", "On success run:"):
|
||||||
|
m = re.search(r"^" + re.escape(label) + r"\\s+(.+?)\\.?\\s*$", text, re.M)
|
||||||
|
if m:
|
||||||
|
subprocess.run(["bash", "-c", m.group(1)], stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL, timeout=120)
|
||||||
|
if mode == "started":
|
||||||
|
break
|
||||||
|
|
||||||
# Record the command call
|
# Record the command call
|
||||||
state.setdefault("calls", []).append(sys.argv[1:])
|
state.setdefault("calls", []).append(sys.argv[1:])
|
||||||
try:
|
try:
|
||||||
@@ -659,6 +686,7 @@ elif cmd1 == "pane":
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
pane_id = args[2]
|
pane_id = args[2]
|
||||||
text = args[3]
|
text = args[3]
|
||||||
|
_onboard_handshake(text)
|
||||||
found = False
|
found = False
|
||||||
for a_name, data in state.get("agents", {}).items():
|
for a_name, data in state.get("agents", {}).items():
|
||||||
if data.get("pane_id") == pane_id or a_name == pane_id:
|
if data.get("pane_id") == pane_id or a_name == pane_id:
|
||||||
@@ -1147,6 +1175,7 @@ elif cmd1 == "agent":
|
|||||||
target_agent = agents[matched_k]
|
target_agent = agents[matched_k]
|
||||||
target_agent["sent_text"] = target_agent.get("sent_text", "") + text
|
target_agent["sent_text"] = target_agent.get("sent_text", "") + text
|
||||||
target_agent["buffer"] = target_agent.get("buffer", "") + "\\n" + text + "\\n\\nesc to interrupt"
|
target_agent["buffer"] = target_agent.get("buffer", "") + "\\n" + text + "\\n\\nesc to interrupt"
|
||||||
|
_onboard_handshake(text)
|
||||||
if wait_flag:
|
if wait_flag:
|
||||||
p_res = state.get("prompt_wait_result")
|
p_res = state.get("prompt_wait_result")
|
||||||
if p_res is None:
|
if p_res is None:
|
||||||
|
|||||||
@@ -60,8 +60,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 129,
|
"start": 134,
|
||||||
"end": 147,
|
"end": 152,
|
||||||
"lang": "yaml",
|
"lang": "yaml",
|
||||||
"sha": "2dd608d04ae0",
|
"sha": "2dd608d04ae0",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
@@ -74,8 +74,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 153,
|
"start": 158,
|
||||||
"end": 155,
|
"end": 160,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "613003708f19",
|
"sha": "613003708f19",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 193,
|
"start": 202,
|
||||||
"end": 197,
|
"end": 206,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "38836d31bf38",
|
"sha": "38836d31bf38",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
@@ -103,8 +103,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 219,
|
"start": 228,
|
||||||
"end": 241,
|
"end": 250,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "d90cfd2555fd",
|
"sha": "d90cfd2555fd",
|
||||||
"class": "b",
|
"class": "b",
|
||||||
@@ -612,10 +612,12 @@
|
|||||||
"4",
|
"4",
|
||||||
"42",
|
"42",
|
||||||
"43",
|
"43",
|
||||||
|
"44",
|
||||||
|
"45",
|
||||||
"other"
|
"other"
|
||||||
],
|
],
|
||||||
"passthrough": {
|
"passthrough": {
|
||||||
"4": "multi-agent-mux-create/scripts/create_session.sh:515"
|
"4": "multi-agent-mux-create/scripts/create_session.sh:532"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ def test_literal_exit_rule_on_base_scripts():
|
|||||||
and stop's `sys.exit(7)` are not counted."""
|
and stop's `sys.exit(7)` are not counted."""
|
||||||
assert literal_exit_codes("multi-agent-mux-resume/scripts/resume_session.sh") == {"0", "1", "2"}
|
assert literal_exit_codes("multi-agent-mux-resume/scripts/resume_session.sh") == {"0", "1", "2"}
|
||||||
assert literal_exit_codes("multi-agent-mux-stop/scripts/stop_session.sh") == {"0", "1", "2", "3", "4"}
|
assert literal_exit_codes("multi-agent-mux-stop/scripts/stop_session.sh") == {"0", "1", "2", "3", "4"}
|
||||||
assert literal_exit_codes("multi-agent-mux-create/scripts/create_session.sh") == {"0", "1", "2", "3", "42", "43"}
|
assert literal_exit_codes("multi-agent-mux-create/scripts/create_session.sh") == {"0", "1", "2", "3", "42", "43", "44", "45"}
|
||||||
|
|
||||||
|
|
||||||
# 5. Evidence exists.
|
# 5. Evidence exists.
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Issue #50 A: `create --onboard` waits for the onboarding job to end.
|
||||||
|
|
||||||
|
- U: the decisions (lib_py.delegation.wrapper wait-terminal / onboard-exit).
|
||||||
|
- S: create_session.sh in the sandbox (mock herdr whose fake agent answers
|
||||||
|
the onboarding prompt through the real publish_event.py): completed -> 0
|
||||||
|
and a one-line result; error -> 44; never ended -> 45; the session is kept
|
||||||
|
and the row says why; worktree_prepare.sh passes 44/45 through.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SKILLS = REPO_ROOT / ".agents" / "skills"
|
||||||
|
if str(SKILLS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SKILLS))
|
||||||
|
|
||||||
|
from lib_py.delegation import registry, wrapper as decide # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# U - decisions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status,code", [("completed", 0), ("error", 44), ("cancelled", 44),
|
||||||
|
("timeout", 45), ("missing", 45), ("", 45), ("garbage", 45)])
|
||||||
|
def test_u1_onboard_exit_table(status, code):
|
||||||
|
assert decide.onboard_exit(status) == code
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_terminal(reg, job, timeout=1, watch_pid=None):
|
||||||
|
cmd = [sys.executable, "-m", "lib_py.delegation.wrapper", "wait-terminal", "--job", job,
|
||||||
|
"--registry-dir", str(reg), "--timeout", str(timeout)]
|
||||||
|
if watch_pid is not None:
|
||||||
|
cmd += ["--watch-pid", str(watch_pid)]
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True, cwd=str(SKILLS),
|
||||||
|
env=dict(os.environ, PYTHONPATH=str(SKILLS)), timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("terminal", ["completed", "error", "cancelled"])
|
||||||
|
def test_u2_wait_terminal_prints_the_terminal_status_at_once(tmp_path, terminal):
|
||||||
|
jid = registry.register_job(prompt="onboarding", agent="claude-code", agent_session="herdr:s",
|
||||||
|
registry_dir=str(tmp_path))
|
||||||
|
rec = json.loads((tmp_path / f"{jid}.json").read_text())
|
||||||
|
rec["status"] = terminal
|
||||||
|
(tmp_path / f"{jid}.json").write_text(json.dumps(rec))
|
||||||
|
t0 = time.time()
|
||||||
|
res = _wait_terminal(tmp_path, jid, timeout=30)
|
||||||
|
assert res.stdout.strip() == terminal and res.returncode == 0, res.stderr
|
||||||
|
assert time.time() - t0 < 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_u2b_wait_terminal_times_out_on_a_job_that_never_ends(tmp_path):
|
||||||
|
jid = registry.register_job(prompt="onboarding", agent="claude-code", agent_session="herdr:s",
|
||||||
|
registry_dir=str(tmp_path))
|
||||||
|
res = _wait_terminal(tmp_path, jid, timeout=1)
|
||||||
|
assert res.stdout.strip() == "timeout", res.stdout + res.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_u2c_wait_terminal_reports_a_missing_record(tmp_path):
|
||||||
|
res = _wait_terminal(tmp_path, "nothere", timeout=30)
|
||||||
|
assert res.stdout.strip() == "missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_u2d_wait_terminal_stops_with_its_caller(tmp_path):
|
||||||
|
jid = registry.register_job(prompt="onboarding", agent="claude-code", agent_session="herdr:s",
|
||||||
|
registry_dir=str(tmp_path))
|
||||||
|
gone = subprocess.Popen(["true"])
|
||||||
|
gone.wait()
|
||||||
|
res = _wait_terminal(tmp_path, jid, timeout=30, watch_pid=gone.pid)
|
||||||
|
assert res.stdout == "" and "is gone" in res.stderr, res.stdout + res.stderr
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S - create_session.sh
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _create(sandbox, *extra, env=None):
|
||||||
|
script = sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
cmd = ["bash", str(script), "--workspace", str(sandbox), "--agent", "claude", "--role", "creator",
|
||||||
|
"--session", "t50-creator-claude", "--onboard", *extra]
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=300)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(sandbox):
|
||||||
|
(row,) = yaml.safe_load((sandbox / ".mam" / "agent-sessions.yaml").read_text())["herdr_sessions"]
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _job(sandbox, jid):
|
||||||
|
return json.loads((sandbox / ".mam" / "jobs" / f"{jid}.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_state(mock_herdr):
|
||||||
|
return json.loads(Path(mock_herdr).read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def test_s1_create_ends_with_the_onboarding_completed(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
res = _create(mam_sandbox)
|
||||||
|
assert res.returncode == 0, res.stdout + res.stderr
|
||||||
|
row = _row(mam_sandbox)
|
||||||
|
jid = row["delegate_job_id"]
|
||||||
|
assert f"onboarding completed: job {jid} (" in res.stdout, res.stdout
|
||||||
|
assert _job(mam_sandbox, jid)["status"] == "completed"
|
||||||
|
assert not str(row.get("last_visible_status", "")).startswith("onboarding-")
|
||||||
|
assert "onboarding" not in res.stderr.lower() or "WARNING" not in res.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_s2_an_onboarding_that_ends_with_error_exits_44_and_keeps_the_session(mam_sandbox, mock_herdr, mock_agents,
|
||||||
|
monkeypatch):
|
||||||
|
monkeypatch.setenv("MAM_MOCK_ONBOARD_HANDSHAKE", "0") # no agent answer: no started -> error
|
||||||
|
res = _create(mam_sandbox)
|
||||||
|
assert res.returncode == 44, res.stdout + res.stderr
|
||||||
|
row = _row(mam_sandbox)
|
||||||
|
jid = row["delegate_job_id"]
|
||||||
|
assert jid, row
|
||||||
|
assert f"onboarding error for 't50-creator-claude': job {jid}; the session is kept" in res.stderr, res.stderr
|
||||||
|
assert _job(mam_sandbox, jid)["status"] == "error"
|
||||||
|
assert row["status"] == "running" and row["last_visible_status"] == "onboarding-error"
|
||||||
|
state = _mock_state(mock_herdr)
|
||||||
|
assert "t50-creator-claude" in state["agents"], "the session was rolled back"
|
||||||
|
assert not any(c[:1] == ["kill-session"] for c in state["calls"]), "the EXIT trap ran"
|
||||||
|
assert "onboarding completed" not in res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_an_onboarding_that_never_ends_exits_45_and_keeps_the_session(mam_sandbox, mock_herdr, mock_agents,
|
||||||
|
monkeypatch):
|
||||||
|
monkeypatch.setenv("MAM_MOCK_ONBOARD_HANDSHAKE", "started") # started, never completed
|
||||||
|
t0 = time.time()
|
||||||
|
res = _create(mam_sandbox)
|
||||||
|
assert res.returncode == 45, res.stdout + res.stderr
|
||||||
|
row = _row(mam_sandbox)
|
||||||
|
jid = row["delegate_job_id"]
|
||||||
|
assert f"onboarding timeout for 't50-creator-claude': job {jid}; the session is kept" in res.stderr, res.stderr
|
||||||
|
assert _job(mam_sandbox, jid)["status"] == "running"
|
||||||
|
assert row["status"] == "running" and row["last_visible_status"] == "onboarding-timeout"
|
||||||
|
assert "t50-creator-claude" in _mock_state(mock_herdr)["agents"]
|
||||||
|
assert time.time() - t0 < 120, "MAM_ONBOARD_WAIT_S=3 bounds the wait in the sandbox"
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3b_the_wait_knob_follows_the_integer_rule(mam_sandbox, mock_herdr, mock_agents, monkeypatch):
|
||||||
|
monkeypatch.setenv("MAM_MOCK_ONBOARD_HANDSHAKE", "started")
|
||||||
|
monkeypatch.setenv("MAM_ONBOARD_WAIT_S", "0") # below the floor of 1: the default (600) with a warning
|
||||||
|
monkeypatch.setenv("MAM_ONBOARD_ACK_S", "2")
|
||||||
|
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
src = script.read_text()
|
||||||
|
# bound the run: the default would wait 600 s; cap the python wait by a stub of the knob call
|
||||||
|
assert 'ONBOARD_WAIT_S=$(mam_int_knob MAM_ONBOARD_WAIT_S 600 1)' in src
|
||||||
|
script.write_text(src.replace('ONBOARD_WAIT_S=$(mam_int_knob MAM_ONBOARD_WAIT_S 600 1)',
|
||||||
|
'ONBOARD_WAIT_S=$(mam_int_knob MAM_ONBOARD_WAIT_S 600 1); echo "KNOB=$ONBOARD_WAIT_S"; ONBOARD_WAIT_S=1'))
|
||||||
|
res = _create(mam_sandbox)
|
||||||
|
assert "KNOB=600" in res.stdout, res.stdout
|
||||||
|
assert res.returncode == 45
|
||||||
|
|
||||||
|
|
||||||
|
def test_s4_the_source_clears_the_trap_before_the_named_exits():
|
||||||
|
src = (SKILLS / "multi-agent-mux-create" / "scripts" / "create_session.sh").read_text()
|
||||||
|
tail = src.split('_mark_row_note "onboarding-$ONBOARD_STATUS"')[1].split("esac")[0]
|
||||||
|
assert tail.index("trap - EXIT") < tail.index("exit 44") < tail.index("exit 45")
|
||||||
|
assert "delegate_wait_terminal" in src and "delegate_onboard_exit" in src
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S5 - worktree_prepare.sh passes 44 / 45 through with its own message
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _git(repo, *args):
|
||||||
|
subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=t", *args],
|
||||||
|
cwd=str(repo), check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rc,word", [(44, "error"), (45, "timeout")])
|
||||||
|
def test_s5_worktree_prepare_passes_create_exit_through(mam_sandbox, rc, word):
|
||||||
|
main = mam_sandbox
|
||||||
|
_git(main, "init", "-q")
|
||||||
|
(main / "README").write_text("x\n")
|
||||||
|
_git(main, "add", "README")
|
||||||
|
_git(main, "commit", "-q", "-m", "init")
|
||||||
|
stub = main / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
stub.write_text(f'#!/usr/bin/env bash\necho "stub create: $*" >&2\nexit {rc}\n')
|
||||||
|
prep = main / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "worktree_prepare.sh"
|
||||||
|
wt = main.parent / f"{main.name}-wt{rc}"
|
||||||
|
res = subprocess.run(["bash", str(prep), "--path", str(wt), "--branch", f"b-{rc}",
|
||||||
|
"--creator-agent", "claude", "--onboard"],
|
||||||
|
capture_output=True, text=True, cwd=str(main), timeout=300)
|
||||||
|
try:
|
||||||
|
assert res.returncode == rc, res.stdout + res.stderr
|
||||||
|
assert f"onboarding {word} for 'creator-claude-b-{rc}' (create_session.sh rc={rc}); session and worktree kept" in res.stderr, res.stderr
|
||||||
|
assert "worktree_remove.sh" not in res.stderr
|
||||||
|
assert "--onboard" in res.stderr and "stub create" in res.stderr
|
||||||
|
assert (wt / "README").exists(), "the worktree is kept"
|
||||||
|
finally:
|
||||||
|
subprocess.run(["git", "worktree", "remove", "--force", str(wt)], cwd=str(main), capture_output=True)
|
||||||
Reference in new issue
Block a user