diff --git a/bin/fm-secondmate-cloud-monitor.py b/bin/fm-secondmate-cloud-monitor.py index 1cc0835df5b..57ed25dc743 100755 --- a/bin/fm-secondmate-cloud-monitor.py +++ b/bin/fm-secondmate-cloud-monitor.py @@ -105,26 +105,30 @@ FM_HOME is left exactly as this monitor received it, which is the controller's own home, so the money document needs no pin and none is set (see spawn_environment for why a pin here would be a durable trap and why - moving FM_HOME aims at a second money document). Nothing about home, - account, worktree, harness, SKU, project, or repository comes from the - cloud side - the project is local policy (FM_SECONDMATE_CHILD_PROJECT, - else that home's single project), and the harness is the cloud lane's only - runtime. The child's backlog row is filed first, because fm-spawn refuses - a new ship/scout task that has none. - - STILL REFUSED HERE, but no longer for want of a capability. The two - missing pieces this comment used to name - an assertable owner kind and an - authorized task-home parameter - both landed in PR #278: fm-spawn takes - FM_SPAWN_TASK_HOME, derives owner_kind from THAT home's marker rather than - from its own, and forwards it to the controller as `--task-home`, which - authorize_task_home proves under the same lock hold that inserts (marker, - canonical registry link, then the unchanged child bounds). - This relay does not set FM_SPAWN_TASK_HOME, so its spawn's task home is - still the controller's primary home, owner_kind is still derived as - primary, and verify_request still refuses a primary-owned request carrying - a parent pair - the refusal round-trips as a durable delivered answer and - no child is recorded, exactly as before. Wiring the compartment's own home - through as the task home is a behavior change with its own bounds to - prove, so it is a follow-up, not a side effect of the chain tip lane. + moving FM_HOME aims at a second money document). What DOES travel is + FM_SPAWN_TASK_HOME, the compartment's own home, which is where this + child's state/, data/ and projects/ live and where fm-spawn derives + owner_kind=secondmate from - the B.5 step 3 split (AMENDMENT 2). Nothing + about home, account, worktree, harness, SKU, project, or repository comes + from the cloud side - the project is local policy + (FM_SECONDMATE_CHILD_PROJECT, else that home's single project), and the + harness is the cloud lane's only runtime. The child's brief and backlog + row are written into the TASK home, because that is the data/ fm-spawn + reads, and the row is filed first because fm-spawn refuses a new + ship/scout task that has none. + - ADMITTED HERE, and bounded. The two capabilities this comment used to + name as missing - an assertable owner kind and an authorized task-home + parameter - landed in PR #278: fm-spawn takes FM_SPAWN_TASK_HOME, derives + owner_kind from THAT home's marker rather than from its own, and forwards + it to the controller as `--task-home`, which authorize_task_home proves + under the same lock hold that inserts (marker, canonical registry link, + then the unchanged child bounds). This relay now supplies that task home, + so the request is secondmate-owned, its bindings are minted from the + child's meta under the compartment's home, and its home_binding names + that home while the money document keeps the primary's. What bounds it is + unchanged and not this file's to relax: depth one, fan-out, lifetime + total and parent liveness, all enforced by enforce_child_bounds under the + one lock, over the one document FM_HOME still names. - ACCEPTANCE IS PROVEN BY THE QUEUE. A zero exit from fm-spawn is evidence the script ran, never that the controller admitted a bounded child, so a served request is confirmed by reading the one controller document back @@ -1009,6 +1013,11 @@ def __init__(self, args, out): self.childreq.mkdir(parents=True, exist_ok=True, mode=0o700) self.inbox = Path(args.inbox) # TWO HOMES, deliberately separate (see spawn_environment): + # spawn_home is the PRIMARY's, and names the ONE money document that + # every child request is admitted into; home is THIS compartment's + # own, and is the TASK home a child's authorities, brief, backlog, + # project and result live under, plus the worktree deltas are cut + # from. Confusing them is the whole bug class this split exists for. self.spawn_home = Path(args.spawn_home) self.home = Path(args.home) if args.home else None self.controller = Path(args.controller) @@ -1183,11 +1192,14 @@ def resolve_project(self): Returns (path, "") or ("", failed check). Nothing about the repository is expressible from the cloud side, so this is decided here from the - secondmate home alone. + COMPARTMENT's own home alone - the same home fm-spawn resolves the + project argument under, because FM_SPAWN_TASK_HOME points its + projects/ there (see spawn_environment). Reading the primary's + projects/ instead would name a directory the spawn cannot use. """ - if not self.spawn_home.is_dir(): + if self.home is None or not self.home.is_dir(): return "", "child spawn refused: the requesting home is unavailable locally" - projects = self.spawn_home / "projects" + projects = self.home / "projects" chosen = os.environ.get("FM_SECONDMATE_CHILD_PROJECT", "") if chosen: if "/" in chosen or chosen in ("", ".", ".."): @@ -1213,8 +1225,12 @@ def ensure_backlog_row(self, child_task, kind, project, brief): markdown append is the documented fallback shape fm-spawn itself scans when the tasks-axi backend is unavailable. Returns "" or a failed check. + + The backlog fm-spawn actually scans is the TASK home's, because + FM_SPAWN_TASK_HOME points data/ at the compartment's own home; a row + filed under the primary would leave that gate refusing every child. """ - data = self.spawn_home / "data" + data = self.home / "data" backlog = data / "backlog.md" summary = "" for line in brief.splitlines(): @@ -1287,54 +1303,72 @@ def backlog_row_present(backlog, child_task): return False def spawn_environment(self): - """The child spawn's environment: this home, no pins, no overrides. + """The child spawn's environment: the primary's FM_HOME, the + compartment's own home as the TASK home, no pins, no overrides. - WHAT ACTUALLY BLOCKS A COMPARTMENT CHILD, stated correctly (an earlier + THE SPLIT, and why it is the whole capability (design B.5 step 3 as + rewritten by AMENDMENT 2). Stated correctly, because an earlier revision of this comment blamed verify_state's home fence, which is - wrong and pointed at the one guard here that must NOT be weakened): - - The controller already admits a compartment child. `--owner-kind` is a - plain argv flag on `request`; the controller never inspects FM_HOME to - decide it. A request minted with FM_HOME set to the controller's own - home and `--owner-kind secondmate` plus the parent pair is admitted - with all four B.1 bounds enforced and children_total incremented. - - The pinch point was that FM_HOME did three separable jobs at once: - (1) where the requesting task's local authorities live, because - authoritative_request_bindings reads `env["home"]/state/.meta`; - (2) the identity stamped into the request's home_binding; and (3) the - identity of the money document. Jobs 1 and 2 belong to the requester, - job 3 belongs to the controller, and the compartment child is the - first case where they differ. - - PR #278 SPLIT THEM, so that capability is no longer missing: FM_HOME - keeps job 3 only and never moves for the spawn, while - FM_SPAWN_TASK_HOME carries jobs 1 and 2 and travels to the controller - as `--task-home`, which authorize_task_home proves under the same lock - hold that inserts. Nothing in that chain is self-authorizing (home - marker, canonical registry link, then the unchanged child bounds). - - This method still does NOTHING clever, and now that is a CHOICE rather - than a constraint: it leaves FM_HOME as the monitor's own home, which - is the controller's home, so the money document stays one document and - the default worker state directory is already the right one. Because - it also sets no FM_SPAWN_TASK_HOME, the spawn's task home is the - primary home, owner_kind derives as primary, and the lane still - refuses early and honestly at verify_request. Passing the - compartment's own home as the task home is the follow-up that would - actually admit a compartment child, and it is a behavior change with - its own bounds to prove. - - Two ideas remain rejected on evidence: + wrong and pointed at a guard that must NOT be weakened: the controller + never inspected FM_HOME to decide owner_kind, and the home fence was + never the blocker. + + FM_HOME does three separable jobs at once: (1) where the requesting + task's local authorities live, because authoritative_request_bindings + reads `/state/.meta`; (2) the identity stamped into the + request's home_binding; and (3) the identity of the money document. + Jobs 1 and 2 belong to the requester, job 3 belongs to the controller, + and the compartment child is the first case where they differ. PR #278 + split them; this method is what supplies the split half. So: + + - FM_HOME is left EXACTLY as this monitor received it, which is the + controller's own home. FM_HOME keeps job 3 alone. It is what names + the ONE money document, and it must not move (see the rejected + alternatives). + - FM_SPAWN_TASK_HOME names the COMPARTMENT's own home and carries + jobs 1 and 2. fm-spawn reads this spawn's state/, data/ and + projects/ from there, derives owner_kind=secondmate from THAT + home's marker rather than from its own, and forwards the path as + `--task-home`, which the controller refuses outside a compartment + child request and then proves through authorize_task_home under + the same lock hold that inserts. Nothing in that chain is + self-authorizing: home marker, canonical registry link, then the + unchanged child bounds. + + The path is `--home`, this compartment's own leased home, which the + wrapper reads from the durable `/.cloud-worktree` + the compartment's own spawn wrote. It is never reconstructed here + from a task id, a naming convention, or a registry read. + + ORDERING HAZARD, deliberately survived: this method pops + FM_STATE_OVERRIDE and every FM_SECONDMATE_* key, so the assignment is + made after the pops and its name sits outside both popped namespaces. + The FM_STATE_OVERRIDE pop is an interlock rather than a nicety - + fm-spawn refuses FM_SPAWN_TASK_HOME combined with FM_STATE_OVERRIDE, + so a leg-scoped state override reaching this argv would refuse the + whole lane. FM_DATA_OVERRIDE and FM_PROJECTS_OVERRIDE are NOT popped + here; fm-spawn refuses those two alongside a task home as well, so an + inherited one fails the lane closed rather than silently re-pointing + the child's data/ or projects/. That is a refusal this method relies + on, not one it performs. + + Two earlier ideas stay rejected on evidence, and this is the reasoning + that keeps a future edit from re-introducing either: - Pinning FM_AZURE_WORKER_STATE_DIR is a DURABLE TRAP, not a - one-shot: that name is inside SPAWN_CLOUD_ENV_ALLOWLIST, so it is - persisted into the child's .cloud-env and would misdirect - every later execute and release. The doc says the same thing. - - Moving FM_HOME itself to the secondmate home aims the request at a - SECOND money document (the one the documented local-secondmate - lane already creates) and refuses at parent liveness. #278's - answer is exactly this comment's answer: move the TASK home, never - FM_HOME. + one-shot: that name is inside SPAWN_CLOUD_ENV_ALLOWLIST + (bin/fm-spawn.sh), so it is persisted into the child's + .cloud-env and would permanently pair a foreign FM_HOME with + this state dir for every later execute and release. + FM_SPAWN_TASK_HOME is deliberately NOT on that allowlist for the + same reason: it is consumed once, by this spawn, and the durable + record of the split is the queue item's own `task_home` field, + which the release lane reads back through authority_home. + - Moving FM_HOME to the secondmate home aims the request at a SECOND + money document (the one the documented local-secondmate lane + already creates) and refuses at parent liveness, which is both the + outcome the design forbids and a refusal that names the wrong + cause. The answer is this method's answer: move the TASK home, + never FM_HOME. """ env = dict(os.environ) # The compartment's own leg configuration and any state override must @@ -1346,15 +1380,24 @@ def spawn_environment(self): env["FM_SPAWN_CLOUD"] = "azure" env["FM_SPAWN_PARENT_TASK"] = self.task env["FM_SPAWN_PARENT_TASK_GENERATION"] = self.generation + # After the pops, on purpose: see ORDERING HAZARD above. + env["FM_SPAWN_TASK_HOME"] = str(self.home) return env def spawn_child(self, message, digest, landed): + # resolve_project is the ONE place that decides the compartment's own + # home is usable, and it refuses by name before any side effect, so + # spawn_environment below can rely on self.home being a real + # directory. Repeating that check here would put one invariant in two + # places; a silent fallback to the primary would be the exact home + # confusion this split exists to prevent. project, failed = self.resolve_project() if failed: self.refuse(digest, landed, failed) return child_task = "{}-c{}".format(self.task, digest[:8]) - brief = self.spawn_home / "data" / child_task / "brief.md" + # The TASK home's data/, which is where fm-spawn looks for the brief. + brief = self.home / "data" / child_task / "brief.md" try: write_atomic(brief, message["brief"].encode("utf-8")) except OSError as exc: @@ -1487,8 +1530,14 @@ def child_classification(self, child_task): The controller queue records reaching a terminal state, never whether the work succeeded, so the split comes from the result the child's own lane wrote; absent, the honest answer says so rather than guessing. + + The child's lane writes that result under the TASK home's state/, + because the split aimed this task's state/ there; the primary's + state/ holds the money document and this compartment's own files. """ - path = self.spawn_home / "state" / "{}.worker-result.json".format(child_task) + if self.home is None: + return "complete (this compartment's own home is not recorded locally)" + path = self.home / "state" / "{}.worker-result.json".format(child_task) try: result = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): @@ -1769,7 +1818,11 @@ def main(argv=None): ) relay.add_argument( "--home", required=True, - help="the compartment's local secondmate home (delta bundles only)", + help=( + "the compartment's own local secondmate home: the TASK home for its " + "children (state/, data/, projects/) and the worktree delta bundles " + "are cut from" + ), ) relay.add_argument("--controller", required=True, help="the ONE money authority document") relay.add_argument("--spawn-bin", required=True) diff --git a/tests/fm-secondmate-cloud-monitor.test.sh b/tests/fm-secondmate-cloud-monitor.test.sh index 9744b43d736..ae38a2112c1 100755 --- a/tests/fm-secondmate-cloud-monitor.test.sh +++ b/tests/fm-secondmate-cloud-monitor.test.sh @@ -209,8 +209,9 @@ LOG=${FM_FIXTURE_SPAWN_LOG:?} if [ "$first" = 1 ]; then printf '%s' "$arg"; first=0; else printf '\x1f%s' "$arg"; fi done printf '\n' - printf 'env\x1fFM_HOME=%s\x1fFM_SPAWN_PARENT_TASK=%s\x1fFM_SPAWN_PARENT_TASK_GENERATION=%s\x1fFM_SPAWN_CLOUD=%s\x1fFM_AZURE_WORKER_STATE_DIR=%s\x1fFM_STATE_OVERRIDE=%s\x1fFM_SECONDMATE_LEG_SECONDS=%s\n' \ - "${FM_HOME:-}" "${FM_SPAWN_PARENT_TASK:-}" "${FM_SPAWN_PARENT_TASK_GENERATION:-}" \ + printf 'env\x1fFM_HOME=%s\x1fFM_SPAWN_TASK_HOME=%s\x1fFM_SPAWN_PARENT_TASK=%s\x1fFM_SPAWN_PARENT_TASK_GENERATION=%s\x1fFM_SPAWN_CLOUD=%s\x1fFM_AZURE_WORKER_STATE_DIR=%s\x1fFM_STATE_OVERRIDE=%s\x1fFM_SECONDMATE_LEG_SECONDS=%s\n' \ + "${FM_HOME:-}" "${FM_SPAWN_TASK_HOME:-}" \ + "${FM_SPAWN_PARENT_TASK:-}" "${FM_SPAWN_PARENT_TASK_GENERATION:-}" \ "${FM_SPAWN_CLOUD:-}" "${FM_AZURE_WORKER_STATE_DIR:-}" \ "${FM_STATE_OVERRIDE:-}" "${FM_SECONDMATE_LEG_SECONDS:-}" } >> "$LOG" @@ -281,12 +282,17 @@ make_world() { git clone --quiet "$ORIGIN" "$GUEST_REPO" BASE=$(git -C "$LANDING" rev-parse HEAD) printf '%s\n' "$LANDING" > "$STATE_DIR/$ID.cloud-worktree" - # The compartment's local secondmate home IS the recorded worktree; its only - # job in the relay is the delta bundle. The CHILD's local authorities (brief, - # backlog row, project) live in the home the spawn runs under, which is this - # monitor's own home, because that is the controller's home and the relay - # deliberately does not move FM_HOME. - mkdir -p "$LANDING/data" "$HOME_DIR/projects/alpha" "$HOME_DIR/data" + # The compartment's local secondmate home IS the recorded worktree, and it + # is the child's TASK HOME: its brief, backlog row, project and worker + # result all live there, because the relay hands it to fm-spawn as + # FM_SPAWN_TASK_HOME. FM_HOME stays this monitor's own home, the + # controller's, because that is what names the ONE money document. + # The two homes carry DIFFERENTLY NAMED projects on purpose: the child's + # project must resolve out of the compartment's home, so a regression that + # read the primary's projects/ names the wrong directory instead of + # accidentally naming the right one. + mkdir -p "$LANDING/data" "$LANDING/projects/alpha" "$LANDING/state" \ + "$HOME_DIR/projects/primary-only" "$HOME_DIR/data" # Persisted compartment environment: identities and durable leg config, # exactly what the gated spawn writes. Small legs keep the suite fast. { @@ -1496,11 +1502,17 @@ PY [ -n "$self_digest" ] || fail "the real runner landed no child request" child="$ID-c${self_digest:0:8}" argv=$(sed -n 1p "$SP_LOG") - [ "$argv" = "$(printf '%s\x1f%s\x1f--harness\x1fpi\x1f--model\x1fgpt-5\x1f--effort\x1fhigh' "$child" "$HOME_DIR/projects/alpha")" ] \ + # The project resolves out of the COMPARTMENT's home (alpha), never the + # primary's (primary-only): the split points fm-spawn's projects/ there. + [ "$argv" = "$(printf '%s\x1f%s\x1f--harness\x1fpi\x1f--model\x1fgpt-5\x1f--effort\x1fhigh' "$child" "$LANDING/projects/alpha")" ] \ || fail "the child spawn argv is not the exact ship shape: $(printf '%s' "$argv" | tr '\037' '|')" env_line=$(sed -n 2p "$SP_LOG") assert_contains "$env_line" "FM_HOME=$HOME_DIR" \ "the child spawn moved FM_HOME; it must stay the controller's own home" + # THE SPLIT: the task home is the compartment's own home, and it is what + # makes fm-spawn derive owner_kind=secondmate and forward --task-home. + assert_contains "$env_line" "FM_SPAWN_TASK_HOME=$LANDING" \ + "the child spawn carried no task home, so it would mint an unrestricted primary-owned request" assert_contains "$env_line" "FM_SPAWN_PARENT_TASK=$ID" "the child spawn lost the parent task" assert_contains "$env_line" "FM_SPAWN_PARENT_TASK_GENERATION=$GEN" "the child spawn lost the parent generation" assert_contains "$env_line" "FM_SPAWN_CLOUD=azure" "the child spawn was not placed on the cloud lane" @@ -1514,9 +1526,15 @@ PY assert_contains "$env_line" "FM_SECONDMATE_LEG_SECONDS=" \ "the compartment's leg configuration leaked into the child spawn" # The brief bytes became the child's brief file, byte for byte. - assert_present "$HOME_DIR/data/$child/brief.md" "the child brief was never written" - [ "$(cat "$HOME_DIR/data/$child/brief.md")" = "ship the compartment child" ] \ - || fail "the child brief is not the requested bytes: $(cat "$HOME_DIR/data/$child/brief.md")" + assert_present "$LANDING/data/$child/brief.md" "the child brief was never written" + [ "$(cat "$LANDING/data/$child/brief.md")" = "ship the compartment child" ] \ + || fail "the child brief is not the requested bytes: $(cat "$LANDING/data/$child/brief.md")" + # The brief and the backlog row land in the TASK home, which is the data/ + # fm-spawn reads; a copy under the primary would leave fm-spawn's own + # backlog-row gate refusing every child in a real home. + assert_absent "$HOME_DIR/data/$child" "the child brief landed under the primary, where fm-spawn does not look" + assert_present "$LANDING/data/backlog.md" "the child backlog row was never filed in the task home" + assert_absent "$HOME_DIR/data/backlog.md" "the child backlog row was filed under the primary" assert_present "$CHILDREQ/.accepted-$self_digest.json" "no durable acceptance record" [ "$(spawn_invocations)" = 2 ] || fail "expected exactly one spawn (argv + env lines), saw $(spawn_invocations) lines" pass "a real runner-emitted child request spawns once, as the secondmate, with the exact parent pair" @@ -1845,8 +1863,10 @@ PY # Terminal AND failed: the controller says complete, the child's own result # carries the non-zero exit that makes it a failure. write_controller_with_child "$child" complete 0 + # The child's own lane writes its result under the TASK home's state/, + # because the split aimed this task's state/ there. printf '{"schema":"fm.worker-execution-result/v1","exit_code":3,"timed_out":false}\n' \ - > "$HOME_DIR/state/$child.worker-result.json" + > "$LANDING/state/$child.worker-result.json" run_relay > "$WORLD/relay-3.log" 2>&1 || fail "the terminal relay pass failed" assert_contains "$(inbox_messages)" "CHILD $child is failed (exit code 3)" \ "the child's terminal status was not mirrored with its failure classification" @@ -2020,28 +2040,28 @@ test_child_request_through_the_real_fm_spawn_with_crew_dispatch() { # config/crew-dispatch.json (the primary propagates it into secondmate # homes, so most real homes have it). # - # It clears the two gates this PR closed - the harness consultation - # backstop (the relay names --harness explicitly) and the backlog-row - # refusal (the relay files the row) - and then hits a THIRD gate that no - # change in this PR's owned files can close, which this unit PINS rather - # than hides: + # THE WHOLE LANE, END TO END, through every real component: a real + # compartment spawned by the real fm-spawn, a real child request, the real + # relay, the real fm-spawn again, and the real controller. It used to stop + # at a third gate that no change in the relay's own files could close - + # owner_kind was derived from the spawn's own home marker, so a request + # minted under the primary's FM_HOME was primary-owned and verify_request + # refused the parent pair on it. The task-home split closed that gate: # - # verify_request requires a compartment child to be a SECONDMATE-owned - # author request, so the spawn must run with FM_HOME=; enforce_child_bounds requires the parent compartment's queue - # entry in the SAME controller document, which is the PRIMARY's because - # the primary spawned the compartment; and verify_state binds that - # document to the home that created it (home_binding = sha256 of the - # resolved home path). All three cannot hold at once through this lane, - # so the request refuses with "lifecycle state home_binding binding is - # not exact" - the compartment child lane cannot admit anything today. + # FM_HOME stays the PRIMARY's, so the request is admitted into the ONE + # money document and the home fence on it is never touched; + # FM_SPAWN_TASK_HOME is the COMPARTMENT's own home, so owner_kind derives + # secondmate from THAT marker, the bindings are minted from the child's + # meta under that home, and --task-home carries the directory to the + # controller, which authorizes it against the marker plus the primary's + # registry and then applies the four child bounds. # - # What this unit proves is therefore the whole point of B.5 step 2 under a - # genuinely broken dependency: the compartment still gets a durable, - # delivered, named answer, and NO phantom child is recorded. When the - # controller contract is fixed (a sibling PR's files), this unit goes red - # and must be rewritten to assert admission. - local out rc parent parent_generation assignment controller relay_home + # So this unit now asserts ADMISSION, and asserts it from the money + # document rather than from an exit code: the child's own queue entry, + # owner_kind=secondmate, this compartment's parent pair, home_binding + # naming the COMPARTMENT's home while the document's own home_binding names + # the PRIMARY, and the parent's children_total incremented. + local out rc parent parent_generation assignment controller relay_home child_task setup_spawn_world real-spawn mast out=$(run_gate_spawn mast \ FM_SPAWN_CLOUD=azure FM_SPAWN_SECONDMATE_CLOUD=1 \ @@ -2090,8 +2110,11 @@ SH chmod +x "$SP_DIR/child-fake/treehouse" mkdir -p "$SP_SUB/treehouse-pools" "$SP_HOME/treehouse-pools" chmod 755 "$SP_SUB" "$SP_SUB/treehouse-pools" "$SP_HOME/treehouse-pools" - git -C "$SP_HOME/projects/alpha" worktree add --quiet --detach "$SP_DIR/child-worktree" \ - || fail "could not stage a child worktree of the home's project" + # The child's project resolves out of the COMPARTMENT's home, so its leased + # worktree must be a worktree of THAT clone; fm-spawn refuses a lease from + # an unrelated repository. + git -C "$SP_SUB/projects/alpha" worktree add --quiet --detach "$SP_DIR/child-worktree" \ + || fail "could not stage a child worktree of the compartment home's project" # One valid child request landed for this compartment. mkdir -p "$SP_DIR/childreq" "$SP_DIR/inbox" python3 - "$SP_DIR/childreq" "$parent" "$parent_generation" "$assignment" <<'PY' @@ -2154,29 +2177,63 @@ PY # Gate 2, closed by this PR: the missing backlog row. assert_not_contains "$delivered" "has no In flight or Queued row" \ "the real fm-spawn refused the relay's argv at the backlog-row gate: $delivered" - # Gate 3, the real blocker: owner_kind is derived, not assertable, so the - # parent pair arrives on a primary-owned request and verify_request refuses. - assert_contains "$delivered" "parent_task is owned by secondmate-owned author requests only" \ - "the compartment child lane got past the owner-kind gate; if an assertable owner kind and a task-home parameter landed, rewrite this unit to assert admission: $delivered" - assert_contains "$delivered" "FIRSTMATE REFUSED your request" \ - "the real-spawn refusal was not delivered into the compartment inbox: $delivered" - [ -n "$(first_matching "$SP_DIR/childreq" '.refused-*.json')" ] \ - || fail "the real-spawn refusal left no durable record" - # And NO phantom child: nothing was recorded as accepted, and the one - # controller holds no entry under this compartment's parent pair. - [ -z "$(first_matching "$SP_DIR/childreq" '.accepted-*.json')" ] \ - || fail "a child that was never admitted was recorded as accepted" - python3 - "$controller" "$parent" "$parent_generation" <<'PY' || fail "a refused child still left a queue entry behind" -import json, sys -controller, parent, generation = sys.argv[1:] + # Gate 3, closed by the task-home split: the request is SECONDMATE-owned, + # so the parent pair is accepted and the child bounds apply to it. + assert_not_contains "$delivered" "parent_task is owned by secondmate-owned author requests only" \ + "the compartment child lane still mints a primary-owned request: $delivered" + assert_not_contains "$delivered" "FIRSTMATE REFUSED your request" \ + "the compartment child lane refused instead of admitting: $delivered" + assert_contains "$delivered" "FIRSTMATE ACCEPTED your" \ + "the admitted child was never acknowledged into the compartment inbox: $delivered" + [ -z "$(first_matching "$SP_DIR/childreq" '.refused-*.json')" ] \ + || fail "an admitted child also left a durable refusal record" + [ -n "$(first_matching "$SP_DIR/childreq" '.accepted-*.json')" ] \ + || fail "an admitted child left no durable acceptance record" + # THE MONEY DOCUMENT IS THE PROOF, not the exit code: exactly one child + # entry under this compartment's parent pair, secondmate-owned, bound to + # the COMPARTMENT's home inside the PRIMARY's document, with the parent's + # lifetime child count incremented and no second document anywhere. + assert_absent "$SP_SUB/state/azure-workers/controller.json" \ + "the compartment child lane created a SECOND controller document under the compartment home" + child_task=$(python3 - "$controller" "$parent" "$parent_generation" "$SP_SUB" "$SP_HOME" <<'PY' +import hashlib +import json +import sys +from pathlib import Path + +controller, parent, generation, sub, primary = sys.argv[1:] state = json.load(open(controller)) children = [ item for item in state["queue"].values() if item.get("parent_task") == parent and item.get("parent_task_generation") == generation ] -assert children == [], children +assert len(children) == 1, children +child = children[0] +assert child["owner_kind"] == "secondmate", child +assert child["role"] == "author", child +sub_binding = hashlib.sha256(str(Path(sub).resolve()).encode()).hexdigest() +primary_binding = hashlib.sha256(str(Path(primary).resolve()).encode()).hexdigest() +assert child["home_binding"] == sub_binding, (child["home_binding"], sub_binding) +assert child.get("task_home") == str(Path(sub).resolve()), child +assert state["home_binding"] == primary_binding, (state["home_binding"], primary_binding) +parent_item = state["queue"]["{}@{}".format(parent, generation)] +worker = state["workers"][str(parent_item["slot"])] +assert int(worker.get("children_total", 0)) == 1, worker +print(child["task"]) PY - pass "through the REAL fm-spawn the relay clears the harness and backlog gates, and the home-bound controller refusal comes home as a durable delivered answer with no phantom child" +) || fail "the admitted child is not a bounded, compartment-bound entry in the primary's one document" + [ -n "$child_task" ] || fail "the admitted child's task id could not be read back" + # THE TASK HOME IS CONSUMED ONCE, NEVER PERSISTED. .cloud-env is + # re-sourced by every LATER execute and release for this id, so a name that + # landed there would permanently pair a foreign home with all of them + # instead of with this one request - the durable trap spawn_environment + # names. The durable record of the split is the queue item's own task_home + # field, asserted above, which the release lane reads back. + assert_present "$SP_SUB/state/$child_task.cloud-env" \ + "the admitted child persisted no cloud environment at all" + assert_no_grep 'FM_SPAWN_TASK_HOME' "$SP_SUB/state/$child_task.cloud-env" \ + "FM_SPAWN_TASK_HOME was persisted into the child's durable cloud environment, where it becomes a durable trap" + pass "through the REAL fm-spawn the relay admits a compartment child into the primary's ONE money document, secondmate-owned and bound to the compartment's home, under the child bounds, with the task home consumed once and never persisted" } test_crewmate_monitor_reclaims_a_stale_dispatch_marker() {