fix(delegate): tell the parent when a worktree was preserved un-inspected
The preserved worktree is invisible to the only consumer that can act on it. Completes the #88113 fix. That change correctly stops the destructive prune when a git probe fails, but still returns commits=0 / dirty=False -- values that were never measured. Those are the defaults the prune used to delete on, so the failure payload is byte-identical to "inspected fine, child left nothing": inspection FAILED, uncommitted work kept -> {commits: 0, dirty: False, pruned: False} inspected OK, child produced nothing -> {commits: 0, dirty: False, pruned: False} The only failure signal was a logger.warning, and the sole consumer of this payload is the parent agent reading the serialized delegate_task entry -- it cannot read logs (no in-repo code reads the key back). So the parent's rational reading of the failure case is "the child produced no work", which is the exact wrong conclusion: a worktree possibly full of uncommitted work is preserved and then never looked at. The data survives but nobody is told to recover it. Changes: - subagent_worktree: one _unproven() helper stamps inspection_failed + a note naming the worktree/branch, warns, and returns the payload. Both unproven exits route through it, so they cannot drift apart again. - subagent_worktree: the pre-existing exception path (timeout, OSError, a non-numeric rev-list stdout) produced the same unproven payload but logged at DEBUG -- effectively silent. It now takes the same flagged path as a non-zero exit; identical outcomes get identical reporting. - delegate_tool: the caller's finalize-raised fallback assigned the creation-side metadata dict (path/branch/repo_root/base_commit) -- a disjoint schema missing commits/dirty/pruned. It now emits the same flagged shape, and logs at WARNING. - Docs + docstring + module contract now state that pruning requires affirmative proof, so a future cleanup doesn't "fix" the preserved worktree by restoring the unconditional prune and reintroducing this P1. Purely additive: the happy-path payload shape is unchanged, so no existing reader can break. Validation: - 18/18 tests/tools/test_subagent_worktree.py; 127 passed across the delegation suites (test_delegate, batch_validation, control_actions, timeout_diagnostic). - 3 new guards mutation-checked: neutering the flag fails all three; reverting the production file to pre-fix main fails all three. Restores checksum-verified. - E2E on real git: inspection-failure now returns inspection_failed=true with work intact on disk; proven-clean still prunes (pruned=true).
This commit is contained in:
@@ -163,6 +163,65 @@ class SubagentWorktreeTests(unittest.TestCase):
|
||||
self.assertTrue((wt / "UNCOMMITTED-WORK.txt").exists())
|
||||
branches = _git(["branch", "--list", info["branch"]], repo).stdout
|
||||
self.assertNotEqual(branches.strip(), "")
|
||||
# The parent agent only ever sees this payload (it cannot read logs),
|
||||
# so the uncertainty must travel in the dict — otherwise "0 commits,
|
||||
# clean" reads as "the child produced nothing" and the work we just
|
||||
# preserved never gets looked at.
|
||||
self.assertTrue(payload["inspection_failed"])
|
||||
self.assertIn("UNKNOWN", payload["note"])
|
||||
self.assertIn(info["path"], payload["note"])
|
||||
|
||||
def test_finalize_flags_unproven_state_distinguishably(self):
|
||||
"""#88113 follow-up: a failed inspection must not look like "no work".
|
||||
|
||||
Without an explicit flag, "inspection failed, uncommitted work
|
||||
preserved" and "inspected fine, child left nothing" serialize to the
|
||||
byte-identical dict {commits: 0, dirty: False, pruned: False} — so the
|
||||
parent agent's rational reading of the failure case is the exact wrong
|
||||
conclusion.
|
||||
"""
|
||||
repo = _make_repo(self.tmp)
|
||||
|
||||
# Case 1: inspection SUCCEEDED, tree genuinely clean, prune disabled.
|
||||
ok_info = sw.create_subagent_worktree(str(repo), "proven-clean")
|
||||
assert ok_info is not None
|
||||
ok_payload = sw.finalize_subagent_worktree(ok_info, prune=False)
|
||||
|
||||
# Case 2: inspection FAILED with real uncommitted work on disk.
|
||||
bad_info = sw.create_subagent_worktree(str(repo), "unproven")
|
||||
assert bad_info is not None
|
||||
bad_wt = Path(bad_info["path"])
|
||||
(bad_wt / "WIP.txt").write_text("real work\n", encoding="utf-8")
|
||||
git_dir = Path(_git(["rev-parse", "--git-dir"], bad_wt).stdout.strip())
|
||||
if not git_dir.is_absolute():
|
||||
git_dir = (bad_wt / git_dir).resolve()
|
||||
(git_dir / "index").write_bytes(b"not-a-valid-git-index\n")
|
||||
bad_payload = sw.finalize_subagent_worktree(bad_info)
|
||||
|
||||
# The three state fields are identical — that is exactly the ambiguity.
|
||||
for key in ("commits", "dirty", "pruned"):
|
||||
self.assertEqual(ok_payload[key], bad_payload[key])
|
||||
# Only the flag separates them.
|
||||
self.assertNotIn("inspection_failed", ok_payload)
|
||||
self.assertNotIn("note", ok_payload)
|
||||
self.assertTrue(bad_payload["inspection_failed"])
|
||||
|
||||
def test_finalize_flags_unproven_state_when_inspection_raises(self):
|
||||
"""A raising probe is the same unknown state as a non-zero exit."""
|
||||
repo = _make_repo(self.tmp)
|
||||
info = sw.create_subagent_worktree(str(repo), "raises")
|
||||
assert info is not None
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise subprocess.TimeoutExpired(cmd="git", timeout=30)
|
||||
|
||||
with mock.patch.object(sw, "_run_git", side_effect=_boom):
|
||||
payload = sw.finalize_subagent_worktree(info)
|
||||
|
||||
self.assertFalse(payload["pruned"])
|
||||
self.assertTrue(payload["inspection_failed"])
|
||||
self.assertIn("UNKNOWN", payload["note"])
|
||||
self.assertTrue(os.path.isdir(info["path"]))
|
||||
|
||||
def test_finalize_missing_path_reports_pruned(self):
|
||||
payload = sw.finalize_subagent_worktree(
|
||||
|
||||
+17
-2
@@ -2508,8 +2508,23 @@ def _run_single_child(
|
||||
subagent_worktree.finalize_subagent_worktree(_worktree_info)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("worktree finalize failed: %s", e)
|
||||
entry_dict["worktree"] = dict(_worktree_info)
|
||||
# finalize is written hard not to raise, but if it ever does the
|
||||
# state is unknown — emit the SAME schema the parent expects,
|
||||
# flagged, instead of leaking the creation-side metadata shape.
|
||||
logger.warning("worktree finalize failed: %s", e)
|
||||
entry_dict["worktree"] = {
|
||||
"path": _worktree_info.get("path", ""),
|
||||
"branch": _worktree_info.get("branch", ""),
|
||||
"commits": 0,
|
||||
"dirty": False,
|
||||
"pruned": False,
|
||||
"inspection_failed": True,
|
||||
"note": (
|
||||
"worktree finalize raised; state unknown — inspect "
|
||||
f"{_worktree_info.get('path', '')} manually before "
|
||||
"assuming no work."
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
_heartbeat_thread.start()
|
||||
|
||||
+41
-13
@@ -26,7 +26,9 @@ Contract (mirrors Muse Code's documented semantics):
|
||||
dirty state so the parent can review or merge each branch.
|
||||
- **Clean worktrees are pruned.** A worktree with no new commits and a
|
||||
clean tree is removed automatically after the child finishes; anything
|
||||
holding work is kept and reported.
|
||||
holding work is kept and reported. Pruning requires affirmative proof:
|
||||
if a git inspection probe fails the state is unknown, so the worktree is
|
||||
kept and the result entry is flagged ``inspection_failed`` (#88113).
|
||||
|
||||
Only the local terminal backend is supported: on docker/ssh/modal/etc. the
|
||||
worktree created on the host would not be visible inside the sandbox, so
|
||||
@@ -177,8 +179,15 @@ def finalize_subagent_worktree(
|
||||
|
||||
Returns a result-entry payload: path, branch, ``commits`` ahead of the
|
||||
base, ``dirty`` (uncommitted changes present), and ``pruned``. A worktree
|
||||
with zero commits and a clean tree is removed when *prune* is true;
|
||||
anything holding work is always kept for the parent to review or merge.
|
||||
with zero commits and a clean tree is removed when *prune* is true **and
|
||||
both git probes succeeded**; anything holding work is always kept for the
|
||||
parent to review or merge.
|
||||
|
||||
If ``git rev-list``/``git status`` exits non-zero (or the inspection
|
||||
raises), the tree state is unknown, so the worktree and branch are kept
|
||||
and the payload carries ``inspection_failed: True`` plus a ``note``.
|
||||
``commits``/``dirty`` are then defaults, NOT measurements — the parent
|
||||
must inspect the worktree instead of concluding the child did no work.
|
||||
"""
|
||||
path = info.get("path", "")
|
||||
branch = info.get("branch", "")
|
||||
@@ -196,6 +205,30 @@ def finalize_subagent_worktree(
|
||||
payload["pruned"] = True # nothing on disk to review
|
||||
return payload
|
||||
|
||||
def _unproven(reason: str) -> Dict[str, Any]:
|
||||
"""Flag the payload as un-inspected and keep the worktree (#88113).
|
||||
|
||||
A failed probe proves nothing about the tree, so ``commits``/``dirty``
|
||||
are still their defaults. The parent agent only ever sees this dict —
|
||||
it cannot read logs — so the uncertainty has to travel *in the
|
||||
payload*, or "0 commits, clean" reads as "the child produced nothing"
|
||||
and the work we just preserved is never looked at.
|
||||
"""
|
||||
payload["inspection_failed"] = True
|
||||
payload["note"] = (
|
||||
f"git inspection failed ({reason}): 'commits' and 'dirty' are "
|
||||
"UNKNOWN, not zero/clean. The worktree and branch were preserved "
|
||||
f"— inspect {path} (branch {branch}) before assuming no work."
|
||||
)
|
||||
logger.warning(
|
||||
"subagent worktree: git inspection failed (%s) — keeping %s "
|
||||
"(branch %s) for manual review",
|
||||
reason,
|
||||
path,
|
||||
branch,
|
||||
)
|
||||
return payload
|
||||
|
||||
inspection_ok = True
|
||||
try:
|
||||
if base_commit:
|
||||
@@ -212,9 +245,10 @@ def finalize_subagent_worktree(
|
||||
else:
|
||||
inspection_ok = False
|
||||
except Exception as exc:
|
||||
logger.debug("subagent worktree: finalize inspection failed: %s", exc)
|
||||
# Unknown state — keep the worktree rather than risk deleting work.
|
||||
return payload
|
||||
# Same unknown state as a non-zero exit (timeout, OSError, or a
|
||||
# non-numeric rev-list stdout) — keep the worktree rather than risk
|
||||
# deleting work, and tell the caller the numbers are unproven.
|
||||
return _unproven(f"inspection raised: {exc}")
|
||||
|
||||
if not inspection_ok:
|
||||
# Fail-safe (#88113): a non-zero git exit proves nothing about the
|
||||
@@ -223,13 +257,7 @@ def finalize_subagent_worktree(
|
||||
# child work. A destructive cleanup requires affirmative proof of
|
||||
# "zero commits + clean tree"; otherwise keep the worktree and
|
||||
# branch for manual inspection.
|
||||
logger.warning(
|
||||
"subagent worktree: git inspection failed (rev-list/status "
|
||||
"non-zero) — keeping %s (branch %s) for manual review",
|
||||
path,
|
||||
branch,
|
||||
)
|
||||
return payload
|
||||
return _unproven("rev-list/status non-zero")
|
||||
|
||||
if prune and payload["commits"] == 0 and not payload["dirty"]:
|
||||
try:
|
||||
|
||||
@@ -412,6 +412,10 @@ With isolation on:
|
||||
reviews or merges each branch (`git log <branch>`, `git merge <branch>`).
|
||||
- A worktree left with **no commits and a clean tree is pruned automatically**
|
||||
(`pruned: true`); anything holding work is kept.
|
||||
- Pruning requires proof. If a git inspection probe fails, the worktree and
|
||||
branch are kept and the entry carries `inspection_failed: true` plus a
|
||||
`note` — `commits`/`dirty` are then defaults, not measurements, so inspect
|
||||
the worktree rather than assuming the child produced nothing.
|
||||
|
||||
Scope: opt-in, git-only, and local-terminal-backend-only. In a non-git
|
||||
directory, on docker/ssh/modal backends, or if worktree creation fails, the
|
||||
|
||||
Reference in New Issue
Block a user