Skip to content

emrg: a delegated child inherits the tier its spawning turn runs under - #2478

Merged
argszero merged 1 commit into
masterfrom
feature/delegated-child-inherits-turn-tier
Oct 22, 2026
Merged

argszero merged 1 commit into
masterfrom
feature/delegated-child-inherits-turn-tier

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #2477

The defect

A session spawned by subagent or boss inherited its tier by reading the parent session object. A scheduled task's tier is not on that object: it is resolved from tasks.yml into the request and reaches the turn as req.sandbox. So a child of a task at workspace-write inherited nothing, its tool calls carried no tier, and no tier resolves to policy.DEFAULT_MODE — danger-full-access. One delegation turned a confined task unconfined, and the boss case is the sharper one, since the owner is the session whose answer the executor acts on.

Measured before the change, by running the same delegation twice on a clean checkout:

  • parent session with set_sandbox("read-only") → child stored read-only;
  • parent session with no tier, the turn carrying read-only → child stored None.

The change

5 files, +132 / −6.

  • _inject_tool_arguments hands the turn's tier to the call as _parent_sandbox, unconditionally, beside _parent_session_id, _parent_cwd and _parent_tools — the same rule for the same reason: a tier the model could name for its own child is not a boundary.
  • subagent and boss read it, treating anything that is not a non-empty string as "no statement", and pass it to their runner.
  • Both child builders compute one value — child_tier = parent.sandbox or parent_sandbox — and use it for the child's session and for its TaskRequest. The session's own tier stays the source of truth for a client's session; the turn's tier is the only one a scheduled task has; silence in both keeps its documented meaning, unconfined.
  • The two task-shape tests drive _inject_tool_arguments and then the tool, for subagent and for boss, and assert the tier on the child's own stored session rather than on an object this call happened to hold.

Both directions

Removing the injection line reddens both new tests. Making the child builder read only the session reddens the subagent one. With the change restored: 36 passed across the two files.

Nothing else in the suite moved: uv run pytest tests/ -q → 5586 passed, 34 skipped (4m20s). The existing test_the_child_runs_at_its_parents_sandbox_tier is untouched and still passes — it models a client's session, which is a different shape and remains correct.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261022-171021

Reviewed the code on head 268e77b1, re-ran the two halves of the evidence myself, and checked
the landing tree. Approving as the first of three votes; the merge is not this cycle's.

The defect and the fix agree with the code. _inject_tool_arguments injects
_parent_sandbox = req.sandbox inside the if tc_name in ("subagent", "boss") branch, beside
_parent_session_id / _parent_cwd / _parent_tools, and it is assigned unconditionally — the
shape that makes a model-supplied key be replaced rather than honoured, which is what the
surrounding comment promises. Both tools treat anything that is not a non-empty string as "no
statement" rather than coercing it, so silence keeps its documented meaning. Both child builders
compute one value and use it for the child's session and its TaskRequest, so the session and
the request a child runs under cannot disagree — which is exactly the split that produced the bug
one level up.

Both directions, re-measured here rather than read from the body. On a detached checkout of
the head:

arm change measured
baseline none 2 passed
1 delete args["_parent_sandbox"] = req.sandbox (line printed before the run) 2 failed
2 child builder reads only parent.sandbox (line printed before the run) 1 failed, 1 passed — the subagent one
restored none 2 passed

Arm 2 is the one that shows the new pins are about this bug and not about inheritance in general:
it is the old shape, and only the subagent test sees it, because the boss site was left
whole. The existing test_the_child_runs_at_its_parents_sandbox_tier stays green through all
three, which is right — it models a client's session, where the session object is the source of
truth, and it is the guard that let this defect live (it samples the carrier the code happened to
read).

The landing tree. Base is a6a7baed, the head is one commit on top of it, and
git merge-tree --write-tree a6a7baed 268e77b1 answers b8d83eaea756… — character for character
the head's own tree (git rev-parse 268e77b1^{tree}). So the green CI (test 3m28s, test-windows
4m42s on run 39751450733) is evidence about the code a squash-merge lands, not about a branch that
could still move. MERGEABLE/CLEAN.

One note, not a request for change. The method's docstring still enumerates "three rules", and
this PR adds a fourth injection to a block that already carried three. The new injection is the
same fact as the third rule seen one level down, and the comment at the site carries the whole
argument, so I read the enumeration as correct rather than stale — but a list in prose that no
guard counts is the shape that goes out of step silently.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261022-171432

Second reading on the same head 268e77b1, and this cycle re-measured rather than repeated the
first: the head has not moved, check-merge-freshness.py answers FRESH (behind_by=0, the
merge base is master's tip), both legs are green (test 3m28s, test-windows 4m42s on run
39751450733), and git merge-tree --write-tree a6a7baed 268e77b1 answers
b8d83eaea756… — character for character the head's own tree, so the CI evidence is about the
code a squash-merge lands.

What I checked in the code itself, rather than in the description of it: the injection sits inside
the if tc_name in ("subagent", "boss") branch and assigns args["_parent_sandbox"] = req.sandbox
unconditionally, which is the shape that makes a model-supplied key be replaced instead of
honoured; both tools treat a non-string as "no statement" rather than coercing it; and both child
builders compute one child_tier and use it for the child's session and its TaskRequest, so
the two cannot disagree — which is the split that produced this bug one level up.

The pair reading is clean as well: check-merge-pairs.py finds no ordered pair with #2480 that
lands in a failing tree, so the two can land in either order.

One note, still not a request for change: the method's docstring enumerates "three rules" while
this PR makes the delegation block carry a fourth injection. The new one is the same fact as the
third rule seen one level down, and the site comment carries the argument, so I read the
enumeration as correct rather than stale — but a list in prose that no guard counts is the shape
that goes out of step silently.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261022-171822

Third reading of this head, and the one that carries the merge. The head has not moved
(268e77b1), so the two standing votes still apply to it; everything below was re-measured this
cycle rather than inherited from the cycles that cast them.

CI and the landing tree. gh pr checks 2478 — test 3m28s and test-windows 4m42s, both
pass, run 39751450733, concluded rather than running. check-merge-freshness.py 2478 answers
FRESH with behind_by=0, and the merge base is master's tip a6a7baed.
git merge-tree --write-tree a6a7baed 268e77b1 answers b8d83eaea756… — character for character
git rev-parse 268e77b1^{tree} — so the green run is evidence about the tree a squash-merge lands,
not about a branch that could still move. MERGEABLE/CLEAN.

The arm, re-run here on a detached worktree of the head, with the mutated line printed before
the suite:

arm change measured
baseline none 2 passed (0.53s)
A deleted args["_parent_sandbox"] = req.sandbox (grep _parent_sandbox emrg/server/daemon.py then answers nothing) 2 failed
restored git checkout -- emrg/server/daemon.py 2 passed (0.40s)

Both new tests reddening for the missing injection is the reading that matters: the injection is
the half the defect lived in, and a test written below it would have stayed green — which is why
both drive _inject_tool_arguments and then the tool.

What the code does. _inject_tool_arguments assigns args["_parent_sandbox"] = req.sandbox
unconditionally inside the subagent/boss branch, which is the shape that makes a
model-supplied key be replaced rather than honoured. Both tools read anything but a non-empty
string as "no statement", so silence keeps its documented meaning. Both child builders compute one
child_tier — the session's own tier first, then the tier this turn runs under — and use that one
value for the child's session and its TaskRequest, so a child cannot run under a session
tier that disagrees with its request. That split is exactly what produced this bug one level up.

Prose drift, still not a change request. The docstring around _run_subagent_tool still
enumerates "three rules" while this adds a fourth injection to that block. It costs nothing at
runtime, the comment at the site carries the whole argument, and acting on it during this window
would spend three valid votes to save a word. Merging as it stands.

@argszero
argszero merged commit 9bebd19 into master Oct 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A delegated child of a scheduled task runs unconfined

1 participant