- registry.get_feedback only returned the detail of the last completed or
error event, so a sub-job the wrapper cancelled before injection
(target_not_found, session_busy) reached the review table as a bare
`cancelled` and its reason was lost (merge review, grok N1). get_feedback
takes the event kinds to read (default unchanged); the table asks for
cancelled too and shows NOT-DISPATCHED with the reason. Test T13, both
reasons, fails without the fix.
- the [귀속되지 않은 열린 잡] line listed jobs registered without
--parent-job but gave the reviewer no rule for them - the one case the
table cannot show (claude K1). A rule sentence follows the line whenever
such a job exists; without one the prompt is unchanged. T6 pins it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
- mam_herdr: for name lookups (agent / has-session / kill-session) a
HERDR_WORKSPACE_ID that is the caller pane's own workspace (it matches
the HERDR_PANE_ID prefix) is dropped. Live agent names are unique per
server (herdr --skill), so this cannot address a same-named agent of
another workspace; an id the caller chose, or one set outside a pane,
still scopes (H-18/H-21/H-23). The generated shim is unchanged (S11).
- wrapper: a preflight that cannot see the target closes the job
cancelled with detail target_not_found instead of session_busy; the
loop's sub-job table counts it as NOT-DISPATCHED.
- tests: T10-T12 with the measured pane env (same-server socket, caller
w6, target w2); T8 follows the sub-job-armed guard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without --worktree/--base the guard fingerprinted the source on every
loop, so a loop whose source holds untracked files that change on every
job voided each round: test_tier4_e2e scenario 5 keeps the mock herdr's
state file (rewritten on each herdr call) in its repository and failed
with rc 1 on 3fdbb62 (passed on 7a4a0c2). A loop without sub-delegation
now behaves as before (issue #34 requirement 3); with sub-jobs the guard
protects against a deadline-closed sub-job still writing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
round_roots was replaced by the correction job, so an implementation
job's open or failed sub-job vanished from the next review and the loop
could pass (planner-reviewer-codex-01, P2). The run's creator jobs now
accumulate; each wait still covers only the new job's tree, so the wait
cap is not paid again per round. T9 covers open / error / completed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- wait_for_job_tree at the execution, correction and compliance waits;
bounded by MAM_CHILD_WAIT_S (3900) and MAM_JOB_TREE_MAX (50)
- [하위 위임 잡] table: OPEN / CLOSED-UNCONFIRMED (deadline_timeout,
agent_blocked, or error with no reason) / NOT-DISPATCHED / error /
completed, plus unattributed open jobs; omitted when there are none so
option-less prompts stay byte-identical
- --wait-open-jobs also waits for unattributed open jobs
- stale-review guard without --worktree/--base: head + tree (control
plane excluded) before review, re-checked after verdicts and before
promotion; off with a warning when the source cannot be fingerprinted
- tests/test_issue34_job_tree.py: real wrapper registrations, real
publish_event / deadline close, real loop process
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- registry: parent_job field, register --parent-job fails closed on an
unknown parent, children_of/descendants_of, list --parent
- wrapper: --parent-job; $MAM_JOB_ID default (ignored with a warning when
this registry lacks it); brief line and one sentence at the end of the
injected instructions
- create_session: the onboarding instructions carry the same sentence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue #36: delegated prompts travel by file, a delegation that fails ends
the loop with rc 1. VERSIONS.md carries the entry; the merge-side reports
(round 1 @59be1c8 and round 2 @708262a with identity sidecars, base
59be1c8 -> head 708262a, evidence review-tree) are archived under
.agents/reports/<session>/.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
- runner._run_cmd: the write of the prompt file was outside the cleanup
and error handling that the exec had (merge review: claude H1, codex R1).
A failed write now removes the partial file and returns the same 126
failed submit an unstartable wrapper does, so the loop ends with rc 1
and no traceback. Test S4c.
- test S9 required the literal "File too large" in stderr; bash 3.2 on
macOS prints nothing for EFBIG, so the whole suite failed on this host
(agy, grok, codex R2). The contract asserted everywhere is rc != 0, no
leftover file and the portable diagnostic line; the libc wording is
checked on Linux only.
- the wrapper accepted an empty --prompt-file and registered an empty
prompt (claude H3); it is refused now. Test S1e.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Review of f26952c (codex, job cc849cbf):
- P2: create_session registered the task by file but then pasted the whole
task into the onboarding text, which send_keys_safe hands to `herdr agent
prompt` as one argv string. A 130,500-byte --submit-job was registered
intact and never reached the agent (execve E2BIG). The task now goes into
<jobs>/<id>/brief.md, as the delegate-job wrapper does, and the injected
text names that path. The publish command and its dispatch token (#23) are
unchanged. The other send_keys_safe callers (the wrapper's short pointer,
stop_session's exit key) carry no task text.
- P3: delegate_job_safe's write-failure branch left the partial prompt file
behind and logged to stdout, which callers capture. It now removes the file
and logs to stderr.
S8 (real create_session.sh, 100 and 130,500 bytes, strace when present) and
S9 (the real function, RLIMIT_FSIZE write failure) carry the reviewer's
reproductions over; all three fail on f26952c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two remaining exec points that carried a prompt as one argv string
(capped at 131,072 bytes):
- run_loop.sh (MAM_LOOP_PY=0): delegate_job_safe, which all eleven shell
delegations go through, turns `--prompt <text>` into `--prompt-file` under
the control root's .mam/ and removes the file once the wrapper returns. Its
failure lines go to stderr: callers capture stdout, and under set -e a
failed submit ends the loop at that line with the wrapper's code, so the
cause was being swallowed.
- lib.sh delegate_submit_job and its legacy twin (create_session's onboarding
and --submit-job): the prompt is written under .mam/ and handed on as
--prompt-file; lib_py.delegation.cli submit accepts --prompt-file and
forwards it to the registry. --prompt keeps working everywhere.
S6/S6b push a 140,000-byte prompt through the CLI and both helpers; S7 runs
the real run_loop.sh with a 140,000-byte plan and a 150,000-byte diff; S7b
fixes a nonzero exit when the implementation or compliance job cannot be
registered. All six fail on 14d11bf.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of f5cadd9 (planner-reviewer-codex-01, R1): the compliance job's
registration failure was only logged, so a passing next round ended the
real loop process with rc 0. Compliance now returns 1 when it cannot be
registered or does not finish, as the correction step already does.
Re-adjudication and arbitration also return 1 when their job cannot be
registered; a registered job that yields no verdict keeps the designed
fail-closed fallback.
S4c (stubbed delegate_job, a fixture whose later rounds never pass) is
replaced by S3c: a real `python -m lib_py.loop` process whose wrapper runs
an executable that simulates the agents, refusing the compliance,
re-adjudication or arbitration registration while the next round passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
S1-S1d push a 140,000-byte prompt through the real wrapper and registry by
file, with a negative control that the same prompt as an argument fails with
E2BIG. S2 runs the real runner against a recording wrapper. S3/S3b run a real
`python -m lib_py.loop` whose wrapper fails or cannot start. S4/S4b/S4c cover
the exec-error handler, the busy retry and the compliance log. S5 sends a
collected diff from the former 131,072..200,000-byte blind spot through whole.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
delegate_job writes the prompt to a temp file under the control root's .mam/
and hands the wrapper --prompt-file; the file is removed once the wrapper
returns. An OSError from starting the wrapper becomes a failed submit (rc 126)
that the existing error branch logs, so the session-busy retry no longer
dereferences a missing result. A retry that yields no job id, and a
compliance job that cannot be registered, are now logged instead of silent.
The collector caps are documented as the size of the evidence a reviewer is
shown; delivery has no cap of its own any more. The loop skill notes that a
pipeline hides the loop's exit code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One argv string is capped at MAX_ARG_STRLEN (131,072 bytes on Linux), so a
prompt passed as --prompt could not reach the registry once it carried a
large diff. submit now takes --prompt-file and passes the same path to
`registry.py register --prompt-file`; the loop type's per-iteration update
goes by file too. --prompt keeps working unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviews: authoring workspace 5 reviewers x 5 rounds PASS; merge workspace round 1 @f4ba205 claude/agy/grok PASS (codex report lost to host sleep; its two probes fixed in 05b0ea4), round 2 @05b0ea4 all four PASS (identity: base f4ba205 -> head 05b0ea4, evidence review-tree). Full suite @f4ba205: 1111 passed, 1 skipped; affected suites @05b0ea4: 123 passed. Reports under .agents/reports/. Closes#23, #24.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Issues #23 and #24: cross-server herdr scope, agent-agnostic start-up
dialogs, onboarding delivery judged by the agent's own started event,
verified rollback. VERSIONS.md carries the entry; the merge-side reports
(round 1: claude/agy/grok; round 2 on 05b0ea4: all four, with identity
sidecars, base f4ba205 -> head 05b0ea4, evidence review-tree) are archived
under .agents/reports/<session>/. The round-1 codex report was lost to the
host sleeping mid-review; its two probes are recorded in the changelog.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Two probes from the merge-side codex review (its report was lost to an API
outage; the probes and their results were recovered from its transcript):
- create_session.sh and resume_session.sh set AUTO_TRUST=1 and then
exported MAM_AUTO_TRUST from it, so the documented MAM_AUTO_TRUST=0
without a flag reached handle_startup_dialogs as 1: the operator had
turned automatic trust off and it stayed on. Now AUTO_TRUST defaults to
the environment value and an explicit --auto-trust/--no-auto-trust wins.
- _mam_dialog_pick exited on the first dialog inside its resend cooldown
instead of looking further down the screen, so a skipped (--no-auto-trust)
or just-answered trust prompt hid the codex update notice below it, which
the docs say is always answered. It continues to the next dialog now; S5
and S5b (one send per cooldown, one retry after it) still hold.
Regressions: S6c, S5f (startup path), R3b and C3 (resume/create, env only,
flag over env). Each fails without the fix. The create skill doc names the
environment form and its precedence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Review round 4 (planner-reviewer-claude-01 B-1, planner-reviewer-codex-01
P2, reached independently): dispatch_verified is a boolean. It says the
subscriber's token check ran when the record was written, but names no
dispatch, the token being masked by the audit redactor and attempt_id not
written. events.ndjson is appended to across a job's attempts, so a started
verified under attempt 1 satisfied every later attempt's wait - the very
misattribution R1 forbids, alive on the remote path while the local path
(data.dispatch_token in events.log) was already immune. codex reproduced it
with the real signed producer, the real _Watcher, the real audit writer and
the real operator re-dispatch; both waits answered 0 where 1 was due, even
when attempt 2's subscriber had correctly dropped attempt 1's replayed packet.
The subscriber now writes dispatch_fingerprint = SHA-256 of the token it
verified, and attempt_id for readers. With a token demanded, the predicate
accepts an audit record only if it is dispatch_verified AND its fingerprint
equals SHA-256 of that token; a record with no fingerprint - written before
the field existed - names nothing and is rejected on that path. Without a
token, any received started counts, as before.
Fingerprint over attempt_id as the identity, weighing the two proposals: the
local path already compares the token, so hashing it keeps one identity axis
for both sources; the token is a uuid4 nonce (registry.py:141), 128 bits, so
its SHA-256 is not recoverable and it is a CAS fence rather than the HMAC
secret in the first place; and attempt_id cannot tell apart a token
re-minted within one attempt, where the token can. attempt_id is recorded
because it is cheap and readable, not relied on.
W7 pins codex's five cases end to end - current dispatch accepted, an old
attempt's record rejected after a real re-dispatch, still rejected when the
stale packet is replayed and dropped, the new dispatch's own started
accepted, a bad HMAC never recorded - with create's wait and the wrapper's
wait required to agree on each. W8 pins the fail-closed legacy record and
the untokened compatibility path. W5 gains the no-fingerprint and
other-dispatch cases; W6 checks the real _Watcher writes the fingerprint and
never the token. S7e's status reset had no reason left and is gone. codex's
own reproduction file, unmodified, passes 5/5 against this tree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 3 (planner-reviewer-codex-01, reviewer-opencode-01): R1 blocking,
R2 and the "ONE builder" wording non-blocking. All three reproduced; none
rebutted.
R1. _wait_started still returned 0 as soon as the record left `pending`,
ahead of the shared predicate, so `error`, `cancelled` and another dispatch's
`running` all acknowledged an injection that create's delegate_wait_started
rejects - the two contracts the ack-finding brief said must not diverge. Both
reviewers reproduced it with the real producer and no record manipulation;
my S7n had hidden it by resetting the record to `pending`.
The bypass and the console grep are gone. The wrapper's evidence is now
delegate_started_recorded alone, the same call create makes. For a remote
agent - no shared disk, so no line in events.log - the predicate reads the
subscriber's audit record of the `started` it received. That record is
attributable by construction: the subscriber verifies the HMAC and drops any
event whose data.dispatch_token differs from the job's expected token before
it writes anything. The token itself cannot go into the record (the audit
redactor masks token-named keys), so the subscriber now stamps a boolean
dispatch_verified, and with a token demanded only a verified record counts;
an untokened legacy record fails closed. The registry status is never
evidence; a terminal job is preserved by the terminal logic, not turned into
an acknowledgement.
Tests: W1/W2/W3 are the reviewers' three negatives and the positive, real
producer, record left exactly as it produced it; W4 runs create's wait and
the wrapper's wait side by side on all four and requires the same answer;
W5 pins the remote route (a verified record counts, an unverified or
non-started one does not); W6 drives the real _Watcher and shows it stamps
the flag on a matching started and records nothing for a mismatch; S7n no
longer resets the record. test_issue14's "registry ack over unconfirmed
inject" produced its ack with a bare status flip - the very bypass - and now
produces it the way an agent does, publish_event with the dispatch token;
its scenario and every other assertion are unchanged. test_issue18's remote
e2e accepted the status messages and now expects the shared one.
R2. _code_only blanked a docstring's whole line range, so code sharing that
line - `"""doc"""; call()` - vanished from the scan. It now removes only the
docstring node from the AST and unparses the rest (comments drop out with
it, code strings stay); test_g9b pins the four same-line shapes.
The builder comment claimed one builder for create and the wrapper; the
wrapper keeps _pub_cmd and S7l pins the two equal argument-for-argument. The
comment now says that.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The full suite on 1667fda showed exactly one failure beyond this host's
baseline: test_g9_phase2a_spawns_no_daemon_and_calls_no_frozen_helper. Its
`\blib\.sh\b` pattern matched a docstring in watcher.py that points at the
shell twin of caller_server ("Same convention as lib.sh's
_herdr_caller_server") - a mention written so the two implementations stay in
sync, not a call.
The guard's own docstring states its claim: no frozen lib.sh helper is
*invoked* from lib_py/loop. Its one prior change (803ba43) added the word
boundary after `hashlib.sha256` tripped it - the same class of false positive,
handled by narrowing the matcher rather than by contorting innocent code.
This continues that: the three patterns and the word boundary are unchanged,
but they now run over the source with comments and docstrings blanked. Every
way Python could actually reach a lib.sh helper - a path or `source` in a
subprocess argv, an identifier - lives in code text, which is still scanned in
full, so the guard keeps its reach. test_g9b pins both sides: an
invocation-shaped string in code still trips it (lib.sh and
ensure_subscriber_daemon both reported), a bare lib.sh path in a list literal
still trips it, and the same words in a module docstring, a function
docstring and a comment do not - with hashlib.sha256 present as the original
regression.
Rephrasing the docstring to dodge the literal was the other option and was
not taken: it would hide a useful cross-reference to satisfy a matcher, which
is the evasion shape this guard should not encourage, and it would leave the
next honest comment to trip the same wire.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Trial 2 of this branch: cross-server delivery was restored (f7cee00), the
agent completed its onboarding and published started and completed - and
create_session still waited 180 s, called the onboarding unconfirmed, and
tried to write `error` over the completed job (refused by the registry).
delegate_wait_started required the started line to carry data.dispatch_token
(review R1: a stale attempt's started must not vouch for this one). But
publish_event only writes that field when its command line carries
--expect-dispatch-token, and create_session's hand-rolled onboarding command
never did - the wrapper's _pub_cmd does, which is why every wrapper-dispatched
agent's started has the token and no create_session onboarding ever could.
Five reviewers missed it because the fixture wrote the token into the started
line by hand: a fake producer that differs from the real one.
Fix, along the wrapper's existing mechanism rather than a new one:
* delegate_publish_cmd (lib.sh) builds the publish command for an agent, with
--expect-dispatch-token and --attempt-id, and create_session uses it. The
agent's own started now names the dispatch, and publish_event's CAS check
refuses a stale dispatch's event at the source. S7l pins the builder
argument-for-argument against the wrapper's _pub_cmd.
* delegate_started_recorded (lib.sh) is the one definition of "this
dispatch's started is on disk" - JSON-parsed, token-matched when a token is
given - used by delegate_wait_started and by the wrapper's _wait_started in
place of its regex. The wrapper keeps its status and subscriber routes
(remote agents); the shared part is the evidence contract.
* An already-terminal job is left alone when the wait expires (req. 4): no
error over a completed/error/cancelled record.
The fixture now registers the record through registry.py and publishes
started through the real publish_event.py; the only synthetic line left (S7h)
exists to pin key-order independence and says so. S7i pins the trial-2 defect
as the negative it is (old command shape -> started without token -> not
acknowledged); S7j runs the builder's command through the real producer and
is acknowledged on the first probe; S7n shows the wrapper accepting the same
real line and rejecting a stale token; S7m pins no-write-over-terminal. S7f
moves from "publish error" to "leave the terminal job alone", per req. 4.
Option B (verify data.hmac_sig with the job's auth_token, plus record-token
still equal) was weighed and not taken: verify_hmac returns True with no
auth_token - PoC mode, which the trial and every sandbox run in - so it proves
nothing there; in production hmac_sig authenticates the signer but does not
identify the dispatch, so R1 would rest on a current-state check that a stale
line plus an unrotated token passes; and it would diverge from the wrapper
unless the wrapper changed too. Token exposure is not new: every wrapper-
dispatched instruction already carries the dispatch token in the pane, and it
is a per-dispatch CAS nonce, not the HMAC secret.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The live trial of this branch found the cross-server verdict inert on this
host: _herdr_call_is_cross_server read the caller's server from HERDR_SESSION,
and herdr 0.8.2 does not export that variable to a pane of the default server
- the orchestrator's usual home, and exactly the pane the #23 leak comes from.
Every call was judged "not crossing", the three ids were never dropped, and
all four trial onboardings ended "no started within 180s". My own pane sits in
a named server and does carry HERDR_SESSION, which is how the assumption got
past me.
HERDR_SOCKET_PATH is exported to every pane and its layout names the server:
.../.config/herdr/herdr.sock is the default server and
.../.config/herdr/sessions/<name>/herdr.sock a named one - the convention
`herdr session list --json` reports and the shim already uses to find a named
server's socket. _herdr_caller_server prefers HERDR_SESSION when a herdr does
export it, otherwise reads the socket's shape (suffix-matched, not $HOME-
anchored), and yields "" for anything unrecognised so an exported scope is
respected rather than guessed away. The rest of the rule is unchanged from
3bcaa39: a `default` or empty target is the ambient server, no caller server
means no pane, caller == target is the same server. watcher.caller_server
applies the same rule for the loop's own herdr calls.
Reproduced against the live server in the trial's exact env: 10a678c answers
"NOT crossing / has-session NOT FOUND"; this commit answers "caller server
default / crossing / FOUND". S1c pins that env end to end with the recorder
asserting the ids never reach herdr; S1d-S1h pin the verdict, the named-socket
same-server and cross-server cases, HERDR_SESSION precedence, and the
unrecognised layout. The 3bcaa39 tests are unchanged and still pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 1 (planner-reviewer-claude-01, reviewer-agy-01,
planner-reviewer-codex-01): one blocking defect and two P2s, all confirmed.
B-1. handle_startup_dialogs took its ready tokens only from the environment,
which wait_for_tui_ready fills when it evaluates the adapter facts. Called on
its own - as resume_session.sh calls it, with no readiness wait before it -
it had no tokens, its early-return branch could never fire, and every resume
of every agent idled through the full 20 s timeout (base: 0.03 s). rc was 0
throughout, so nothing failed; the resume tests just took ~30 s each.
_mam_load_dialog_facts now also takes MAM_STRONG_READY_TOKENS / MAM_READY_TOKENS
from the facts, but only when the caller has not set them, so a caller-set
value (wait_for_tui_ready's own, or a test steering readiness) is never
clobbered - the concern that made it take a single fact in the first place.
S5d pins one-iteration return on a ready first frame for five adapters with
no environment; S5e pins that caller tokens survive; R4 gets a wall-clock cap
so a timeout-idle can no longer pass as success. The old
_MAM_READY_TOKENS_CLAUDE fallback was left defined but unread - removed.
R1 / N-2. delegate_wait_started treated any status past `pending` as the
agent's acknowledgement. codex reproduced three false positives: `error` and
`cancelled` records with no started line, and a `running` record from another
dispatch. The status branch is gone; the evidence is a started line carrying
this dispatch's token, which the local publisher writes before it syncs the
status, so nothing that really started is missed (S7c re-pinned on the line
with running/completed/error statuses; S7f/S7g pin the three negatives).
N-4, taken with it: the events.log line is parsed as JSON rather than pattern-
matched. publish_event nests dispatch_token under `data`, and the old regex
only matched because of key order (S7h, real line shape and a reordered one).
R2 / N-1. Two tests hardcoded <repo>/.venv/bin/python and failed with
FileNotFoundError in every review worktree. sys.executable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
resume_session.sh called handle_startup_dialogs only when AGENT=claude and
passed no agent, so a resumed agy, grok or codex sat on its directory-trust
prompt exactly as the create path did before this branch. The gate is gone and
the agent is passed, which is the call create already makes.
--no-auto-trust / MAM_AUTO_TRUST apply to resume too. A resumed CLI shows the
same trust dialog as a fresh one, in the same directory, with the same
consequence for codex (trusting loads the project's local config, hooks and
exec policy), and handle_startup_dialogs already reads MAM_AUTO_TRUST - so the
only question was whether resume exposes the switch or silently inherits the
caller's environment. Exposing it keeps the two skills' contracts identical.
The tests run the shipped create/stop/resume scripts against the mock herdr.
The mock's pane always shows a ready banner, so they fix the call resume makes
(session, timeout, agent, MAM_AUTO_TRUST) by overriding the handler in the
sandbox copy of lib.sh, plus one unmodified run showing the real handler is
reached for a non-claude agent; which keys a dialog gets is fixed on the create
side (S4-S6).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two defects in the started-based verdict, both found by the full suite.
The first was destructive. No `started` within T exited 1, which fired the
rollback trap and killed the session. That is worse than the bug being fixed:
create_session already has a rule for "the agent is not responding but the
process is alive" one step earlier - a TUI that never signalled ready is
preserved for diagnosis - and an onboarding that is merely slow is the same
situation. The job is marked `error` and warned about loudly; the session, its
row and the pane stay. MULTI_AGENT_RULES §6 has the GM gate on this job
anyway, so an unacknowledged onboarding still blocks real work.
It also broke 21 tests across 8 modules (create/sanity/tier2/tier3/tier4/
issue15/issue19/issue21) and added roughly an hour to the suite: no sandbox
has an agent to publish `started`, so every create_session test sat out the
full 180 s and then failed on the rollback. MAM_ONBOARD_ACK_S shortens that
wait, and the sandbox fixture sets 3 s. It is deliberately not the wrapper's
MAM_INJECT_ACK_GRACE_S - that is a different wait whose own tests assert on
the value they pass, and reusing it made issue18's acceptance2 read "within
3s" instead of "within 4s".
The second: the delivery failure published `error` and then the rollback
published `cancelled` on the same job, which the registry refuses
("conflicting cancelled after terminal error"). Terminal events are published
exactly once, so the trap now skips a job this run already closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plan commits 3 and 4 landed together: both rewrite the same region of
create_session.sh and share one test file, and splitting them afterwards would
have meant hunk-level surgery on already-verified code.
Delivery verdict (item 3). create_session --onboard called a healthy agent a
failure three seconds after registering the job: inject_instructions ->
send_keys_safe returns rc 4 when `agent prompt --wait --until working` fails
and the pane is still not working two seconds later, which is exactly where a
TUI that is still starting up lands. It never got the ISSUE-18 confirmation the
delegate-job wrapper has. rc 4 is a warning now and the agent's own `started`
decides, within the job's own ack timeout; rc 1/2/3 stay hard failures, since
they say the pane never quiesced, a dialog was blocking, or the text never
appeared, and no later `started` repairs those.
The self-published `started` had to go first. create_session published it on
the agent's behalf right after injection, so the registry showed a started job
whether or not the agent ever saw the prompt - with that line in place, no
started-based verdict can mean anything.
delegate_wait_started is lib.sh's counterpart to the wrapper's _wait_started
(plan item D10-b). The wrapper's own `submit` was the first choice, but it
blocks until the job reaches a terminal state, and create_session must return
as soon as delivery is confirmed; the on-disk evidence it reads - registry
status past `pending`, or a started line carrying this dispatch token - is
written by publish_event before its wire publish, so no broker is involved.
Rollback integrity (item 4). The trap ran `_herdr kill-session -t
"$SESSION_NAME" 2>/dev/null || true`, so a kill that closed nothing still
printed "rolling back" while the session kept running, and the row stayed
`running` pointing at the job the same rollback had just failed (#23 symptom
5). With item 1 in place this check is finally trustworthy: the kill used to
fail because the caller's workspace id hid the pane.
The trap now reads the kill's rc, confirms with has-session, closes the
onboarding job as `cancelled` (a terminal transition the registry knows, and
the truthful one - the job was never dispatched), and marks the row. A session
still alive exits 43 with the manual cleanup command, distinct from 42 (TUI
never ready).
The row keeps a schema-valid status rather than the plan's `error`, which
atomic_yaml.py rejects: `terminated` once the session is really gone, and
`running` when the kill failed, because it is still running. What makes that
case legible is the note, the cleared delegate_job_id and exit 43 - not a
status claiming the opposite of the truth. The row is never deleted; a deleted
row takes the evidence with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
handle_startup_dialogs was a claude-only string ladder called only when
AGENT=claude, and wait_for_tui_ready does not treat a dialog screen as ready.
So agy, grok and codex sat on their directory-trust prompt for the full
readiness timeout, the session was recorded as tui-ready-timeout, and
onboarding injection was disabled (create_session.sh:313-314) - a human had to
accept each dialog by hand.
The signatures and accept keys move to the adapters as `startup_dialogs`
(pattern, keys, kind), published through the existing `facts` eval contract as
MAM_STARTUP_DIALOGS. claude's ladder moves over verbatim; agy takes Enter, grok
y, codex Enter, and codex's update notice takes 2 (Skip), which blocks a start
just as hard while changing nothing. kind is `trust` or `notice`:
--no-auto-trust leaves trust dialogs to a human and still answers notices,
since trusting a directory makes codex load its project-local config, hooks and
exec policy.
Re-sending is gated by time (MAM_DIALOG_RESEND_S, default 5s), not by frames.
#24's comment reports that `--source visible` keeps showing an accepted dialog
for a while, and _pane_capture hardcodes that source, so "still on screen" is
not evidence the keys were missed; acceptance is judged by the ready tokens
alone.
Two existing tests asserted the old shape and are updated, not weakened:
b19's "Escape branch stays" now looks for it in claude's adapter, and c1b's
"Enter once per iteration" becomes "never twice for one frame", which the
cooldown satisfies strictly more tightly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A MAM script often runs inside one herdr server's pane (the orchestrator's)
while targeting another (the isolated MAM server). The pane exports
HERDR_WORKSPACE_ID, HERDR_PANE_ID and HERDR_TAB_ID, and those ids only mean
something on the server that issued them. They reached the scope filters as a
foreign workspace, whose fail-closed miss (ISSUE-3 / F-2b) reports a live
session as absent: has-session, kill-session, agent prompt and the delegate-job
preflight all answered "no such session". Reproduced on Linux and macOS in the
#23 comments, worked around until now with `env -u` at every call site.
mam_herdr drops the three ids for a call aimed at a named server other than the
caller's own. Scope then falls back to .mam/herdr_workspace_id and finally to
server-global, which is what _herdr_ws_scope already does. `default` is not a
crossing: it is the sentinel for "pass no session flag", so herdr answers on
the ambient server, which for a caller in a pane is that caller's own. A caller
outside any pane keeps an exported scope, so H-18/H-21/H-23 keep their meaning.
The fix lives in mam_herdr, not in the shim heredoc: that heredoc is
materialised into $WORKSPACE_ROOT/.mam/shim/herdr, one file shared by every
checkout bound to a control plane and rewritten on every herdr call, so editing
it would put unreviewed code in the control root and let two checkouts
overwrite each other. S11 fixes that invariant.
lib_py/loop/watcher.py builds its own herdr argv and is the one path that does
not go through mam_herdr, so it applies the same rule via scoped_env.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue #25: a promoted plan is reused only with --plan-file; the implicit
newest-plan load is gone in both runners, and an empty planner report no
longer passes silently. VERSIONS.md carries the entry; the four merge-side
review reports and their identity sidecars (base 6fc9194 -> head 1e82651,
evidence review-tree) are archived under .agents/reports/<session>/.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Review R1/N1 (reviewer-grok-01, planner-reviewer-codex-01), reproduced before
fixing. The usage line and SKILL.md both promise "relative to the control
root", but parse_args did Path(...).expanduser().resolve(), which is relative
to the caller's cwd. The runner never chdirs to workspace_root, so the two are
routinely different - the documented recipe runs the loop from the control root
while the source root is a worktree.
Two ways it went wrong: a plan that exists only under the control root was not
found at all (exit 1), and a same-named file in the caller's cwd was loaded
instead, quietly putting an unrelated plan into the first implementation
prompt. That second one is the very failure #25 is about, reintroduced through
the option meant to replace it.
Resolve against workspace_root the way jobs_dir already does (Issue #21 R2);
pathlib's `/` leaves an absolute or expanded-home path untouched. Every earlier
test passed an absolute tmp_path, which is why none of them caught this: P3c
covers both failure modes with the control root away from the caller's cwd, and
P3d pins that absolute and ~ paths are not rewritten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The skill described the implicit load as a feature in five places. Replace them
with what the loop does now: nothing is loaded without --plan-file, the path is
relative to the control root, and the plan rides the first implementation
prompt only. Adds the CLI table row and a section on what an empty plan report
means at each of the four load points.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P1 is the reproduction: a running planner plus a promoted plan in its report
directory must not reach the Creator. P2 pins that a named plan is used, that
the newer unrelated one is not, and that the plan rides only the first
implementation prompt - the correction round carries feedback alone, as the
implicit load did too. P3-P5 keep the option's checks in front of any job.
P6 keeps --plan mode and its promotion working.
P9-P11 cover the loads the review widened: an empty initial plan stops the run,
an empty discussion refine keeps the previous plan, and an empty escalation
refine leaves the "follow the updated plan below" prompt carrying the real one.
The existing _drive helpers hand every job the same non-empty PASS report and
so cannot reach these paths at all, so this module drives the loop with its own
stub that scripts a report per job. P7-P8 pin wrapper/runner parity, the legacy
shell refusal, and that neither implementation globs for a plan any more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The --plan-file check landed on the one path a human is watching. The three
--plan-mode loads read a report written by another agent with nobody watching,
and had no content check at all: find_report only tests that the path exists.
An empty or whitespace-only report therefore blanked current_plan in silence.
Phase 2's `if self.current_plan` then flipped the prompt to self-planning and
the run finished rc 0, having paid for a planner job, a Creator challenge and a
refine job and thrown the result away. The escalation refine was worse: it has
no guard, so the correction prompt asserted "follow the updated plan below" and
carried nothing.
All four loads share _plan_text() now - a plan is a readable regular file with
text in it - and each caller says what None means. An empty initial plan stops
the run instead of demoting it, and an empty refine warns and keeps the plan it
already has, which is still good. Those two together leave the escalation
prompt unable to claim a plan it does not carry, so it needs no new branch.
Found by the Creator's challenge to the plan (job 0f63ac1d), sustained by the
Planner as revision 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without --plan, Phase 1 resolved a planner session automatically and globbed
<workspace_root>/.agents/reports/<planner>/plan-*.md for the newest file by
mtime, handing it to the Creator as "the plan already agreed on". Both keys are
wrong: the report directory belongs to a session, not to this task, so other
issues' plans pile up in it, and right after a clone or checkout mtime is
checkout order. 47 such plans are committed in this repository, so an ordinary
run could silently implement against an unrelated design.
Reuse is explicit now. --plan-file <path> is the only way to load an existing
plan; it is resolved against the control root, checked before a single job
exists, and refused if the file is missing, not a regular file, unreadable or
empty (an empty file would quietly turn into a "no plan" prompt). It cannot be
combined with --plan, and --review-only warns and ignores it because no
implementation job is delegated there. The implementation prompt itself is
unchanged, so the Creator's contract does not move.
Phase 1 now says which plan it loaded and where from, or that it loaded none.
The legacy shell loop (MAM_LOOP_PY=0) loses the same auto-load and rejects
--plan-file rather than duplicating the check on a path that is on its way out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- worktree_prepare.sh installs the same files deploy/install.sh does
(.agents/skills/multi-agent-mux-delegate-job/requirements.txt: paho-mqtt,
PyYAML) plus requirements*.txt. Without paho the loop wrapper, stop_session
and the subscriber tests fail in the worktree - seen as 4 suite failures
in a worktree without its own venv, all passing with one.
- review D1: a failed pip install removes the venv it half made, so the next
run recreates it instead of reporting VENV=kept (test w1c).
- review D3: the [worktree context] block's uncommitted-changes flag uses
--untracked-files=all like the guards.
- review D2: the create skill doc says the install needs the package index.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
R1/R2: `git status --porcelain` honours status.showUntrackedFiles, so a
`no` setting in a submodule (or the worktree) hid untracked files from the
checks that justify worktree_remove.sh's single --force and from the review
snapshot guard (_dirty_submodules), which then approved content it never
captured. All three checks (lib_py/worktree.py, worktree_remove.sh,
runner._dirty_submodules) now pass --untracked-files=all. The source tree's
own snapshot was already safe: `git add -A` ignores that setting.
R3: worktree_prepare.sh no longer links .venv to the control root's. It
creates the worktree's own venv with the control root's interpreter and
installs requirements*.txt; a leftover link is refused (exit 1), a control
root without an interpreter is a warning (VENV=skipped); VENV= is printed.
Tests: w1 (real venv, kept on re-run), w1b (link refused / skipped), w6e
parametrised over the hidden setting for the post-stop recheck, w6e2
(hidden untracked file in the submodule and in the worktree blocks the
removal), l17 untracked-hidden / during-hidden (guard refuses before the
first review; a mid-round hidden file makes the round stale). Each new
case fails without the fix.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
The close landed 8-9 s past the 20 s deadline in three full-suite runs on
this host (all passed alone); the pinned defect closed at ~2 x TIMEOUT,
so a 15 s margin keeps the test meaningful.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
The 33 failures on the Linux / Python 3.14 host (#28) were environment
dependencies, not regressions; each test is worth keeping, so fix the
dependency instead of deleting the test:
- test_workspace_scope: `source` under `shell=True` ran in dash; run the
command string under bash.
- conftest autouse: MAM_ENV_FILE points at an absent file for every test,
so a workspace .mam.env with a reachable broker no longer breaks the
"broker unavailable" tests (T1-4).
- help-capture guard: compare normalized help text (whitespace collapsed,
"optional arguments"/"options" unified) so argparse re-wrapping across
Python versions is not drift.
- deploy freshness D-11..D-19 and D-22..D-30: skip with a reason when the
nats-docker submodule is not initialized.
- lib_py.agents.base.conversation_in_use and heartbeat._process_table:
`ps -eo pid=,ppid=,args=` (POSIX) instead of the BSD `-axo ... command=`
form, the most likely cause of the 12 purge failures on Linux (the holder
scan returned nothing); to be confirmed on that host.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
S1/S2 per-test defaults and the mam_sandbox override, S3 submit with WORKSPACE_ROOT on a checkout leaves its .mam alone, S6 a nested pytest started with a pane's binding sees none of it (import-time copy included) and leaves the target untouched, S7 lib.sh takes the session registry from a test's WORKSPACE_ROOT.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sdga4A8i1iSc8DaaW9iKU2
The onboarding tests compared whole listings of the repo's .mam, so a concurrent run's entries failed them. Session names are now unique per run; the check is that the sandbox's own job ids are absent from every real root (this checkout's and the recorded control plane's) and that no new entry there names the session. cli_defaults also checks that the audit log follows WORKSPACE_ROOT.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sdga4A8i1iSc8DaaW9iKU2
Watches this checkout's .mam and the ones the recorded binding named. A new entry is a leak when it is an audit log without a registry record, or when its agent_session is missing or not a registered session; live delegations write the record first and target a registered session. Reports, never deletes. The #19 guard's docstring pointed at a #15 session guard that never existed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sdga4A8i1iSc8DaaW9iKU2
_job_for_inprocess_publish deleted DELEGATE_JOB_LOGS_DIR to fall back to the cwd, but an ambient WORKSPACE_ROOT comes first, so the audit log went to the real .mam/delegate_job_logs. It now keeps the per-test default and asserts the log is under tmp_path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sdga4A8i1iSc8DaaW9iKU2
pytest_configure records WORKSPACE_ROOT, AGENT_SESSIONS_YAML, MAM_JOBS_DIR and DELEGATE_JOB_LOGS_DIR and drops the last three, plus WORKSPACE_ROOT when it names another checkout: a worktree creator's pane carries all four at the live main checkout, and each wins over a test's own WORKSPACE_ROOT. It runs before collection because test_orc_onboard copies os.environ at import. Every test then defaults MAM_JOBS_DIR and DELEGATE_JOB_LOGS_DIR to tmp_path/.mam-test-state; mam_sandbox and a test's own monkeypatch still override.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sdga4A8i1iSc8DaaW9iKU2
- review jobs' [워크트리 컨텍스트] points at the [리뷰 identity] path only (B-2);
remote re-adjudication gets a matching sentence (N-4)
- dirty/untracked submodule content fails the snapshot and makes a round stale,
regardless of submodule ignore settings (B-R1)
- with --worktree/--base and no reviewer, the creator self-review takes the
reviewer path: snapshot diff, evidence check, pre-promotion re-check and the
identity sidecar; NOT PASS still re-reviews without a correction job (B-R2/B-R3)
- snapshot diff failures keep the UNAVAILABLE marker (N-1); prune only when
worktree remove fails (N-3); the empty per-loop review dir is removed (N-5)
- tests L17-L21, B-2 assertions in L6/L11; SKILL.md env stale note; import order
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
The byte-identity check of the two-argument form read the old script with
`git show 25f4be3:...`, which fails in a `git archive` export (no .git) and
failed the test there. The comparison now runs only when the old script can
be read; the two-argument path is otherwise still covered by
test_b7_diff_untracked. L13 passes in the repository and in an export.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
Two more failure modes seen on the public broker after the stale-dispatch fix:
- the runner served this run's job, but the broker delivered only the
retained `completed` (not the non-retained `started`); the wrapper logged
"advanced to 'completed'", which the test did not accept as delivery
evidence although it is the same protocol evidence as "advanced to
'running'";
- the orchestrator's dispatch publish failed with "no CONNACK from broker"
and the runner never saw the job - an unreachable broker, the same kind of
precondition as the existing skip when the runner cannot subscribe.
Everything else still fails. The test remains dependent on the public broker
(3 runs after this change: 2 passed, 1 failed with CONNACK retry warnings).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
- loop SKILL.md: --review-isolation and --remote-review in the option table,
a paragraph on review identity (snapshot, snapshot diff, stale re-review,
pre-promotion re-check, report-<jid>.identity.json with evidence, nested
repositories refused), and the remote-reviewer rule in the Issue #18
remote-session section.
- design doc: the §2-C reviewer sentence describes the implementation; §5
marks item 5 as loop B and says plainly that item 7's acceptance-case tests
are not written yet; the §7 loop row states the remote-reviewer limit and
that snapshot sharing is out of scope; a compatibility note for the
--worktree/--base + remote-reviewer combination.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
The remote runner subscribes to every job's retained dispatch topic on the
public broker and serves the first dispatch signed for its session. With the
fixed session name `sim-worker` and a fixed secret, a stale retained dispatch
left by an earlier run verified fine and was served instead of this run's
job, which then timed out: in the observed failure the runner logged
`published started/completed ... job=5eb1abcf` and `DONE rc=0` while the
orchestrator waited on job 5ee3fbcf with nothing received. Each run now uses
a random session name and secret, so stale dispatches cannot match.
Also, per the plan: the no-event window is 10 x 2 s instead of 3 x 2 s, and a
failure skips only when the runner provably published `started` for the
orchestrator's own job, the orchestrator subscribed to that job's topic, and
received and dropped nothing for it (broker non-delivery). Every other failure
still fails. The runner's own `started --heartbeat` already covers the window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
Active with --worktree/--base only; without them prompts, flow and promotion
are unchanged (a loop_id is recorded in the checkpoint either way).
- Every review round is pinned (B3): the source's HEAD and its working state
as a tree built through a temporary index (the real index/status/refs are
untouched); a dirty state becomes a ref-less snapshot commit on HEAD
(committed as mam-loop, not as the user). An untracked nested repository
cannot be pinned and is refused. identity = (loop_id, base, head, tree).
- Review isolation (B4): a detached review worktree of the snapshot under
TMPDIR (default), or `--review-isolation env` (the source root with env and
temp-dir instructions). Failing to open the tree refuses the round - the
isolation is never lowered silently. Trees are removed after the round and
on any exit (run() wraps the body; release_guard too).
- The reviewers' diff comes from the snapshot (B13):
diff_collect.sh takes an optional head commit for a commit-to-commit diff;
its two-argument form is byte-identical.
- Stale rounds (B7/B8): after the round the source is measured again; if it
changed, the round's approvals are discarded and the next iteration
re-reviews without a correction job (last iteration: rc 1). Only the last
non-stale round's PASSes are promoted.
- Records (B9/B15): review_rounds in the checkpoint with per-reviewer
transport/evidence, and report-<jid>.identity.json next to each promoted
report: evidence review-tree | source-tree | diff-only.
- Pre-promotion re-check (B10): a source changed after approval is not
promoted (rc 1).
- Remote reviewers (B14, Creator challenge 91c81095): they cannot reach this
host's review tree. Checked before anything is delegated: refused by
default; `--remote-review diff-only` admits them with the snapshot SHAs and
diff as their only evidence (no local path in their prompt), and refuses a
round whose diff is truncated or unavailable.
- run_loop.sh accepts both options (usage identical to the runner's); the
legacy shell loop refuses them before the lock.
Tests tests/test_issue21_loop_b.py L1-L16 (22).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
T2-6 set both variables in os.environ directly and restored them in a
finally block; a failing assertion between the two could still leak them to
later tests in the process. They are now set with monkeypatch.setenv (the
wt_repo fixture already delenvs both). The assertions are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
Both unpushed checks in lib_py/worktree.py - `submodule foreach` over the
initialised checkouts and the direct `--git-dir` walk over the repositories
under the worktree's modules/ - used `HEAD --branches --not --remotes`. A
commit made on a submodule's detached HEAD and kept only by a tag is reachable
from neither, the top-level status stays clean, and worktree_remove.sh then
deleted the repository with its single --force (exit 0).
Both checks now share one rev list, UNPUSHED_REVS = HEAD --exclude=refs/stash
--all --not --remotes: HEAD and every local ref (branches, tags, notes, ...)
minus remote-tracking refs. refs/stash stays out of it because the stash check
(stash list / reflog) owns it, so a stash is reported once, as 'stashes'. As
intended, a local-only note is now protected too. The stash, error, partial
repository and dirty checks are unchanged.
Tests W6l (tag-only commit; initialised submodule and deinit'd repository) and
W6m (annotated tag). The helper header, the stop SKILL.md and the design doc
state the wider rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
- N1 (planner-reviewer-claude-01) worktree_remove.sh: when `git status` fails
in the worktree (e.g. its directory was deleted), judge_tree returned before
METHOD was set and the summary line died under set -u with exit 1. METHOD
now has its default before the function; the refusal is exit 3 with
"nothing was changed", plus a `git worktree prune` hint when the directory
is gone. Same for --dry-run.
- R4 (planner-reviewer-codex-01) worktree_prepare.sh: a submodule path that is
not initialised yet but already holds files made the clone fail, and the
rollback's `deinit -f` then deleted those files. --submodules now refuses
such a path up front (new exit 3, nothing changed), which also makes the
rollback's premise true: after a failed attempt, everything under a
not-yet-initialised path was created by that attempt.
- R5 (planner-reviewer-codex-01) lib_py/worktree.py: `submodule foreach`
visits initialised checkouts only, but a deinit'd submodule keeps its
repository - stash and unpushed commits included - under the worktree's
modules/, which the forced removal deletes. Every repository under modules/
(nested ones included) is now checked directly (`--git-dir`): commits on no
remote (HEAD included) and refs/stash with its reflog. A directory that looks
like a partial repository cannot be checked and refuses.
- Also: the rollback loops are C-style (`seq 0 -1` counts down on BSD), and
prepare warns that --base is ignored for an existing branch.
Tests W6i (deleted worktree directory, with and without --dry-run), W6j and
W6j2 (stash / unpushed commit in a deinit'd submodule's repository) and W6k
(existing files in an uninitialised submodule path). Skill docs and the design
doc describe the new refusal and the wider repository check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
Review ffb5055e (planner-reviewer-codex-01):
- R1 [P1] worktree_remove.sh: the clean-tree and submodule checks ran only
before --stop, so work a stopping session wrote (or committed, or stashed)
was deleted by the forced removal. The checks are one function now, run
before any change and again right before the removal; any finding then
refuses with exit 3 (sessions already stopped, worktree kept), and the
removal method follows that latest look.
- R2 [P1] lib_py/worktree.py: a submodule stash is reachable from no branch
and leaves the status clean, and the forced removal deletes the worktree's
own submodule repositories. `git stash list` in every submodule is a
separate finding (`stashes`), refused like dirty/unpushed; errors fail
closed as before.
- R3 [P2] worktree_prepare.sh: when one submodule cloned and a later one
failed, the rollback deleted modules/ while the cloned checkout's .git file
still pointed into it, and `git status` failed in the worktree. The rollback
now undoes only this attempt: each submodule whose git dir did not exist
before is deinitialised before its git dir is deleted; submodules
initialised earlier are kept; shared submodule.* config values are restored
exactly (new keys removed, pre-existing values put back - init and deinit
both write .git/config). A final `git status` either confirms a usable
checkout or prints what to inspect.
Tests W6e (a write during stop keeps the worktree and the file), W6f (a
submodule stash refuses), W6g (partial failure on a fresh worktree: usable,
no modules/, no new config, then prepare and a plain removal work) and W6h (a
failing retry keeps the earlier `sm` init and its config value). The skill
docs and the design doc describe the recheck, the stash rule and the scoped
rollback.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
- design doc §3: the hand-assembled worktree/.venv/create recipe is replaced by
worktree_prepare.sh --creator-agent (its BASE= line is the base to record);
cleanup is worktree_remove.sh --stop; resume needs the original path.
- §4: what the helpers do - no .mam.env copy, the shared info/exclude, opt-in
submodules (any init leaves modules/, which blocks a plain remove), removal
of submodule worktrees with one --force after the checks, and never by hand.
- §5: item 4 is loop A on this branch; phase-1 items are done in v4.6.0.
- §7: create (control-root reconcile/watchdog, loud binding failure), resume
(original cwd only, binding persisted when already running), stop (the
removal helper's checks), monitor (register via create/prepare only).
- create / stop / resume SKILL.md: usage and exit codes of worktree_prepare.sh
and worktree_remove.sh, the original-cwd rule and its exit code (1 -> 2).
Frontmatter versions are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
- worktree_prepare.sh (create skill): idempotent `git worktree add` next to the
control root (a path inside it is refused), .venv linked to the control
root's, /.venv and .pytest_cache/ in the shared info/exclude, submodules only
with --submodules - a failed init removes its own traces (modules/, new
submodule.* keys in the shared config) and exits 1, since git refuses a plain
`worktree remove` once modules/ exists - and an optional creator registered
in the control root's .mam with MAM_NO_SUBSCRIBER_DAEMON=1. Prints BASE= for
`run_loop.sh --worktree ... --base ... --review-only`.
- worktree_remove.sh (stop skill): every check first, nothing changed on a
refusal (exit 3): linked and not locked, running sessions only with --stop,
no open job of those sessions except the delegate jobs stop cancels, clean
tree including submodules, and inside submodules no modification and no
commit on no remote (HEAD included - submodules sit on a detached HEAD).
Then stop, re-check jobs, and remove: plain, or a single --force when the
worktree holds submodule state.
- lib_py/worktree.py: the parsing behind both (stdlib only).
- A8: create's immediate reconcile and watchdog use the control root's code
and WORKSPACE_ROOT, not the worktree --workspace's copy.
Tests W1-W6d (local submodules allowed through GIT_CONFIG_* env only).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
- A11: resume refuses (exit 2) a --workspace other than the row's pane.cwd, and
a pane.cwd that no longer exists (checked first: mam_abs_workspace returns a
missing path unchanged). Before, claude/grok failed with a misleading "No
saved session" and class A agents started a new conversation in the wrong
directory. Rows without pane.cwd are not checked.
- A12 (recommendation 1): create and resume no longer swallow a failed binding
computation. A worktree (--workspace != control root) is refused with exit 2
and nothing spawned or recorded; the control root itself warns and records
the row without control_plane.
- A13 (recommendation 2): resume computes the binding before the herdr-alive
check, so the already-running path records it too (update_yaml_resumed.sh
fills a missing field only). Pane forwarding and the whitespace refusal stay
on the spawn path.
Tests W7-W9.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
Both installers wrote `/.venv/`, which only matches a directory: a .venv
symlink (the worktree recipe links the control root's) or file showed up as
untracked in installed repositories. The managed block now has `/.venv`.
Existing installations get it on the next install/update, which rewrites the
block between its markers as a whole; the integrity values are unaffected
(.gitignore is in neither asset_hashes.txt nor the install manifest).
Tests W10 (both installers; .venv as link, file and directory all ignored) and
W11 (a pre-#27 block is replaced, user lines outside it are kept, .gitignore
stays out of the ledgers).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
d34b532 committed `.venv` as a symlink to
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv, the merge
worktree's preparation link. `.gitignore` had `.venv/`, which matches
directories only, so the link was not ignored. Updating a checkout to main
then silently replaces its existing (ignored) .venv directory with that
link: a broken path on other hosts and a self-referencing link in the
original checkout. Untrack it and ignore `.venv` in both forms, which also
covers the worktree preparation link of the #21 operating model.
Also correct the release date at the top of VERSIONS.md to 2026-09-14 to
match the v4.6.0 entry. No code change; the version stays 4.6.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PfoijjCS2U6TQPFuxAE5Pn
Reviewed with [VERDICT: PASS] from claude, agy, grok (author-side loop) and codex; re-reviewed merge-side by claude, agy and codex at 4a81d15. Full suite at 4a81d15: 940 passed.
Issue #21 phase-1 prerequisites (PR #22) add loop runner options and the
control-plane module: MINOR. VERSIONS.md gains the v4.6.0 entry, the
archived design reviews lose their markdown trailing whitespace so
`git diff --check` is clean, and the three merge-side PASS reports
(claude, agy, codex @ 4a81d15) are archived under .agents/reports/.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
run_loop.sh sources lib.sh before it refuses --worktree/--base/--review-only
under MAM_LOOP_PY=0, and sourcing lib.sh runs _init_herdr_isolation, which
rewrites $WORKSPACE_ROOT/.mam/shim/herdr. T2-8 copied the caller's
environment as is, so that root was the caller's control root (normally the
real checkout). The test now requests mam_sandbox, pins WORKSPACE_ROOT to it
explicitly, runs from it, and asserts the shim was written there; the four
parametrized refusal cases and their three assertions are unchanged. No
product code changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
Review N1 (planner-reviewer-claude-01 50ae9fb0, planner-reviewer-codex-01
8779f4b4): af8a54c (R1) stopped reusing an idle pane when env flags are
present, because the old path put `env K=V ...` after `herdr agent start ...
--`, where herdr passes it to the agent executable as arguments. h25 still
required that old behaviour (idle pane reused, env in the agent command) and
failed. It now asserts the new contract instead: a pane split / workspace
create carries MY_TEST_FLAG=active and HERDR_WORKSPACE_ID as --env, the agent
does not land on the idle pane, and nothing env-like follows `--` in agent
start. Finding A's intent (env must not be silently lost) is kept; reuse
without env flags stays pinned by h24. No product code changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
Review 9a1adb46 (planner-reviewer-codex-01):
- R1 [P1] lib.sh new-session: a forwarded binding reached the agent as
arguments. With env flags the idle-pane branch prefixed `env K=V ...` to
the command that `herdr agent start ... --kind <agent> -- <args>` passes to
the agent executable. An idle pane is now reused only when no environment
has to be set; otherwise the pane is created by pane split / workspace
create, which take --env. The dead `env` prefix code is removed (its
send-text fallback would have typed the same line).
- R2 [P2] runner: a relative MAM_JOBS_DIR is resolved once against the
control root, the way the wrapper (cwd=workspace_root) reads it, and that
absolute dir is used for delegation, wait, report lookup and cleanup.
- R3 [P2] stop: the row's control_plane.delegate_job_logs_dir is restored
(exported unless the caller set DELEGATE_JOB_LOGS_DIR), so the
cancellation history lands in the row's audit-log root.
Tests: T3-1 and T3-3 now assert that the binding arrives as --env on the
pane-creation calls and that no env/K=V token follows `--` in agent start
(fresh and reused pane); T3-4c (audit root on stop) and T2-15 (relative
MAM_JOBS_DIR) are new. The design doc's compatibility table and a create
comment no longer describe the removed idle-pane env path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
Fixes the sentences the four design reviews flagged and records what phase 1
actually implements: status line, monitor-topic scope, option A's isolation
and capacity wording, creator/reviewer ownership (tests write; promotion is
the control root's), the control-plane binding persisted and forwarded, two
loop recipes ((a) the loop implements, (b) the base recorded before an
out-of-loop implementation, then --base --review-only; no base in --task),
the loop lock as a refusal, "phase 1 = parallel implementation, one review
loop", the eight Issue #21 items with their status, and a new section 7 with
root definitions and a per-skill compatibility table. The review files are
unchanged. The loop SKILL.md lock note names the lock branch instead of line
numbers that moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
A session row now records the control plane it belongs to, and the pane
gets it explicitly instead of relying on env inheritance.
- lib_py/control_plane.py (+ thin lib.sh wrappers mam_control_plane_json /
mam_control_plane_prefix): the binding is WORKSPACE_ROOT,
AGENT_SESSIONS_YAML, MAM_JOBS_DIR, DELEGATE_JOB_LOGS_DIR as absolute paths.
- create: the binding is always persisted on the row (`control_plane`) and
forwarded to the pane only when it differs from what --workspace alone
implies, as leading K=V tokens that lib.sh's new-session already turns into
--env (fresh pane) or `env K=V` (reused idle pane). pane.cmd_full and
start_command stay unprefixed. A binding with whitespace that would have to
be forwarded is refused with exit 2 (lib.sh splits the flags unquoted); the
~/.local/bin wrapper path records the binding but warns it is not forwarded.
- resume: the row's binding wins over the caller's env (a difference is
reported on stderr) and is forwarded again; a legacy row without one uses
the caller's binding and update_yaml_resumed.sh records it.
- stop: registry order MAM_JOBS_DIR > the row's mam_jobs_dir >
$WORKSPACE_ROOT/.mam/jobs > .mam/jobs, exported for delegate_publish_event.
- status: both get_job_status copies look in MAM_JOBS_DIR, the row's
registry and the matching audit logs before the old candidates.
- reconcile: MAM_JOBS_DIR wins in the subscribe heredoc and is passed in.
Tests T3-0..T3-8 (mock herdr: forwarding is verified at the herdr-call level
only; real pane env propagation is left to item 7). T3-7 is a static guard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
- --worktree <path> (CLI over MAM_LOOP_WORKTREE): the source root. Only the
diff, base, head and the empty-set check use it; the wrapper, registry,
checkpoint, loop guard, promotion and cleanup stay on the control root. The
collector is the control root's diff_collect.sh (never the worktree's
unreviewed copy), its arguments shlex-quoted. Validation: an existing
directory, the top of its working tree, the same git common dir as the
control root (".git" is resolved against the directory it came from).
- --base <ref> (CLI only, no env - a stale sha would silently widen the next
range): resolved to a full sha, must be an ancestor of the source HEAD.
- --review-only: requires --base, refuses --plan; Phase 2 is skipped and
logged; correction and comply jobs still go to the creator.
- With --worktree or --base every delegated prompt starts with a context
block (path, branch, base and its origin, head, base..head commit count,
uncommitted changes); without them prompts are byte-identical. All call
sites in run() go through one _dispatch helper.
- Fail-closed guard (only with --worktree/--base): before each review round,
self-review included, an empty change set or one git cannot determine
(exit codes, never output parsing) ends the loop with rc 1 instead of
asking for reviews. This closes the fail-open where a loop started after
an out-of-loop commit sent reviewers "(no changes since base commit)".
- The runner honours MAM_JOBS_DIR and passes --registry-dir to the wrapper.
- run_loop.sh accepts the three options; the legacy shell loop
(MAM_LOOP_PY=0) refuses them before the freeze and the loop lock.
Tests T2-1..T2-14.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
The injected prompt, the start/success/error commands and the brief's Output
Report Path used cwd-relative `.mam/jobs/...`, and the wrapper ignored
MAM_JOBS_DIR. A creator whose cwd is a git worktree could not run them as
written, and its audit log fell back to <cwd>/.mam/delegate_job_logs.
- REGISTRY_DIR defaults to ${MAM_JOBS_DIR:-.mam/jobs} and is made absolute
right after `cd "$WORKDIR"` (a relative value still resolves against WORKDIR)
- the publish command is shell-quoted (printf %q) and prefixed with
DELEGATE_JOB_LOGS_DIR=<abs>, resolved by the same get_logs_dir() the
wrapper's subscriber uses; the heartbeat timer inherits it
- direct and loop jobs share one _instructions helper; brief and report paths
are $REGISTRY_DIR/$JOB_ID/... (absolute)
- create --onboard: absolute publish_event.py, --registry-dir and log root
Tests T1-1..T1-5 (a space in the control root, commands run from another cwd
with no workspace env).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
claude, agy, grok: PASS with amendments; codex: NOT PASS (recipe overstates
current env-override support; review identity and lifecycle ownership
underspecified). Direction (creator per worktree, shared reviewer pool)
agreed by all four.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Design proposal for review (no code change): why the checkout-path
workspace assumption blocks parallel issues and caused the #18 checkout
switch, three candidate models, the recommended hybrid (writers isolated
per worktree, readers shared, one .mam control plane via env overrides),
how it maps onto today's scripts, what has to change, and the questions
put to the reviewers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Review round 5 (planner-reviewer-codex-01, NOT PASS at 2b1d590):
- F1 bounded lock cycle: the stopper holds the per-job lifecycle lock while
it waits for the timer to exit, and the timer's exit cleanup waited for
that same lock, so every stop ran out the drain and ended in SIGKILL with
a false "did not drain" warning. The exiting timer now tries the lock
without blocking (lifecycle_lock(blocking=False)); when it is busy the
ownership operation holding it removes the pidfile, and when it is free
the own-pid comparison is kept.
- F2 untracked owner: when writing the new timer's pid fails after the
spawn, _spawn_timer_locked kills and reaps that exact process while the
lock is still held and drops only a partial pidfile of that attempt,
then re-raises.
- tests: an idle timer stops well before a 10 s drain without SIGKILL;
a pidfile failure after spawn leaves no survivor, no pidfile and a free
lock (both fail on 2b1d590); a spawn failure leaves nothing behind and
the next start works.
- docs: MESSAGING 2.5 and VERSIONS item 1 describe the lifecycle lock;
VERSIONS test count 31.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
Review round 4 (codex R3): two concurrent `start_timer` calls for the same
attempt both passed the "no timer" check, both spawned, and the pidfile
tracked only one - the untracked timer could still publish a retained
`running` after `completed`. A per-job flock (`<job>.heartbeat.lock`,
separate from the registry/seq lock so in-flight beats can still take
their seq) now makes observe/drain/spawn/publish-pid one ownership
operation; the terminal stop takes the same lock; a start that finds the
attempt ended (terminal record or raised barrier) does not arm; the timer
removes its own pidfile only under the lock so a replacement's pid is never
deleted. Regressions: concurrent starts leave exactly one tracked owner that
the terminal stop drains; a start after the barrier arms nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Review round 3 (planner-reviewer-codex-01, NOT PASS R3 at 7e1d3f0):
start_timer stopped a running timer with a bare TERM and overwrote the only
pidfile. A beat the old timer had already admitted - seq taken, recorded on
disk, held at the wire - stayed alive but undiscoverable, so a later
terminal publish drained only the replacement and the old beat could still
put a retained `running` on the broker after `completed`.
- heartbeat.start_timer: stop_timer(drain=MAM_HEARTBEAT_DRAIN_S). The old
timer is drained or killed, and confirmed gone, before the pidfile names
the new one. A terminal publish racing the replacement either drains the
same old pid or meets a new timer whose beats the barrier refuses.
- test: an old timer held at the wire after admission is replaced, then
the job completes; the old timer is gone before the handover and nothing
it held follows the terminal (fails on 7e1d3f0).
- docs: MESSAGING 4.3 no longer says any state resets the idle clock;
MESSAGING 2.5 and VERSIONS state the replacement rule; test count 26.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
The subscriber is started before the injection, and the injection itself
(idle handshake, quiescence, delivery grace) can take tens of seconds, so
a first-event budget of T + K x N measured from the subscription could
expire before `started` was even possible. The wrapper now passes
--ack-timeout <wall clock>: it judges delivery with _wait_started (T from
the injection) and reaps the subscriber on failure; K x N applies from the
first event on. A standalone subscriber keeps T + K x N. Documented in
MESSAGING.md 4.3.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Review round 2 (planner-reviewer-codex-01, NOT PASS at d09c162):
- R1 remote.mirror_job: admission under the registry lock. The same
dispatch_token is idempotent (a finished attempt never runs again), a
lower attempt or the same attempt under another token is refused before
any write, and only a strictly newer attempt replaces the record
(register_job(supersede_older_attempt=True); events.log kept, seqs stay
monotonic).
- R2 remote.handle_dispatch: the --exec command runs in its own session;
at timeout_sec, or when the runner is interrupted (SIGTERM now unwinds
it), the whole process group is terminated (TERM, then KILL) before the
error is published.
- R3 terminal barrier: a terminal publish raises an attempt-scoped barrier
under the lock that allocates seqs, stops the heartbeat timer, gives it
MAM_HEARTBEAT_DRAIN_S (default 1 s) to exit and then kills it, and only
then takes its own seq. Beats are admitted under that lock
(next_seq(admit=...)); a beat still on the wire is cancelled with the
timer rather than holding the terminal back; and a late non-terminal
event never publishes a running state over a terminal record.
- R4 job_subscriber: a retained state renews the idle clock only when its
seq is newer than everything already accepted; re-delivered or older
snapshots still recover a terminal status.
- tests: five regressions, all failing on 77f2829; T1-14's wall clock
15 -> 30 s against public-broker latency.
- docs: MESSAGING 2.5 (barrier, state renewal, detection vs final close),
heartbeat docstring, loop SKILL.md (remote rows need the Python runner),
VERSIONS v4.5.0 items 1/4/5 and the test count (25).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
In the v4.5.0 review round grok read the brief and published `started`
70 s after the injection; with T = 60 the wrapper had already closed the
job as agent_blocked and refused its later PASS. T bounds delivery, not
work, so a generous default costs nothing when the agent answers fast.
Code, docs and the CLI snapshot updated together.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Both tests ran against the public broker with second-scale budgets: the
error publish after the close can take 10-20 s, and a worker's `started`
must land before a 12 s wall clock. Assert the close time from the record's
updated_at and give the loop scenario a 30 s wall clock.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Review round 1 (claude NOT PASS) corrections:
- B2: the timer now watches a process - `--watch-pid`, or by default the
nearest agent-CLI ancestor of the publishing command - and stops within
one period once it is gone, so a crashed agent stops heartbeating and is
closed after K x N; a remote runner watches itself. Documented as process
liveness (an alive-but-stuck agent keeps heartbeating).
- R1: `_wait_started` accepts an events.log `started` only when it carries
the current dispatch token (loop jobs share one events.log).
- R2: the remote runner ignores a re-sent dispatch of an attempt that
already reached its terminal.
- R3: registry.md EOF blank line (git diff --check clean).
- R4: the ack error states the effective budget when the wall clock cut it.
- P3: stop_timer verifies the pidfile pid is this job's timer before
signalling; a retained state message keeps its own seq watermark so it
can never make the monotonic guard drop its own event.
- MULTI_AGENT_RULES.md timeout section updated for the contract.
- tests: agent-process death stops the timer, recycled-pid safety,
attempt-scoped started, re-sent dispatch; acceptance 3 now kills the
agent process instead of the timer. Help captures refreshed
(--watch-pid). The wrapper can be sourced with
MAM_DELEGATE_JOB_NO_MAIN=1 for helper-level tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
T1-14 and T1-20 model an agent that goes `working` and completes late. Under
the liveness contract an agent that never publishes `started` is a delivery
failure, so the watcher threads now acknowledge (non-blocking: the record
lands in events.log before the wire publish) and the late completion is
honoured inside the K x N grace as before. help-job_subscriber.txt records
the intended --ack-timeout option.
Full suite at 66f23c7: 888 passed, 3 failed (these), 0 leaked daemons.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Contract (no new event types): heartbeat every N s (automatic timer),
unresponsive after K x N s without an event, `started` within T s of the
injection, retained per-job state, and a remote transport.
- registry: heartbeat_sec / heartbeat_misses / ack_timeout_sec / transport
fields; idle_timeout_sec derives K x N unless explicit (defaults 30/4/60
keep the historical 120 s idle window).
- heartbeat.py + publish_event --heartbeat N: detached fixed-cadence timer
publishing signed `progress` heartbeats (threaded wire publish, local
record first); stopped by the terminal event via pidfile, by re-dispatch
and by lifetime. Terminal events refuse --heartbeat.
- publish_event: retained `<prefix>/state` (status, last_event, seq, HMAC)
sent on the same connection after every event (--no-state to skip);
publisher.publish_raw takes extra messages.
- job_subscriber: subscribes the state topic, recovers terminal status from
retained state, resets idle on any accepted message, records seq_gap and
reconnected in the audit log, idle budget before the first event is
T + K x N (--ack-timeout), --remote mirrors started/terminal events into
the registry through the transition boundary.
- lib.sh send_keys_safe: an accepted-but-unconfirmed submission is confirmed
by agent_status working or the job's own `started` (_sks_job_started);
pane change and busy token are no longer delivery signals.
- wrapper: --heartbeat/--heartbeat-misses/--ack-timeout (MAM_* env),
injected start command arms the timer, brief states the contract,
_wait_started (registry / events.log / subscriber log, bounded by T and
the wall clock) replaces the 10 s inject grace, R1 grace uses only the
on-disk event age (herdr/pane logged once as a diagnostic),
remote:<name> sessions skip preflight/idle/pane and dispatch over the
broker (remote_dispatch).
- remote.py (+ scripts shim): signed retained dispatch (MAM_DISPATCH_SECRET)
and a runner that mirrors the job into its own registry with the
orchestrator's identity, publishes started --heartbeat, runs --exec and
publishes the terminal event.
- loop runner: passes MAM_HEARTBEAT_SEC / MAM_HEARTBEAT_MISSES /
MAM_ACK_TIMEOUT_S through; YAML rows with transport: remote delegate as
remote:<name>.
- docs: MESSAGING.md 2.5 contract + remote transport, delegate-job SKILL.md,
registry.md, loop SKILL.md.
- tests: tests/test_issue18_liveness_contract.py (16); issue 14 pane-signal
tests flipped to started-based; issue 6 working-agent tests acknowledge
with started; CLI snapshot/help captures refreshed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Review round 3 (reviewer-grok-01, NOT PASS N1; non-blocking B1):
- reconcile.sh (N1): the python subscriber installs its SIGTERM/SIGINT
handlers before connect/loop_start. A signal during the broker handshake,
the CONNACK wait, or right after the paho thread logs "subscribed to" now
takes the clean path (the signal line, loop_stop/disconnect, rc 0) instead
of Python's default action; the CONNACK wait ends on it, and a signalled
subscriber never falls back to polling (rc 3).
- reconcile.sh (B1): `_sub_stop` reads `$!` with nounset off. On bash 3.2
`${!:-}` and `${!+x}` are both unbound before the first `&`, so the trap
died with rc 1 instead of 143.
- tests: SIGTERM during a CONNACK wait against a silent local listener (no
broker needed), and the verbatim `_sub_stop` run without and with a
child; both fail on 8f9745b.
- docs: monitor SKILL.md and VERSIONS.md state the handler order; test
count 16.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
Review round 2 (planner-reviewer-codex-01, NOT PASS P2):
- conftest `_is_subscriber_daemon_argv`: `ps -o args=` joins argv unquoted,
so the script path is taken from the shell word up to the first
`/reconcile.sh --subscribe` instead of as one whitespace token. A sandbox
or --basetemp path with a space no longer hides its daemon from the
fixture reaper and the session leak guard. A candidate with whitespace
counts only when it names an existing file, so a bash wrapper under the
root whose later argv carries the words is still excluded.
- tests: a whitespace-path selector regression and an owned live-process
cleanup check (both fail on c096cb4); the R-2 test also pins the bash
wrapper exclusion.
- docs: the polling fallback's signal handling is stated precisely in
monitor SKILL.md and VERSIONS.md (a running --once pass finishes first,
the interval's sleep child ends on its own); create SKILL.md says an
already-running daemon's PID is still reported under the opt-out;
VERSIONS selector wording and test count (14).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
Review round 1 (claude PASS, agy PASS) recommendations:
- reconcile.sh: `_sub_stop` falls back to `$!` for the window before
`_sub_pid=$!` (R-1); lock identity compared as (st_dev, st_ino).
- lib.sh legacy start_watchdog prints nothing when opted out and no daemon
runs, byte-identical to the python body.
- conftest/test: `_is_subscriber_daemon_argv` selects only
`bash|sh <root>/.../reconcile.sh --subscribe`, never a brief-carrying
wrapper that quotes the words or a prefix-colliding sibling root (R-2);
one new unit test for the selector.
- monitor SKILL.md / VERSIONS.md: the polling fallback checks the lock
file's presence only and does not exclude a second daemon; measurement
wording uses the argv-shape criterion instead of raw pgrep.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
The reaper executes in fixture teardown, before monkeypatch restores a
test's patches; G5 forbids subprocess.run/Popen for the test's duration and
subprocess.run resolves Popen through the module at call time, so the
teardown tripped the spy. Bind the Popen class at import instead.
Full suite at 38d8ccb: 870 passed, 1 error (this teardown), 0 leaked daemons.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
create_session.sh detaches `reconcile.sh --subscribe --idle-timeout 0` per
workspace; test sandboxes never reaped it and the daemon neither noticed its
workspace vanish nor honoured SIGTERM (bash deferred the signal behind the
foreground python child). 202 orphans were found holding public-broker
wildcard subscriptions for deleted pytest tmpdirs.
- daemon.py / lib.sh: MAM_NO_SUBSCRIBER_DAEMON=1 (literal) skips the spawn but
still reports a running daemon; cli prints a stderr note, stdout stays empty.
- reconcile.sh: python subscriber runs as a waited background child with a
TERM/INT trap (exit 143); the MQTT loop exits when the workspace dir is gone
or .mam/monitor.lock is no longer the held inode; SIGTERM/SIGINT handled via
the stop event so loop_stop/disconnect run; the polling fallback checks the
same and sleeps in a waited child.
- conftest: sandbox default MAM_NO_SUBSCRIBER_DAEMON=1, per-sandbox reaper
finalizer, session-scope leak guard over the pytest basetemp.
- tests: 11 lifecycle tests (tests/test_issue19_subscriber_daemon_lifecycle.py);
phase-1 watchdog/daemon characterization tests opt back in.
- docs: monitor SKILL.md lifecycle contract + pitfalls, create SKILL.md note.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
The test blocked on each publish_event heartbeat, and against the public
broker a publish (with retries) takes 2-6 s, so consecutive events.log
records landed further apart than the 6 s idle window and the wrapper
closed the job before the next heartbeat - a broker-latency flake that
failed the full suite once and passed on re-run.
Heartbeats are now fired without waiting for the wire publish (the disk
record is written before the broker is touched, ebe58f6) and keep coming
for 20 s to outlast a slow connect; the wall clock is widened to match.
3/3 green in a row afterwards.
Refs #14
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff