mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Close residual gaps: CEO escalation from blocked, ref repair, reset-script state
Three independent residual hardenings surfaced by the smoke run:
- escalate_to_ceo is now reachable from a blocked task (source widened to
{awaiting_pm_review, blocked} with a matching status-transition row), so a
main_pm or board agent has a clean verb to surface a task it cannot resolve
to the CEO — who can then approve, reject, or cancel it. Regenerated the
lifecycle artifacts (status-transitions doc + panel JSON) to match.
- WorkspaceService now prunes broken loose refs (.bak debris and any ref whose
contents are neither an object id nor a symref) before the refresh fetch, so
a ref left corrupt by an interrupted recovery no longer produces per-operation
"broken ref" warnings or wedges ref enumeration. Best-effort, file-reads-only.
- The reset script's full-reset Claude-state clear now defaults to the
replay-state subdirs (projects/, todos/) of the mounted Claude home and
refuses to clear the home root, so a full reset actually clears conversation
replay state by default without wiping the host's stored credentials.
This commit is contained in:
@@ -1025,7 +1025,7 @@ async def test_escalate_to_ceo_matches_spec(role: str, status: str) -> None:
|
||||
|
||||
- role in {main_pm, product_owner, head_marketing},
|
||||
- composed ``escalate_to_ceo`` action's source_status
|
||||
(AWAITING_PM_REVIEW only).
|
||||
(AWAITING_PM_REVIEW or BLOCKED).
|
||||
|
||||
The verb body keeps the journal:decision preflight (the spec doesn't
|
||||
model journal side effects); satisfied here so the spec gate is the
|
||||
|
||||
@@ -242,6 +242,8 @@ def test_status_transitions_includes_ceo_paths() -> None:
|
||||
) in sources
|
||||
assert (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.COMPLETED) in sources
|
||||
assert (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.NEEDS_REVISION) in sources
|
||||
# A blocked task the PM cannot resolve can also be surfaced to the CEO.
|
||||
assert (spec.Status.BLOCKED, spec.Status.AWAITING_CEO_APPROVAL) in sources
|
||||
|
||||
|
||||
def test_status_transitions_includes_block_pause_paths() -> None:
|
||||
@@ -307,20 +309,35 @@ def test_status_transitions_role_constraints_match_canon() -> None:
|
||||
assert by_pair[
|
||||
(spec.Status.AWAITING_PM_REVIEW, spec.Status.COMPLETED, "complete")
|
||||
] == frozenset({spec.Role.CELL_PM, spec.Role.MAIN_PM})
|
||||
# escalate_to_ceo: main_pm + product_owner + head_marketing
|
||||
assert by_pair[
|
||||
(
|
||||
spec.Status.AWAITING_PM_REVIEW,
|
||||
spec.Status.AWAITING_CEO_APPROVAL,
|
||||
"escalate_to_ceo",
|
||||
)
|
||||
] == frozenset(
|
||||
# escalate_to_ceo: main_pm + product_owner + head_marketing — from a
|
||||
# completed review and from a blocked task, same role gate.
|
||||
escalate_roles = frozenset(
|
||||
{
|
||||
spec.Role.MAIN_PM,
|
||||
spec.Role.PRODUCT_OWNER,
|
||||
spec.Role.HEAD_MARKETING,
|
||||
}
|
||||
)
|
||||
assert (
|
||||
by_pair[
|
||||
(
|
||||
spec.Status.AWAITING_PM_REVIEW,
|
||||
spec.Status.AWAITING_CEO_APPROVAL,
|
||||
"escalate_to_ceo",
|
||||
)
|
||||
]
|
||||
== escalate_roles
|
||||
)
|
||||
assert (
|
||||
by_pair[
|
||||
(
|
||||
spec.Status.BLOCKED,
|
||||
spec.Status.AWAITING_CEO_APPROVAL,
|
||||
"escalate_to_ceo",
|
||||
)
|
||||
]
|
||||
== escalate_roles
|
||||
)
|
||||
# CEO actions: CEO only
|
||||
assert by_pair[
|
||||
(spec.Status.AWAITING_CEO_APPROVAL, spec.Status.COMPLETED, "ceo_approve")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""`WorkspaceService._prune_broken_refs` drops debris loose refs before a fetch.
|
||||
|
||||
Interrupted hard-stop recovery can leave `.bak` ref debris and truncated/garbage
|
||||
loose-ref files under `.git/refs`. The prune reads files only (no git subprocess),
|
||||
so these tests just stage a `.git/refs` tree on disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from roboco.services.workspace import WorkspaceService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_VALID_SHA1 = "a" * 40
|
||||
_VALID_SHA256 = "b" * 64
|
||||
|
||||
|
||||
def _make_heads(workspace: Path) -> Path:
|
||||
heads = workspace / ".git" / "refs" / "heads"
|
||||
heads.mkdir(parents=True)
|
||||
return heads
|
||||
|
||||
|
||||
def test_keeps_valid_sha1_ref(tmp_path: Path) -> None:
|
||||
good = _make_heads(tmp_path) / "main"
|
||||
good.write_text(_VALID_SHA1 + "\n", encoding="utf-8")
|
||||
WorkspaceService._prune_broken_refs(tmp_path)
|
||||
assert good.exists()
|
||||
|
||||
|
||||
def test_keeps_valid_sha256_ref(tmp_path: Path) -> None:
|
||||
good = _make_heads(tmp_path) / "main"
|
||||
good.write_text(_VALID_SHA256 + "\n", encoding="utf-8")
|
||||
WorkspaceService._prune_broken_refs(tmp_path)
|
||||
assert good.exists()
|
||||
|
||||
|
||||
def test_keeps_symref(tmp_path: Path) -> None:
|
||||
sym = _make_heads(tmp_path) / "current"
|
||||
sym.write_text("ref: refs/heads/main\n", encoding="utf-8")
|
||||
WorkspaceService._prune_broken_refs(tmp_path)
|
||||
assert sym.exists()
|
||||
|
||||
|
||||
def test_removes_bak_debris_even_with_valid_content(tmp_path: Path) -> None:
|
||||
bak = _make_heads(tmp_path) / "feature.bak"
|
||||
bak.write_text(_VALID_SHA1, encoding="utf-8")
|
||||
WorkspaceService._prune_broken_refs(tmp_path)
|
||||
assert not bak.exists()
|
||||
|
||||
|
||||
def test_removes_garbage_ref(tmp_path: Path) -> None:
|
||||
junk = _make_heads(tmp_path) / "broken"
|
||||
junk.write_text("not-a-sha-or-symref garbage", encoding="utf-8")
|
||||
WorkspaceService._prune_broken_refs(tmp_path)
|
||||
assert not junk.exists()
|
||||
|
||||
|
||||
def test_no_git_refs_dir_is_noop(tmp_path: Path) -> None:
|
||||
# No .git/refs present — must not raise.
|
||||
WorkspaceService._prune_broken_refs(tmp_path)
|
||||
Reference in New Issue
Block a user