fix(reset): refuse id-less claude/grok targets, gate submit during a reset, validate the spec (#78)
Review round 1 of PR #82: - A running claude/grok row without a recorded id is refused by the precheck (stop would fill it with a folder-wide guess the purge then deletes); record-id never records a pre-assigned guess, and a target is skipped (id_unverified) when the row's id after the normal stop is not the one recorded before it. - reset takes the update admission lock exclusively while it publishes .mam/loop-guard-active and .mam/reset-in-progress; delegate submit refuses on a live reset marker (mam_reset_admit). Before each target's stop its open jobs are re-read (skipped as open_job, never cancelled), and a row whose agent/server no longer matches the spec is skipped. - read_spec validates every stored field (herdr_server required, never defaulted) and rejects duplicate names. - Signal tests wait 60 s for the kill-session gate (this Mac needs ~18 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f90a705316
commit
5c3c2eb861
6 files changed
+529
-62
No files matched your search
@@ -20,7 +20,7 @@ import sys
|
||||
import tempfile
|
||||
from typing import Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
from lib_py.agents.base import AGENT_SESSION_ENV
|
||||
from lib_py.agents.base import AGENT_SESSION_ENV, POST_DISCOVERED
|
||||
from lib_py.agents.registry import agent_of_row, get_adapter, own_key
|
||||
from lib_py.update import open_jobs, row_server
|
||||
|
||||
@@ -40,10 +40,21 @@ def spec_path(workspace: str) -> str:
|
||||
return os.path.join(_real(workspace), SPEC_REL)
|
||||
|
||||
|
||||
def _rows_by_name(state: dict) -> Dict[str, dict]:
|
||||
return {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
|
||||
|
||||
def _state(workspace: str = "") -> dict:
|
||||
raw = os.environ.get("MAM_STATE_JSON")
|
||||
if raw is not None:
|
||||
return json.loads(raw or "{}") or {}
|
||||
# Only --dry-run gets here (reset.sh passes MAM_STATE_JSON everywhere
|
||||
# else): it does not source lib.sh, and reads a copy of the DB because a
|
||||
# read on macOS rewrites -shm, so the dry run changes nothing under .mam.
|
||||
yaml_path = os.environ.get("AGENT_SESSIONS_YAML") or (
|
||||
os.path.join(_real(workspace), ".mam", "agent-sessions.yaml") if workspace else ""
|
||||
)
|
||||
@@ -136,6 +147,15 @@ def select_targets(workspace: str, state: dict) -> dict:
|
||||
key = own_key(agent)
|
||||
raw_id = row.get(key)
|
||||
old_id = str(raw_id).strip() if isinstance(raw_id, str) and raw_id.strip() else None
|
||||
if old_id is None and get_adapter(agent).id_lifecycle != POST_DISCOVERED:
|
||||
# Its id is given at spawn, so none on the row means none was ever
|
||||
# pinned (the monitor defers an ambiguous one); stop would fill it
|
||||
# with a folder-wide guess that the purge would then delete.
|
||||
errors.append(
|
||||
"running session %s (%s) has no recorded conversation id; record it (monitor) "
|
||||
"or stop/purge it by hand first" % (name, agent)
|
||||
)
|
||||
continue
|
||||
targets.append({
|
||||
"name": name,
|
||||
"agent": agent,
|
||||
@@ -219,14 +239,28 @@ def read_spec(workspace: str) -> dict:
|
||||
agents = doc.get("agents")
|
||||
if not isinstance(agents, list):
|
||||
raise SpecError("spec file %s has no agents list; check it, then remove it by hand" % path)
|
||||
names = set()
|
||||
for item in agents:
|
||||
if not isinstance(item, dict):
|
||||
raise SpecError("spec file %s has a non-object agent entry; check it, then remove it by hand" % path)
|
||||
name = item.get("name")
|
||||
agent = item.get("agent")
|
||||
role = item.get("role")
|
||||
if not (isinstance(name, str) and name and isinstance(agent, str) and agent and isinstance(role, str) and role):
|
||||
server = item.get("herdr_server")
|
||||
old_id = item.get("old_id")
|
||||
if not (
|
||||
all(isinstance(v, str) and v for v in (name, agent, role, server))
|
||||
and isinstance(item.get("herdr_workspace"), str)
|
||||
and "old_id" in item
|
||||
and (old_id is None or (isinstance(old_id, str) and old_id))
|
||||
and isinstance(item.get("old_created_at"), str)
|
||||
):
|
||||
raise SpecError("spec file %s has an incomplete agent entry; check it, then remove it by hand" % path)
|
||||
if name in names:
|
||||
raise SpecError(
|
||||
"spec file %s lists %s twice; check it, then remove it by hand" % (path, name)
|
||||
)
|
||||
names.add(name)
|
||||
adapter = get_adapter(agent)
|
||||
if adapter is None or adapter.name != agent or not own_key(agent):
|
||||
raise SpecError(
|
||||
@@ -245,12 +279,7 @@ def record_old_id(workspace: str, state: dict, session_name: str) -> Optional[st
|
||||
"""
|
||||
ws_real = _real(workspace)
|
||||
spec = read_spec(ws_real)
|
||||
rows_by_name = {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
row = rows_by_name.get(session_name)
|
||||
row = _rows_by_name(state).get(session_name)
|
||||
if row is None:
|
||||
return None
|
||||
changed = False
|
||||
@@ -262,6 +291,10 @@ def record_old_id(workspace: str, state: dict, session_name: str) -> Optional[st
|
||||
old_id = str(raw_old).strip() if isinstance(raw_old, str) and raw_old.strip() else None
|
||||
if old_id:
|
||||
return old_id
|
||||
# Only a post-discovered id is captured by this pane's pid (#40); a
|
||||
# pre-assigned agent's is a folder-wide guess, never recorded.
|
||||
if get_adapter(entry["agent"]).id_lifecycle != POST_DISCOVERED:
|
||||
return None
|
||||
cur_created_at = str(row.get("herdr_session_created_at") or "")
|
||||
old_created_at = str(entry.get("old_created_at") or "")
|
||||
if cur_created_at != old_created_at:
|
||||
@@ -433,15 +466,22 @@ def precheck(
|
||||
)
|
||||
)
|
||||
|
||||
rows_by_name = {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
blockers.extend(_caller_blockers(targets, rows_by_name, env, ancestors=ancestors))
|
||||
blockers.extend(_caller_blockers(targets, _rows_by_name(state), env, ancestors=ancestors))
|
||||
return blockers
|
||||
|
||||
|
||||
def target_open_jobs(workspace: str, state: dict, env: Mapping[str, str], session_name: str) -> List[str]:
|
||||
"""Open (or unreadable) job records addressed to one target, re-read just
|
||||
before its stop: a job registered after the precheck is never cancelled."""
|
||||
out: List[str] = []
|
||||
for j in open_jobs(_real(workspace), state, env):
|
||||
if j.get("unreadable"):
|
||||
out.append("job record %s cannot be read" % j["path"])
|
||||
elif j["agent_session"] == "herdr:%s" % session_name:
|
||||
out.append("job %s (%s) is open" % (j["job_id"], j["status"]))
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- D1: entries shell lines
|
||||
|
||||
def format_entry_line(entry: dict, row: Optional[dict]) -> str:
|
||||
@@ -454,16 +494,27 @@ def format_entry_line(entry: dict, row: Optional[dict]) -> str:
|
||||
("R_NAME", str(entry.get("name") or "")),
|
||||
("R_AGENT", str(entry.get("agent") or "")),
|
||||
("R_ROLE", str(entry.get("role") or "")),
|
||||
("R_SERVER", str(entry.get("herdr_server") or "default")),
|
||||
("R_SERVER", str(entry.get("herdr_server") or "")),
|
||||
("R_LABEL", str(entry.get("herdr_workspace") or "")),
|
||||
("R_OLD_ID", str(entry.get("old_id") or "")),
|
||||
("R_ROW_ID", row_id),
|
||||
("R_ROW_AGENT", (agent_of_row(row) or "") if row else ""),
|
||||
("R_ROW_SERVER", str(row_server(row)) if row else ""),
|
||||
]
|
||||
return " ".join("%s=%s" % (k, shlex.quote(v)) for k, v in pairs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- D8: result table
|
||||
|
||||
SKIP_RESULTS = {
|
||||
"no_id": "skipped: no conversation id recorded",
|
||||
"purge_refused": "skipped: purge refused",
|
||||
"open_job": "skipped: job open",
|
||||
"id_unverified": "skipped: id not verified",
|
||||
"changed": "skipped: row changed",
|
||||
}
|
||||
|
||||
|
||||
def _parse_skipped(items: Optional[List[str]]) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
for raw in items or []:
|
||||
@@ -497,11 +548,7 @@ def build_table(
|
||||
failed_spec: Optional[str] = None,
|
||||
) -> Tuple[str, int]:
|
||||
"""Format the D8 result table and compute the D9 exit code (0, 1, 4, or 5)."""
|
||||
rows_by_name = {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
rows_by_name = _rows_by_name(state)
|
||||
skipped_map = _parse_skipped(skipped_items)
|
||||
failed = _parse_failed(failed_spec)
|
||||
failed_name = failed[0] if failed else None
|
||||
@@ -539,11 +586,7 @@ def build_table(
|
||||
result_col = "not reached"
|
||||
has_skip_or_incomplete = True
|
||||
elif name in skipped_map:
|
||||
kind = skipped_map[name]
|
||||
if kind == "no_id":
|
||||
result_col = "skipped: no conversation id recorded"
|
||||
else:
|
||||
result_col = "skipped: purge refused"
|
||||
result_col = SKIP_RESULTS.get(skipped_map[name], SKIP_RESULTS["purge_refused"])
|
||||
has_skip_or_incomplete = True
|
||||
elif r_state == "done":
|
||||
result_col = "done"
|
||||
@@ -598,11 +641,7 @@ def format_dry_run(
|
||||
refusals: List[str] = list(extra_refusals or [])
|
||||
refusals.extend(precheck(ws_real, state, env))
|
||||
|
||||
rows_by_name = {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
rows_by_name = _rows_by_name(state)
|
||||
sel = select_targets(ws_real, state)
|
||||
spath = spec_path(ws_real)
|
||||
action_map = {
|
||||
@@ -628,7 +667,7 @@ def format_dry_run(
|
||||
e["name"],
|
||||
e["agent"],
|
||||
e["role"],
|
||||
e.get("herdr_server") or "default",
|
||||
e["herdr_server"],
|
||||
e.get("herdr_workspace") or "",
|
||||
e.get("old_id") or "-",
|
||||
e.get("old_created_at") or "-",
|
||||
@@ -694,6 +733,10 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
s.add_argument("--session", default="")
|
||||
s.add_argument("--warn-new", action="store_true")
|
||||
|
||||
s = sub.add_parser("open-jobs")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--session", required=True)
|
||||
|
||||
s = sub.add_parser("record-id")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--session", required=True)
|
||||
@@ -766,17 +809,19 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
% (spec_path(ws), ", ".join(extra)),
|
||||
file=sys.stderr,
|
||||
)
|
||||
rows_by_name = {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
rows_by_name = _rows_by_name(state)
|
||||
for e in spec["agents"]:
|
||||
if a.session and e.get("name") != a.session:
|
||||
continue
|
||||
print(format_entry_line(e, rows_by_name.get(e["name"])))
|
||||
return 0
|
||||
|
||||
if a.cmd == "open-jobs":
|
||||
lines = target_open_jobs(ws, state, os.environ, a.session)
|
||||
for line in lines:
|
||||
print(line)
|
||||
return 1 if lines else 0
|
||||
|
||||
if a.cmd == "record-id":
|
||||
try:
|
||||
record_old_id(ws, state, a.session)
|
||||
|
||||
Reference in new issue
Block a user