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:
@@ -285,6 +285,13 @@ _STATUS_TRANSITIONS: tuple[StatusTransition, ...] = (
|
||||
"escalate_to_ceo",
|
||||
frozenset({Role.MAIN_PM, Role.PRODUCT_OWNER, Role.HEAD_MARKETING}),
|
||||
),
|
||||
# A blocked task the PM cannot resolve can be surfaced to the CEO directly.
|
||||
StatusTransition(
|
||||
Status.BLOCKED,
|
||||
Status.AWAITING_CEO_APPROVAL,
|
||||
"escalate_to_ceo",
|
||||
frozenset({Role.MAIN_PM, Role.PRODUCT_OWNER, Role.HEAD_MARKETING}),
|
||||
),
|
||||
# CEO approve / reject
|
||||
StatusTransition(
|
||||
Status.AWAITING_CEO_APPROVAL,
|
||||
@@ -510,7 +517,10 @@ _ATOMIC_ACTIONS: dict[str, ActionSpec] = {
|
||||
Role.HEAD_MARKETING,
|
||||
}
|
||||
),
|
||||
source_statuses=frozenset({Status.AWAITING_PM_REVIEW}),
|
||||
# Reachable from a completed review (the normal sign-off escalation) AND
|
||||
# from a blocked task the PM cannot resolve — so a wedged task has a
|
||||
# clean verb to a human decision instead of only the admin override.
|
||||
source_statuses=frozenset({Status.AWAITING_PM_REVIEW, Status.BLOCKED}),
|
||||
target_status=Status.AWAITING_CEO_APPROVAL,
|
||||
allowed_task_types=None,
|
||||
preconditions=(),
|
||||
|
||||
@@ -40,6 +40,11 @@ from roboco.models.base import Team
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# A healthy loose ref file holds either an object id (sha1 = 40 hex, sha256 = 64
|
||||
# hex) or a symbolic ref ("ref: refs/..."). Anything else is debris — used to
|
||||
# detect broken loose refs left by interrupted recovery before a fetch.
|
||||
_REF_OBJECT_ID_RE = re.compile(r"\A[0-9a-f]{40}\Z|\A[0-9a-f]{64}\Z")
|
||||
|
||||
# Agent container runs the `agent` user created in agent-base.Dockerfile.
|
||||
# Debian's `useradd -m` defaults to uid 1000 when that uid is free.
|
||||
# Overridable via env so operators can customize if they rebuild agent-base
|
||||
@@ -416,6 +421,44 @@ class WorkspaceService:
|
||||
and (git_dir / "objects").exists()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _prune_broken_refs(workspace: Path) -> None:
|
||||
"""Drop debris loose refs before a fetch. Best-effort; never raises.
|
||||
|
||||
Interrupted hard-stop recovery can leave ``.bak`` ref debris and
|
||||
truncated/garbage loose-ref files under ``.git/refs``. Git tolerates
|
||||
them but emits a "ignoring broken ref" warning on every ref-walking
|
||||
operation (fetch included), which pollutes logs and can wedge ref
|
||||
enumeration. Remove ``.bak`` debris and any loose ref whose contents are
|
||||
neither an object id nor a symref. Reads files only — no per-ref
|
||||
subprocess — so it stays cheap even on a many-branch monorepo clone.
|
||||
"""
|
||||
refs_dir = workspace / ".git" / "refs"
|
||||
if not refs_dir.is_dir():
|
||||
return
|
||||
try:
|
||||
for ref_file in refs_dir.rglob("*"):
|
||||
if not ref_file.is_file():
|
||||
continue
|
||||
if ref_file.suffix == ".bak":
|
||||
ref_file.unlink(missing_ok=True)
|
||||
continue
|
||||
content = ref_file.read_text(encoding="utf-8", errors="replace").strip()
|
||||
if not (
|
||||
_REF_OBJECT_ID_RE.match(content) or content.startswith("ref: ")
|
||||
):
|
||||
logger.debug(
|
||||
"ensure_workspace: pruning broken loose ref",
|
||||
ref=str(ref_file),
|
||||
)
|
||||
ref_file.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"ensure_workspace: broken-ref prune failed",
|
||||
workspace=str(workspace),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _fetch_origin_best_effort(workspace: Path, project_slug: str) -> None:
|
||||
"""Refresh `origin`'s refs into a healthy clone. Never raises.
|
||||
@@ -603,6 +646,10 @@ class WorkspaceService:
|
||||
# the first call always runs the fetch regardless of clock value.
|
||||
last_fetch = self._fetch_cache.get(str(workspace), -math.inf)
|
||||
if force or (now - last_fetch) >= _FETCH_CACHE_TTL_SECONDS:
|
||||
# Repair broken-ref debris first so the fetch (and the
|
||||
# agent's later `git diff/log origin/...`) doesn't trip on a
|
||||
# ref left corrupt by an interrupted recovery.
|
||||
await asyncio.to_thread(self._prune_broken_refs, workspace)
|
||||
await self._fetch_origin_best_effort(workspace, project_slug)
|
||||
self._fetch_cache[str(workspace)] = _monotonic()
|
||||
# Re-chown so the agent user can still write into .git
|
||||
|
||||
Reference in New Issue
Block a user