mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Chore: 141 Gaps fill-in (#283)
* Updated uv.lock
* Bunch of fixes we need to verify first..
* feat(megatask): per-cell project map root-subtasks (multi-project, multi-cell)
A MegaTask root-subtask can now target an ad-hoc per-cell project map — a
third targeting shape that mirrors the existing product fan-out root. In
RoboCo a project is per-cell (ProjectTable.assigned_cell); a monorepo is N
per-cell projects sharing one git_url. So 'multi-cell' IS 'multi-project',
and a task may mix per-cell projects across products or include OSS-library
projects not in any product.
Storage: migration 052 adds task_cell_projects (mirrors product_projects;
unique per (task, team)). TaskTable gains a cascade-delete cell_projects
relationship; TaskCreateRequest / TaskCreate / Task response carry the map.
Policy: batch.is_branchless_coordination + is_valid_batch_shape gain a
has_cell_projects param — a root-subtask targets exactly one of project /
product / cell-map; the umbrella still targets none. TaskService passes
has_cell_projects at every predicate call site and persists the rows in
create(). _ensure_branch_for_task cuts feature/main_pm/{root} per distinct
project in the map (via _distinct_projects_for_task); _require_target_or_umbrella
and _validate_batch_membership accept the map shape.
Fan-out: every distinct_project_ids site (task.py branch creation, routes
_project_for_complete + _resolve_project_for_merge, orchestrator
_ambient_projects_for_task, pr_review._project_slug_for, git._project_for_task)
generalizes to first-distinct-project-of-map-or-product. Choreographer
_resolve_subtask_project resolves a delegated subtask's cell from the parent's
cell map. The product-scoped _slugs_for_product intake helper is unchanged.
Intake: prompter._draft_cell_map extracts the per-cell map from the_work[].
_validate_batch_scope counts distinct projects across all drafts' cells
(>=2 min stays; one 2-cell draft satisfies it). create_task_from_draft
persists cell_projects for >=2-cell drafts (project_id/product_id None),
collapses a 1-cell map to the single-project shape, and leaves single-cell
top-level project_id drafts unchanged. _resolve_owning_team routes a
multi-cell map to Main PM (coordination root, like a product root — a cell
PM can't delegate cross-cell). propose_draft/propose_batch tool descriptions
declare the per-cell project_id (both Claude SDK + grok runtimes).
The umbrella stays branchless / pure-coordination / submit_root-rejected;
the CEO-escalation pr_number gate is not widened (the map root is
is_umbrella=False, mirroring a product root, so submit_root supplies it).
Single-cell root-subtasks and everything below them are byte-for-byte
unchanged. Un-run MegaTask waves (multi-cell drafts) become runnable.
* [feature] Panel per-cell project picker + pnpm format infra
MegaTask root-subtasks can fan out across cells (be+fe, fe+uxui). Since a
RoboCo project is per-cell (ProjectTable.assigned_cell), a monorepo is N
per-cell projects sharing one git_url — so multi-cell IS multi-project. The
batch-review card now shows one project Select per the_work entry, scoped to
that cell's repos, instead of one Select bound to a single top-level
project_id. confirmBatch validates each cell's project is in scope and the
batch still spans >=2 distinct projects.
- prompter.ts: CellWork gains optional project_id (the per-cell picker seam).
- batch-review-card.tsx: per-cell Selects (one per the_work entry), scoped to
the cell's projects; legacy single-cell drafts keep the one-Select path.
- use-prompter.ts: updateBatchDraftProject edits per-cell (entryIndex); confirmBatch validates every cell; batchFromEvent parses per-cell map.
Also adds the missing pnpm format infrastructure (the panel had no formatter
at all): prettier devDep + .prettierrc.json (default-style config: 80-col,
double-quote, semi, trailing-comma-all) + .prettierignore, plus format /
format:check scripts. Only the 3 changed files above were reformatted; the
~222 pre-existing non-compliant files are left untouched (a wholesale reformat
is a separate explicit decision, not bundled into this feature).
* [fix] MegaTask verification: migration 052 enum + async cell-map read
Two real bugs surfaced running the full gate against a containerized
Postgres (and the orchestrator boot log):
1. Migration 052 crashed a real orchestrator boot with
'type "team" already exists'. The generic sa.Enum(create_type=False)
does NOT set the postgres enum's create_type attribute, so op.create_table
(checkfirst=False) emitted a redundant CREATE TYPE against the pre-existing
team enum. Switched to postgresql.ENUM(create_type=False) — the postgres-
native enum whose create_type _check_for_name_in_memos actually reads, so
the CREATE TYPE is suppressed. Verified: 051->052 upgrade against a DB where
the team enum pre-existed (the exact path that crashed) now succeeds;
downgrade 052->051 drops the table and preserves the shared enum; fresh
upgrade head clean. (Migration 016 has the same latent sa.Enum pattern but
never re-runs in prod, so it's noted, not touched here.)
2. _ensure_branch_for_task read task.cell_projects (lazy=selectin to-many)
directly, tripping MissingGreenlet on a freshly-created/unqueried task —
which then poisoned the async session (PendingRollbackError). Replaced with
_task_has_cell_map: peeks InstanceState.unloaded (no IO) and reads the
already-loaded map, falling back to an awaited count query only when the
relationship is genuinely unloaded. Non-ORM stubs route to the plain
attribute. Fixes 2 integration tests; the 6 cell-map unit tests still pass.
Also: typed the self stub as Any in test_choreographer_subtask_project
(mypy tests/ wants Choreographer, not SimpleNamespace) — the codebase idiom.
Gate: ruff format/check clean; mypy roboco/ + tests/ clean; full pytest
10371 passed / 388 skipped against containerized pgvector:pg16; vulture clean.
Pre-existing xenon C-rank on reassign (from prior commit 19a474d3, not this
feature) still blocks make quality — surfaced separately.
* [refactor] Extract reassign board-advisory diversion helper (C→B complexity)
`reassign` in roboco/services/task.py hit xenon absolute complexity 11 (a
C-rank block), failing `make quality`'s --max-absolute B gate. The C-rank
originated in 19a474d3 (pre-existing, not this feature branch's work).
Extract the board/advisory → cell-task diversion into
`_maybe_divert_board_advisory_reassign` (complexity 4, A). reassign drops to
9 (B); behavior is byte-for-byte preserved — the helper runs the same
guard + pool diversion + log, returning the diverted task or None so the
caller falls through to the normal handoff. Whole-repo xenon exits 0; the 159
reassign / board-guard tests pass.
Unblocks `make quality` on feature/metrics-granularity.
* [fix] migration 016: postgresql.ENUM(create_type=False) for reused team enum
016_add_products_and_task_product_id used `sa.Enum(..., create_type=False)`
for the reused Postgres "team" enum — the same latent defect that crashed
052 on a real orchestrator boot. On the generic `sa.Enum` the
`create_type` kwarg is silently dropped, so `_check_for_name_in_memos`
never sees it and `op.create_table` (checkfirst=False) emits a redundant
`CREATE TYPE team` that fails with "type 'team' already exists" against a
DB where the enum pre-exists.
Switch to the postgres-native `postgresql.ENUM(..., create_type=False)` —
its `create_type` is a real attribute the guard reads, so the CREATE TYPE
is suppressed (and DROP TYPE on downgrade too). The member list is inert
under create_type=False (it never creates/alters the type), so it stays at
016's original six, reflecting the enum as it stood then, not the
later-widened set.
This never crashed in prod because 016 is never re-run (alembic_version is
past it), but it's the same defect class. Verified on the real boot path:
upgrade to 015 in process A (team enum created by 001), then `upgrade head`
in a fresh process B — 016 applied clean, no DuplicateObjectError; downgrade
016->015 clean, shared team enum preserved.
See project_migration_enum_create_type_gotcha.
* [chore] panel: prettier reformat across the codebase
Apply `pnpm format` (prettier 3.8.5, 80-col / double-quote / semi /
trailing-comma-all) to the 223 pre-existing panel files that predated the
prettier infra added in cb5365a4. Pure formatting — no semantic changes:
multi-line arrays/objects collapsed where they fit, trailing newlines added
(.prettierrc.json), import grouping unchanged.
Verified: `pnpm format:check` clean, `pnpm lint` clean, `pnpm typecheck`
clean, `pnpm test` 113/113 pass (7 files).
* Bunch of runtime fixes for MegaTask and other issues
* Fix different project same PR number collision problem
Fix (two layers):
1. Root cause — pr_merge and rebase_pr_for_task now take a required project_id and scope the lookup where(pr_number == X AND project_id == Y). Required so no caller can forget — the bug class can't recur. All 4 call sites updated (choreographer cell_pm_complete, the rebase-retry, the superseded close_pull_request now passes project_id, and _verb_runner._do_pr_merge).
2. Crash guard — _finalize_cell_complete None-checks the complete() return and returns a clean invalid_state envelope (with a remediate hint) instead of dereffing None → 500 → respawn loop.
* Fix: Make main_pm + task_type=code impossible
* Fix Main PM needs revision can't re delegate
* [chore] Bump local LLM glm-5→glm-5.2 + swap Ollama fleet defaults off minimax
- llm_catalog: OLLAMA_DEFAULT_MODEL minimax-m3:cloud → kimi-k2.7-code:cloud;
role defaults kimi-k2.6→kimi-k2.7-code, developer minimax→kimi, product_owner/
ceo kimi→glm-5.2, documenter glm→kimi; GLM 5.1→5.2 comment fix.
- config + .env.example + docker-compose{.yml,.yaml,.registry.yml} + docs +
memory_distiller + optimal_brain: glm-5:cloud → glm-5.2:cloud.
- panel ai-routing-card: typed SelfHostedModel/boolean annotations; drop the
stale "Minimax M3 default" string (default is now catalog-driven).
- tests: glm-5:cloud → glm-5.2:cloud in pricing + rate-limit-retry fixtures.
* [fix] submit_root: hard unchanged-PR gate stops the pr_fail re-submit loop
The 2026-06-27 infinite pr_fail loop: a Main-PM root (PR #139) was pr_fail'd,
routed to needs_revision, and re-submitted byte-identical → awaiting_pr_review
→ pr_fail again, forever. The prior hint/a2a steer was ignored by the weak
coordinator model — hints don't stop a model that won't read them. A HARD gate
refuses the re-submit when the assembled root PR's head SHA is unchanged since
the last pr_fail (no new cell work → identical diff); a different SHA ⇒ the
branch advanced ⇒ allow. Every ambiguous case fails open (no prior fail, no
recorded SHA, no pr_number, unresolvable slug, git error, closed PR) — only the
exact-unchanged case is hard-blocked.
- content/models: PrReviewContent.head_sha (optional; JSON col → no migration).
- git: get_pr_head_sha (GitHub pulls API; None on any failure → fail-open).
- pr_gate: pr_fail captures head_sha into the verdict record; pr_pass does not.
- _impl: submit_root runs _submit_root_unchanged_pr_guard after _submit_up_guard;
_current_root_pr_head_sha resolves slug + current SHA (fail-open).
- pr_review: extract module-level resolve_task_project_slug, shared by the mixin
and the gate helper (_LegacyChoreographer reaches it via cast to the
ChoreographerHelpers typed view — it doesn't inherit the helpers mixin).
- tests: test_submit_root_unchanged_pr_guard (11 — refuse/allow/6 fail-open/3
capture-side, mypy-clean via cc:Any spy idiom, zero type:ignore) +
test_pr_gate_notifies_pm capture-path stub.
* [chore] mypy tests/: clear all 15 pre-existing type errors so make quality can go green
The branch tip had 15 mypy tests/ errors in files this bundle did not author,
which blocked CI's make quality mypy step (mypy roboco/ tests/) regardless of
the bundle's own commits. Pre-existing is still existing — fix every one:
- test_schemas_v1_flow.py (8): the StrList coercion tests intentionally pass
SDK-nested list-of-strings input ([[['...']]], {'item':{'$text':'...'}}, int,
dict). Annotate those literals as list[Any] locals so mypy accepts the
coerce-able shape; the StrList BeforeValidator still flattens to list[str] at
runtime. No type:ignore.
- test_pr_gate_records_verdict.py (3): notes_structured is dict|None; narrow
with 'assert t.notes_structured is not None' before indexing (the existing
pattern at line 90).
- test_pr_review_hand_format_guard.py (1 site, 2 errors): the _verb_runner()
spy assertion — use the cc: Any = c alias idiom so assert_not_awaited
resolves; drops the now-unused type:ignore[union-attr].
- test_pr_gate_notifies_pm.py (1): drop the unused type:ignore[method-assign]
on the a2a.send reassignment.
- test_content_models.py (1): narrow coerced with isinstance(coerced,
PrReviewContent) before reading .issues (the base _Content lacks the field).
Gates: rm -rf .mypy_cache && mypy roboco/ tests/ = Success (855 files);
ruff check + format clean; 5 affected suites = 40 passed.
* [fix] fail_qa routes needs_revision back to the dev, never the pool
A dev task in needs_revision must go back to the developer, never the
pool. The pool path let a cell PM re-claim the revision (PMs can claim
needs_revision) — the live 2026-06-27 'needs revision on a dev task sent to
the cell PM' bug.
fail_qa's original_developer marker is the fast path, but it is
unreliable in practice (live observation: never persisted), so the
unassign else-branch was the load-bearing path and it dropped the task
into the pool. Add a work-session fallback (_resolve_revision_dev) that
resolves the developer who actually worked the task — the most recent
work session whose agent is a developer, the QA's own session excluded
— and reassigns to that dev instead of unassigning. Only unassign when
no developer ever touched the task. Self-heals the marker so a
subsequent re-fail takes the fast path and the QA-review index
attributes the work correctly.
* [feature] delegate carries dev-task collision surface (sequencing S1)
The cell/main PM's delegate verb now carries the dev-task collision
surface (intends_to_touch / adds_migration / touches_shared) and an
explicit depends_on override through DelegateRequest -> DelegateInputs
-> _create_subtask_from_inputs -> create_subtask, and create_subtask
forwards sequence / dependency_ids / batch_id / surfaces into the
prepared TaskCreateRequest instead of dropping them (the base create
already persists them at task.py:878-884).
This is the plumbing for the multi-level sequencing model edge kind 3
(dev-task collision DAG). Previously a dev task delegated with a
collision surface or an explicit dependency lost it before persistence
— dependency_ids was always [], so the only dev-task ordering was the
weak assignee-keyed spawn barrier (the live 2026-06-27 out-of-order
break: 40842957 started before 9b3682b8's PR merged). Phase S2 runs
SequencingService over the surfaced siblings and wires the DAG via
add_dependency.
* [feature] wire dev-task collision DAG at cell-PM delegation (sequencing S2)
Pure dev_task_collision_edges in sequencing.py turns a parent's surfaced
siblings into (depends_on_id, task_id) pairs via SequencingService. TaskService.
wire_sibling_collision_dag wires them through add_dependency (idempotent). The
choreographer calls it after each dev-task delegate so the sibling collision DAG
is built incrementally as the cell PM decomposes — file-overlap serializes,
migration chains, shared-last; stable (priority, sequence) ordering keeps edges
from flipping into reverse cycles on re-runs.
* [feature] wire cell-task wave chain + by-osmosis edge (sequencing S3)
Kind 2 (cell-task wave chain): a new cell-task under root-subtask UT_n
depends on every cell-task under every root-subtask in UT_n.dependency_ids
(the kind-1 wave-chain edges), so its branch carries the previous wave's
merged cell work. Re-derived from the root-subtask's deps, not the cell-task's
own dependency_ids (which also carry UX/product-fanout edges the by-osmosis
edge must not pick up). A root may fan to several cell-tasks (different cells),
so the previous wave's cell-task is a SET.
Kind 4 (by-osmosis): the first dev task (sequence 0) under a cell-task depends
on each predecessor cell-task's tail (max-sequence) dev task, so the new wave's
first branch carries the previous wave's fully-merged tail. Subsequent dev
tasks inherit the tail via kind 3 or the merged base.
Both wired from _create_subtask_from_inputs, dispatched on parent.team
(MAIN_PM -> kind 2; cell team -> kind 4). Pure helpers
(cell_task_wave_chain_depends_on, by_osmosis_tail_dev_tasks) unit-tested in
test_sequencing.py; TaskService methods integration-tested. Idempotent +
best-effort throughout (add_dependency dedupes; missing predecessors are
no-ops). Also fixes a latent mypy-tests gap (estimated_complexity required on
direct TaskCreateRequest calls in the S2 tests).
* [feature] sync_branch dev verb — gate-level branch rebase (Phase B1)
Raw shell git is denied to agents (Bash(git:*) base deny), so a developer
whose branch fell behind its base had no gate-level rebase — only the
CEO/PM-only /rebase HTTP route. sync_branch is the dev verb that wraps the
rebase through the gate (traced + evidenced), so the 'everything goes through
the gates' invariant holds.
- lifecycle: IntentSpec sync_branch (dev-only, ownership-gated, composes=(),
git-only — no DB transition); _next_hint_synced helper.
- GitService.sync_task_branch: rebase task.branch_name onto its resolved base
via rebase_onto_base (fetch + rebase + force-with-lease push).
- Choreographer.sync_branch + _sync_branch_preflight_rejection: not_found /
unknown-role / spec-gate / no-branch / protected-base guards, then the git
op; conflicts abort (no force-push) and steer to resolve-by-hand; git failure
steers to i_am_blocked.
- HTTP route /api/v1/flow/developer/sync_branch + SyncBranchRequest schema.
- MCP tool sync_branch(task_id) + _TOOLS registration (manifest auto-propagates
via intents_for_role(Role.DEVELOPER)).
Tests: intent spec (5), choreographer handler (8: happy/conflicts/not_found/
not_authorized/no-branch/protected-base/git-failure/audit), route (1), MCP (1).
ruff + mypy roboco/ tests/ clean; unit suite green (DB-fixture errors env-only).
* [feature] i_am_done behind-base submit gate (Phase B2)
A sibling's PR merging into the parent branch while a dev worked leaves the
dev's branch behind its base — the assembled PR then can't merge cleanly and
the sibling's changes go missing (the 2026-06-27 out-of-order dev-task break).
The behind-base gate refuses i_am_done in that state and steers the dev to
sync_branch (the Phase B1 gate-level rebase verb).
- GitService.is_behind_base: rev-list --left-right --count across
origin/{base}...origin/{head} → (behind, ahead); fetch-first so origin
reflects the pushed head. Raises on git failure (consistent with
rebase_onto_base); malformed stdout degrades to (0,0).
- Choreographer._behind_base_gate: wired into _i_am_done_gate after
_ensure_branch_pushed. behind>0 → invalid_state remediate→sync_branch.
Fail-open on git/base-resolution error (flaky fetch can't strand a task at
the submit gate — the merge layer has its own behind checks). Skipped for
branchless roots and protected bases (master/main/-prefixed).
Tests: gate (6: refuse+steer/up-to-date/branchless/protected/fail-open-base/
fail-open-git), is_behind_base (6: parse/up-to-date/malformed/argv-form/
requires-branch/missing-project). ruff + mypy roboco/ tests/ clean; unit green.
* [docs] sync_branch prompt + behind-base guidance (Phase B3)
Update every behind-base/rebase guidance surface to reflect the B1
sync_branch dev verb + B2 i_am_done behind-base gate: devs now self-rebase
through the gate instead of escalating a plain behind-base condition; PMs
still escalate cell/root integration branches (they have no rebase verb).
- developer.md: sync_branch in the verb table; 'When your branch is behind
its base' rewritten — call sync_branch, do NOT i_am_blocked a plain
behind-base; conflicts → resolve by hand, commit, sync_branch again.
- cell_pm.md: delegate signature gains intends_to_touch/adds_migration/
touches_shared/depends_on + a 'Collision surface' section (fill it on every
code subtask so sibling dev tasks that touch the same files sequence into a
conflict-free order — the 2026-06-27 out-of-order break fix); behind-base
section steers devs to sync_branch, PMs escalate only the integration branch.
- main_pm.md: behind-base section — dev leaf = dev's sync_branch; cell/root
integration branch = escalate_up.
- RAG git-errors.md / blocked-tools.md: devs sync_branch, PMs escalate.
- docs/troubleshooting/common-issues.md: leaf self-rebases; integration branch
still escalates to operator.
- CLAUDE.md verb surface: developer gains sync_branch.
- agents/prompts/_generated/*: regenerated via scripts/regenerate_verb_tables.py
— adds sync_branch to the dev table AND catches the generated tables up to
the S1/S2 delegate sequencing params + meltdown-fix note top-level params
(the derived files had drifted stale vs the already-committed schemas).
Docs/prompts only — no code. ruff + mypy roboco/ tests/ clean.
* [chore] orchestrator: refuse to spawn human-only roles (CEO/prompter/secretary)
A live 2026-06-27 incident saw a CEO agent container spawned. Root cause:
_dispatch_a2a_work iterates every A2A/notification target and spawns it
with no human-role filter, and _is_agent_active('ceo') is always false
(the CEO is never a container), so the 'skip if active' check could never
protect the CEO. Any CEO-addressed notification (board handoff, escalation)
launched a CEO container — the system acting as the human CEO: a trust
violation. The CEO is the human operator; intake (prompter) and secretary
are human-driven chats launched through their own dedicated guarded paths
(_spawn_intake_container / _spawn_secretary_container), never spawn_agent.
Fix: a single chokepoint guard at the top of spawn_agent refuses
Role.CEO / PROMPTER / SECRETARY (raises AgentReadinessError + logs). This
structurally covers every dispatcher present and future, since they all go
through spawn_agent. Plus a defense-in-depth skip in _dispatch_a2a_work so
a human-role target never even calls in (avoids error-log spam; the
notification stays for the human to read in the panel).
Safe: the dedicated human-spawn paths do not route through spawn_agent.
Regression tests: spawn_agent refuses ceo/intake-1/secretary-1, does NOT
refuse a real agent; _dispatch_a2a_work skips CEO/intake/secretary targets
and still spawns real-agent + mixed-target cases.
* [chore] orchestrator: skip human-only assignees in claimed/pm-review dispatchers
Defense-in-depth for the spawn_agent human-role chokepoint (d31d6719).
The chokepoint structurally guarantees no CEO/prompter/secretary container
can ever spawn — every dispatcher goes through spawn_agent. But two
dispatchers resolve an arbitrary assigned_to and spawn it with only a
None/unknown-role filter, so a human-assigned task would reach the
chokepoint and RAISE: caught by the per-dispatcher try/except, but it
aborts that dispatcher's whole tick (stalling other respawns behind the
mis-assigned task) and error-logs every cycle. The other dispatchers are
already safe by whitelist/hardcoded slug (blocker_resolver_slug returns
None for non-PM/non-BOARD; escalation/approval use whitelists; marketing
and audit hardcode their non-human slug).
- _claimed_task_needs_agent: return None for a CEO/prompter/secretary
assignee — no container to respawn, and do NOT release a human-owned
task to pending (that would re-route it to a PM). Leave it for the human.
- _dispatch_pm_review_work (assigned branch): skip a human-only assignee
so a CEO-assigned awaiting_pm_review task neither spawns nor aborts the
dispatcher's tick.
Audited all target-iterating dispatchers; only these two lacked a filter.
Regression tests cover both skips.
* [F002] retype board-routed MegaTask root-subtasks code->planning on activation
_activate_batch_root_subtasks flipped a held root-subtask to team=MAIN_PM
but left task_type=code (intake only coerces main_pm-team drafts, so a
board-routed code root reached activation still code-typed). The
main_pm+code combo re-introduces the 2026-06-27 meltdown. Mirror
approve_and_start's own retype via main_pm_cannot_own_code so the
activated child is a planning-typed coordination root.
TDD: RED test_activate_batch_root_subtasks_retypes_code_to_planning
watched fail (task_type stayed CODE), then GREEN after the retype.
ruff+mypy clean; 125 batch/umbrella/approve tests green, no regressions.
* [F003,F004,F014] enforce HMAC agent-token gate on do routes + WebSocket streams
F003/F014: /api/v1/do/* only required X-Agent-ID (UUID) — no token check,
unlike the flow routers' role guards. A forged X-Agent-ID passed. Added
require_any_authenticated_agent (token-only; do router serves all roles)
and applied it as a router-level dependency. Binds X-Agent-ID to a verified
HMAC token when ROBOCO_AGENT_AUTH_REQUIRED=true; rejects a forged token
even in dev mode.
F004: /ws/* per-agent streams (channels/agents/sessions/notifications)
never read the nginx-injected X-Agent-Token, so in strict mode an agent on
the Docker network could subscribe to another agent's notifications with
no auth. Added _require_panel_token verifying the CEO panel token against
the CEO identity; wired into all four per-agent streams (system stream
stays operator-only per its docstring). Same strict/dev contract.
TDD: RED tests watched fail (no gate -> 200/accept), then GREEN. ruff+mypy
clean; 399 api/mcp + 29 WS tests green, no regressions.
* [F005,F006] grok auth: directory mount + atomic-write fallback
F005: the single-file bind mount of auth.json pinned the inode, so the
orchestrator's atomic refresh (tmp+rename within ~/.grok) never reached a
running grok container — a long-lived container hung at the login prompt
when the original ~6h token expired. Mount the host ~/.grok DIRECTORY (ro)
at /home/agent/.grok-auth-ro; the entrypoint symlinks ~/.grok/auth.json at
that RO mount so grok + the --check backstop read the live credential (the
directory mount sees the host-side rename) while grok's writable state
(config.toml, sessions/) stays in the image's ~/.grok.
F006: a rotated refresh_token is single-use — xAI invalidates the old one
the instant it issues the new one. If the atomic write failed after the
rotation, the file kept the now-dead old refresh_token and the credential
was permanently lost on the next refresh. _atomic_write now falls back to a
direct write when tmp+replace fails, so the rotated token always lands on
disk (losing the write is catastrophic; losing atomicity is not).
TDD: RED tests watched fail, then GREEN. ruff+mypy clean; 32 grok tests
green, no regressions.
* [F016,F017] choreographer: surface invalid_state instead of None.status 500 on submit_root / i_am_blocked
Both verbs compose a single atomic action whose None return (the verb's
own result) flowed out of run_intent and was dereferenced as t.status,
HTTP 500-ing with no actionable rejection:
- F016 submit_root: submit_for_review returns None when the root->master
PR was already opened / the task raced out of in_progress. Post-runner
None-guard extracted into _submit_root_finalize -> invalid_state
(re-fetch; if awaiting_pr_review the PR is open, wait for reviewer;
else re-delegate fixes and retry) instead of None.status.
- F017 i_am_blocked: escalate returns None in four cases (no task, no
agent, no resolvable escalation-target slug, no target agent row) e.g.
a developer whose role has no PM above it. _run_i_am_blocked_intent
now guards updated is None -> (t, invalid_state rejection) with
remediation (re-fetch + escalate to CEO directly / retry) instead of
the caller deref'ing None.status -> 500 + respawn-loop.
TDD red->green; ruff + mypy clean; gateway suite green (58 passed).
* [F007] choreographer: cell-level unchanged-PR re-submit loop-stopper for submit_up
The root loop-stopper (F016) was root-only; a weak cell PM could re-submit
the unchanged cell->root PR after a pr_fail and loop awaiting_pr_review ->
pr_fail forever (the cell analogue of the 2026-06-27 root loop).
pr_fail stamps the assembled PR's head SHA into notes_structured.pr_review
.head_sha for cell AND root gate tasks alike (the capture is gate-verb-
level, not root-level), so the same structural refusal applies to submit_up:
if the cell PR's current head SHA equals the SHA the last pr_fail recorded,
no new dev work landed on the cell branch -> the diff is byte-identical ->
refuse, do not re-open the gate. Different SHA -> branch advanced -> allow.
- _submit_up_unchanged_pr_guard mirrors _submit_root_unchanged_pr_guard
(cell-PM remediation: re-delegate to the dev + wait for re-assembly),
wired into submit_up after _submit_up_guard passes.
- Renamed shared _current_root_pr_head_sha -> _current_pr_head_sha (both
guards use it; the lookup was never root-specific).
- Every ambiguous case FAILS OPEN (no prior fail, no recorded sha, no
pr_number, no resolvable project, git/closed-PR None) — only the exact-
unchanged case is hard-blocked.
TDD red->green; ruff + mypy clean; F007+F016 guard suites green (15 passed).
* [F008] evidence_builder: surface persisted pr_review verdict+issues in the PM task_handoff
The pr_fail a2a steer to the owning PM is fire-and-forget; a PM respawned
into needs_revision later read none of it (build_task_handoff never looked
at notes_structured), saw a generic 'needs revision' with zero concrete
change-requests, and re-submitted the same PR (the 2026-06-27 infinite
pr_fail loop on 9980d0a0 / PR #138). The signal-gap was only partially
closed by the a2a.
build_task_handoff now extracts notes_structured.pr_review
(verdict/summary/issues/head_sha — the slot pr_fail authors on every fail)
into a pr_review field on the handoff, so every PM briefing for the task
carries the concrete change-requests. A prior pr_fail alone now counts as
prior-work-worth-resuming. Type-guarded + capped; absent => no key (no
misleading empty slot).
TDD red->green; ruff + mypy clean; evidence_builder suite green (14 passed).
* [F009] notification: derive requires_ack from ACK_REQUIRED_BY_TYPE, not the True default
NotificationService._create_notification built NotificationTable without
requires_ack, so the column default (True) applied to EVERY notification -
including informational REVIEW_REQUEST / DOCUMENTATION_REQUEST /
A2A_REQUEST / KNOWLEDGE_SHARE (ACK_REQUIRED_BY_TYPE -> False) and every
@mention from MessagingService._notify_mentions. Each false ack-required
inflated the recipient's unacked set and soft-blocked i_am_idle into
respawn churn.
- _create_notification: requires_ack=ACK_REQUIRED_BY_TYPE.get(type, True)
(unmapped types default True - preserve the action-required bias).
- _notify_mentions: requires_ack=False explicit (MENTION is informational).
TDD red->green (identity is False/is True assertions - the mocked
flush doesn't apply SQLA's insert-time default, so pre-fix the attribute
was None); ruff + mypy clean; notification suite green (18 passed).
* [F010] notification: never dedup informational notifications (knowledge-share data loss)
The purpose-based dedup suppressed a same-purpose (same sender/type/task,
overlapping recipients) notification while a prior one was unacked. For
informational types (KNOWLEDGE_SHARE / MENTION / A2A_REQUEST / BROADCAST +
the pickup-proves-receipt triad) each send carries DISTINCT content (a new
learning, a new mention) and acking is voluntary, so a recipient who never
acks the prior one let the dedup permanently suppress every subsequent
same-sender broadcast - silent learning-broadcast data loss.
The dedup's anti-loop rationale (stop unacked-set inflation soft-blocking
i_am_idle) only holds for action-required signals. Gate the dedup on
ACK_REQUIRED_BY_TYPE.get(type, True): action-required types still dedup,
informational types always create. Unmapped types default True (dedup on).
TDD red->green; ruff + mypy clean; notification + dedup suites green (20).
* [F011] playbook: de-index rejected/archived playbooks from the PLAYBOOKS RAG index
* [F012] release_executor: fail-closed on git add/commit before push
* [F013] release_proposal: Redis SET NX mutex guards the ~40min execute against concurrent approves
* [F015] flow_qa/flow_doc: add i_am_blocked route (manifest-registered escape hatch was 404)
* [F018] claim_guards: treat blocked as active + broaden the guard lookup so a blocked dev can't double-claim
* [F019] git: clear orphaned .git/*.lock files after a timeout-SIGKILL'd mutation op
* [F031] identity: role_for_slug_or_none so defensive skip-guards don't crash the dispatcher tick on stale slugs
* [F032] test: unknown-assignee claim reaches release-to-pending path
F031's role_for_slug_or_none fix made the unknown-assignee release branch
in _dispatch_claimed_without_agent reachable (the human-only guard no
longer raises/short-circuits on a stale slug). Lock that reachability in:
a claimed task with an unknown-assignee UUID past grace returns the slug
(not None) so get_agent_role -> 'unknown' releases the claim to pending
for a role-matched reclaim.
* [F033] orchestrator: capture container_id at startup re-adoption
_readopt_running_agents registered re-adopted ACTIVE instances with
container_id=None. _check_health skips container_id-is-None instances, so
when a re-adopted container later exited the stopped-container handler
never ran and the task stranded under a phantom ACTIVE instance forever.
Add _resolve_container_id (docker inspect -f '{{.Id}}') and store the real
id on re-adopt. Best-effort: a probe failure degrades to None (still
ACTIVE; the reaper's Docker-liveness fallback covers it).
* [F034] orchestrator: re-stamp respawn last_check at restore
_pm_made_rule_following_retry bounds its tracing_gap audit lookup with
since = record.get('last_check'). A stale persisted last_check from before
the restart matched pre-restart tracing_gap rows, falsely resetting the
breaker on the very first post-restart spawn — exactly when a fresh strike
count should be evaluating current state.
_partition_respawn_rows now re-stamps last_check to the restore time on
every restorable entry, bounding the lookup to post-restart gaps only.
* [F035] orchestrator: probe-resume loop actually revives parked agents
_park_provider_unavailable parked the provider + offlined the instance but
never registered a WaitingRecord, so _on_probe_success -> _parked_agents_for
(always filtered on waiting_for=='rate_limit_lifted') returned [] and
resolve_wait revived nobody — recovery fell to the 600s stale-claim reaper
instead of the probe-success path the parking design relied on.
Register + persist a rate_limit_lifted WaitingRecord at park time (mirrors
mark_waiting_long, minus stop_agent — the container is already dead).
Companion reaper guard: _reap_with_service now skips provider-parked
assignees (_assignee_is_provider_parked) so the claim survives until the
probe revives the agent — otherwise the reaper releases the claim to pending
and probe-success respawns on a task the agent no longer owns.
* [F036] orchestrator: read transcript for overload detection too
The SDK server writes model-API errors (529/500/503) to /tmp/sdk-server.log,
not stdout, so an overload marker can appear only in the durable Claude
transcript — the same rationale already applied to the session-limit
detector. _provider_overload_park_target read only docker logs, so an
overload was missed and the agent crash-respawned straight back into it.
Now concatenates the transcript tail before matching, mirroring the
rate-limit path.
* [F037] orchestrator: drop bare error-NNN overload markers
The bare 'error 529'/'error 500'/'error 503' markers were broad enough to
false-match an agent that merely writes about an HTTP status code in its own
notes ('the endpoint returned error 500, retrying'), parking the whole
Anthropic fleet on a non-issue.
The SDK error formatter emits 'API Error: NNN' + a JSON error type, so the
remaining 'api error: 529/500/503' + 'overloaded_error' +
'internal_server_error' markers cover every real overload without that
false-match surface.
* [F038/F039] orchestrator: sign X-Agent-Token on self-API calls
The prior self-PATCH 401 fix only carried X-Agent-ID/X-Agent-Role. Arming
ROBOCO_AGENT_AUTH_REQUIRED=true made the middleware require a signed
X-Agent-Token, so every orchestrator self-call (auto-block / auto-resume /
auto-recover / SLA annotation) 401'd and silently no-op'd — wedging
paused/blocked parents.
Add _system_api_headers() that wraps the base headers with a signed token
for the system identity (issue_agent_token); switch all six self-call sites.
Dev fallback: no secret set => UNSIGNED sentinel + auth not required.
* [F040] orchestrator: finalize grok spawn session on cost-cap kill
_enforce_grok_cost_budget killed + evicted the container without calling
_finalize_spawn_session, so the open agent_spawn_sessions row stayed open
(ended_at IS NULL) and the burned usage/cost was never recorded in the
dashboard.
Call _finalize_spawn_session(exit_reason='cost_cap') BEFORE popping the
instance — it reads self._instances[agent_id] for the model +
usage_session_id, which the pop would lose.
* [F041] park grok exit-78 (auth missing/expired) instead of crash-retrying
A one-shot grok container whose entrypoint ran grok_auth --check and found
the token missing/expired exits 78 (EX_CONFIG). Crash-retrying 3x burns
tokens for zero progress — the agent cannot start without a valid token.
Park the provider with kind=auth_missing (same shape as the 429 exit-75
path) so the probe-resume loop revives the task once grok_auth.refresh_if_stale
mints a fresh token; if still expired, the next exit 78 re-parks (no burn).
Also fixes a latent F035 regression: _park_provider_unavailable now registers
a WaitingRecord, so the bare-__new__ rate-limit park test had to set
_waiting_records + stub _persist_waiting_record (mirrors the overload-test
fixture).
* [F042] isolate concurrent-duplicate conventions cache put in a savepoint
Two task creates for the same project/HEAD can race to populate the
conventions cache; the loser's INSERT fails the partial-unique index with
IntegrityError. A bare session.add + flush poisons the shared session (the
task-create transaction rides the same session), so every subsequent op
raises 'this session is in error state' and task creation crashes.
Run the INSERT in a savepoint (begin_nested) and swallow the IntegrityError:
only the savepoint rolls back, the outer transaction stays usable, and the
winner's row satisfies the next _cache_get.
* [F043] guard escalate_up against resurrecting terminal tasks
escalate_up had composes=() and no source-status guard, so a PM could
escalate a COMPLETED/CANCELLED task and apply_escalation set it back to
BLOCKED — bypassing the state machine's terminal-state invariant.
Defense in depth:
- spec: add PRECONDITION_NON_TERMINAL to escalate_up's extra_preconditions so
the lifecycle gate rejects terminal tasks (invalid_state) before the
journal:decision write fires; generalize _check_intent_preconditions to
honor non-tracing rejection_kind (not_authorized / invalid_state).
- service: apply_escalation (the single write primitive) returns False and
refuses to mutate a terminal task — covers the HTTP escalate route which
bypasses the spec gate. escalate() / escalate_up_to_role() return None on
refusal so the gateway emits a clean invalid_state envelope.
- route: the HTTP escalate route 409s a terminal task BEFORE sending the
escalation notification (so a finished task isn't yanked back, PM not pinged).
* [F044] pr_pass gate remediation points the reviewer at pr_fail, not i_am_blocked
The pr_pass gate runs the toolchain + conventions guards on the REVIEWER's
workspace, but their remediation text said 'call i_am_blocked' — a verb the
PR reviewer does not have. The reviewer would chase a verb they cannot call
instead of rejecting the PR.
Make the guards reviewer-aware: a reviewer=True flag (passed by _pr_pass_blocked)
switches the remediation to pr_fail(issues=[...]) — the reviewer's reject
lever, sending the PR back to needs_revision for the dev to fix the
environment / validator. The dev (i_am_done) path keeps i_am_blocked, which a
dev does have. _conventions_guard (the pr_pass path) now passes reviewer=True
through to _conventions_rejection.
* [F045] rate-limit: loud activate-failure log + in-memory orphan-probe fallback
The in-verb i_am_blocked(rate_limited) path wrapped RateLimitStateTracker.activate
in a bare contextlib.suppress. A silent activate failure stranded the fleet:
agents were parked in _waiting_records but the provider never entered the tracker,
so the tracker-driven _sweep_rate_limit_probes never probed it and no
_on_probe_success ever resumed them — parked agents stuck in WAITING_LONG.
Fix: (1) replace the bare suppress with a try/except that logs an error event
naming the provider + affected agents; (2) in _sweep_rate_limit_probes, after
probing the tracker-listed set, scan _waiting_records for any rate_limit_lifted
provider the loop did NOT cover and probe it via the time-expiry fallback (empty
state -> probe now) so _on_probe_success resumes the parked agents. The fallback
reads only local memory, so it still resumes when Redis was down at park time
(list_rate_limited_providers failure now falls through to the orphan scan instead
of returning early).
* [F046] pr_gate: guard None runner result on concurrent transition (pr_pass/pr_fail)
_gate_decision dereferenced the verb-runner result without a None guard.
run_intent returns None when a concurrent transition (cancel or a racing
reviewer) moves the task out of awaiting_pr_review between the precondition
gate and the runner's final composed action (the verb runner's documented
last-action source-status contract). The subsequent t.assigned_to /
t.status / _post_gate_review_to_pr(t, ...) dereferences then crashed the
gate with a 500 AttributeError. Add a None guard that surfaces a clean
invalid_state rejection (re-fetch + re-issue) before any dereference; no
PR post or a2a runs against a None task. TDD test_pr_gate_notifies_pm.py (+2).
* [F047] conventions: reviewer-aware block-finding remediation on pr_pass gate
The pr_pass (reviewer) conventions guard reused the dev-path block-finding
remediation: 'add a waiver to .roboco/conventions.yml in your branch'. A
pr_reviewer does not own the assembled cell->root / root->master branch and
has no commit verb on it, so the waiver remediation is unreachable — a false
positive stranded the gate with no self-recovery (the reviewer could neither
commit a waiver nor pr_pass). The fail-open content path is documented
precision-over-recall and stays as-is; the actionable gap is the remediation.
Fix: _conventions_rejection now branches the block-finding remediation on
reviewer=True (mirroring the could_not_run branch from F044). The reviewer
path points at pr_fail carrying the findings as issues so the PR returns to
needs_revision and the DEV fixes the violation or commits the waiver (the dev
CAN commit to the branch); waiver authorship is framed as the dev's action,
not the reviewer's. Dev i_am_done path wording unchanged. TDD
test_conventions_gate_pr_pass.py (+1).
* [F048] notify: reject human-only recipients (prompter/secretary) — no agent ack path
notify() only checked the SENDER role. The recipient was resolved by
NotificationService._resolve_recipients, which drops only unresolvable slugs
— it does not exclude human-only roles. The prompter (intake-1) and secretary
(secretary-1) are seeded agent rows, so they resolved, and an ack-required
ALERT addressed to them sat permanently unacked (no agent auto-acks it),
polluted the panel's pending-ack view, and — via the dedup query's
~acked_by.contains — permanently suppressed any later same-purpose
notification from the same sender to that human role. The knowledge-share
path already excludes all three human-only roles; the general notify path
did not.
Fix: a recipient-role guard in notify() via _reject_disallowed_recipient
(folds the new check into the existing CEO-dependency-block return slot so
notify stays under the PLR0911 return limit). Rejects prompter/secretary
with not_authorized; the CEO is human too but acks via the panel, so it stays
an allowed recipient (its only disallowed case, a dependency-block page, is
preserved). TDD test_notify.py (+3: reject prompter, reject secretary, allow
CEO).
* [F049] merge_pull_request: idempotent on already-merged PR (mirror _merge_with_retry)
* [F050] merge_pr_for_task: verify caller pr_number matches task's recorded PR
* [F051] open_conventions_pr: refuse dirty tree + verify checkout-base landed
* [F052] pr_target: scope task lookup by project_id (mirror close_pull_request)
* [F053] _token_for_project: log decryption failure (key rotation) with project slug
* [F054] learnings index: enforce shareable on every shared retrieval path (private-leak fix)
* [F055] messaging: recover from concurrent channel auto-create race via savepoint + re-fetch
* [F056] messaging: lock group row before session check-then-create to prevent active-session orphan race
* [F057] playbook: index/unindex as a post-commit step so the RAG corpus never leads the status transaction
* [F058] release-readiness: non-empty bump plan on first release
_canonical_bump_files derived the bump set from the previous
chore(release): commit. On the first release there is no such commit,
so it returned [] -> assess set version_bump_plan=[] -> the executor
published a tag with no files bumped (a no-op masquerading as X.Y.Z).
Fall back to the version-reference scan when no prior release commit
exists: the files currently embedding the version are exactly the set a
first release must bump, and the set the first release commit then
records as canonical for subsequent releases. Read-only derivation; the
CEO-approval gate and fail-closed executor are untouched.
* [F059] self-heal: hold fix tasks for CEO Approve-&-Start (restore dispatch gate)
The module docstring promised self-heal fix tasks 'wait for the CEO's
Approve-&-Start', but _originate created them confirmed_by_human=True and the
orchestrator dispatched them at once — a self-heal fix that re-broke CI would
trigger another cycle, open another auto-dispatched fix, and loop with no CEO
gate on dispatch.
Restore the documented gate:
* _originate opens the task confirmed_by_human=False (held for the CEO).
* The orchestrator holds a self-heal task out of both the PM and dev dispatch
paths until confirmed_by_human flips True.
* approve_and_start (the CEO's start gate) sets confirmed_by_human=True so the
held task finally dispatches (idempotent for board/intake tasks already True).
* list_pending_for_agent scopes the give_me_work hold to self-heal
(source != self_heal OR confirmed_by_human) so an already-alive PM can't grab
it pre-approval — while ordinary delegated subtasks (confirmed_by_human=False
by default, where the delegation IS the authorization) still dispatch.
The 'never self-deploys' guarantee (no merge) is unchanged.
* [F059] fix DB-integration test auth + retype self-heal root code→planning
conftest test-DB defaults matched the project's own running postgres
(roboco/roboco @ localhost:15432, the docker-compose roboco-postgres
service with CREATEDB) instead of the OS user on localhost:5432 which has
no such role — every db_session test failed with InvalidPasswordError
instead of running.
Once the DB connection worked, the self-heal origination DB test went RED
with MAIN_PM_NO_CODE: the self-heal root was task_type=CODE owned by
main_pm, the combo the main_pm_cannot_own_code guard rejects. The Main PM
coordinates the fix (delegates the code work to a cell dev); it has no
code verb. Retyped CODE→PLANNING and rewrote description/AC to
coordination-level.
* [F060] emit reversal audit row on claim-branch-failure rollback
The forward task.claimed audit row is flushed before the branch-creation
attempt, and AuditService commits on its own connection, so the rollback's
flush reverts the task row but not that audit row — the journey's last
event stayed task.claimed while the task reverted to its pre-claim status,
diverging from real state and corrupting downstream cycle-time/bottleneck
metrics. The rollback now emits a CLAIMED->original reversal audit row
(only when the forward transition was made) attributed to the claimant.
* Removing completely unnecessary files (for the repo they are unnecessary)
* [F061] audit status-transition rows now written in-session (F061/F073/F075)
_emit_status_transition_audit now writes AuditLogTable rows into
self.session synchronously (session.add) instead of dispatching
AuditService.log_task_event fire-and-forget on its own connection.
The audit row now commits/rolls back atomically with the status
transition in the caller's transaction, closing three facets at once:
- F061: audit commit no longer decoupled from the transition commit
- F073: a committed transition can no longer have NO audit row
(the row rides the same transaction; a swallowed persist can't drop it)
- F075: a transition rolled back inside a verb savepoint no longer
leaves a phantom audit row (the row is in the savepoint too)
log_task_event is now called only from this helper (narrow blast
radius verified); revision_count increment stays at this single
chokepoint. Cycle-time/bottleneck reconstruction from task.<status>
events is no longer silently corruptible.
Tests: test_emit_status_transition_audit_writes_in_session_atomically,
test_finalize_claim_rollback_emits_reversal_audit, escalation-audit
tests retargeted to in-session AuditLogTable rows.
Also: _canonical_bump_files grep-looseness follow-on (F058) -- filter
by subject, not body; git log --grep matches any message line, so a
non-release commit whose body references chore(release): shadowed the
real release commit. Test
test_canonical_bump_files_ignores_body_only_chore_release_match.
* [F061] drop type:ignore from audit-emit tests
Convention: no type:ignore/noqa. The F061 in-session audit-emit
tests used '# type: ignore[assignment]' to assign a MagicMock to
AsyncSession.add, and the F060 test assigned to .flush the same way.
Rewritten to hold a local 'session: MagicMock' variable (mypy sees
its auto-children as MagicMock, so .add.side_effect / .flush assign
cleanly with no suppression). Verified via 'mypy tests/' that both
files are now type-clean (the F060/F061 commits had skipped tests/
in mypy, masking two method-assign errors).
* [chore] clear all 64 pre-existing mypy errors in tests/ (no type:ignore)
Convention: no type:ignore/noqa, and pre-existing violations still
violate. The make-quality gate runs 'mypy roboco/ tests/', but the
prior commits' gates only ran mypy on production files, masking 64
type errors across 15 test files (method-assign, unused-ignore,
no-untyped-def, attr-defined, union-attr, has-type, index, misc).
Fixed without any type:ignore:
- method-assign (svc.session.X = / svc.method = AsyncMock()): hold a
local 'session: MagicMock'/'AsyncMock' and assert on it, or stub via
object.__setattr__ / monkeypatch / a typed '_bind' helper returning
Any, or alias 'cc: Any = c' (the pattern the file already used).
- unused 'type: ignore[assignment]' (real code was method-assign):
removed; replaced with the no-suppression patterns above.
- 'Callable[...] has no attribute assert_*': keep a typed local ref to
the AsyncMock and assert on the local, not the method-typed attr.
- no-untyped-def: annotate helper params (Any / pytest.MonkeyPatch).
- attr-defined / index / union-attr: type the helper as Any, narrow
with an 'is not None' assert, or add the missing attr to a fake.
- has-type / return-value: fix the declared return type to the tuple
the function actually returns.
- PLC0415 inline imports: hoisted to top-level.
test_pr_gate_notifies_pm._stub_gate_path converted fully to the
'cc: Any = c' alias (it already used it for one attr) so its five
'# type: ignore[method-assign]' suppressions are gone.
mypy tests/: 64 errors -> 0 (538 files). ruff check tests/: clean.
All 84 tests in the touched files pass.
* [chore] remove all remaining type:ignore suppressions from tests/
Converts 115 `# type: ignore[...]` suppressions across 23 test files to
no-suppression patterns (helper-return widening to Any, local Any aliases,
cc:Any aliases, cast at narrow call sites, typed fixtures) so the hard
no-type:ignore convention holds across tests/. No test logic or assertions
changed — only mock-wiring mechanics and type annotations.
Gate: ruff check tests/ clean; mypy tests/ (538 files) clean; 176 changed-file
tests pass. Zero real suppressions remain (the 7 grep hits are 3 hygiene-
checker string-literal test inputs and 4 prose mentions in comments).
* [F062] work_session.merge_pr: idempotency + active-status guard
merge_pr unconditionally set pr_status=merged, pr_merged_at, merged_by,
status=COMPLETED on whatever session it loaded — the only session-terminal
transition in WorkSessionService lacking both the active-status guard
(complete/abandon) and the terminal-idempotency guard (close). Two failure
modes: (1) a retried merge after a successful-but-unconfirmed GitHub merge
overwrote merged_by/pr_merged_at with the retry's actor/timestamp, corrupting
the merge audit trail; (2) merge_pr on an ABANDONED session resurrected it to
COMPLETED, undoing the single-active abandonment. Mirrors close()'s guard:
if status != ACTIVE, return the session unchanged. Both git.py callers await
merge_pr and discard the return, so the no-op is safe. TDD: 3 tests
(happy-path + both modes).
* [F063] workspace._clone_repo: rmtree half-configured clone on failure
If _configure_git raised CalledProcessError before its `remote set-url`
scrub, .git/config kept the tokenized auth URL (the project PAT) and
_assert_no_pat_leak never ran. The except clauses raised WorkspaceError
without removing the workspace, so the next ensure_workspace's health
short-circuit (valid .git with HEAD + objects) skipped past the leak —
mounting the agent on a workspace whose .git/config let it read+exfiltrate
the PAT. Both clone-failure except clauses now rmtree the workspace before
raising, so a half-configured clone is destroyed and ensure_workspace
re-clones from scratch. TDD: 2 tests (configure-failure leak + timeout).
* [F067] flow_main_pm: add missing /triage route
main_pm's manifest advertises triage (lifecycle.intents_for_role(MAIN_PM)
includes it via _PM_ROLES, alongside triage_all) but flow_main_pm.py had no
POST /triage route, so a main_pm agent calling triage hit a raw 404 that
bypassed the per-verb circuit breaker. Added the route mirroring flow_cell_pm's
/triage — wires to the existing team-scoped choreographer.triage (uses pm.team,
works for any PM role; Main PM gets its own team's blocked/awaiting tasks).
Fix direction: add-route, NOT remove-from-manifest — the manifest is spec-correct
(intents_for_role by construction); removing triage would contradict the spec
and leave main_pm with only cross-team triage_all. TDD: test_triage_route_exists_and_dispatches.
* [F068][F069] mcp servers: classify all rejection shapes + envelope 404s
F068: the do/flow-server circuit breaker only counted rejections whose
`error` field was a STRING in _CIRCUIT_REJECTION_KINDS. A 422 validation
failure (no `error` field, a `detail` list) and a 500/HTTPException
(dict-shaped `error` from the exception handlers) both bypassed the breaker
→ unbounded retries on a storm of either. Added _classify_rejection(payload)
(shared, applied to both servers) mapping all three shapes to a counted kind:
string error (existing), dict error → substring-mapped code
(*DENIED*/*AUTHORIZED*/*FORBIDDEN*/*PERMISSION*→not_authorized,
INVALID_INPUT/*VALIDATION*→incomplete_input, *NOT_FOUND*→None parity, else
→invalid_state), 422 detail→incomplete_input. The dict TypeError defence lives
in the classifier (isinstance, never dict-in-frozenset).
F069: a manifest-registered verb whose HTTP route is missing got FastAPI's raw
`{"detail":"Not Found"}` 404 body — a non-envelope payload the breaker
couldn't classify, so a storm bypassed it. _post now synthesizes an
invalid_state Envelope rejection (with a remediate hint → i_am_blocked/i_am_idle)
for a 404 status, routed through _record_and_check_circuit so the breaker counts
it. A 404 that carries a real Envelope (error field present) is surfaced as-is,
preserving test_flow_post_returns_envelope_on_404. TDD: 422/dict/404 tests in
both server test files; updated test_dict_shaped_error_does_not_crash to assert
the SDK is now called with not_authorized (replacing the pass-through assertion
that encoded the bug).
* [F064][F065][F066] websocket: non-blocking fan-out, finally-disconnect, idle timeout
F064: the bridge forwarder awaited every conn.send_text in a gather with no
per-connection queue and no send timeout — one slow WS client back-pressured
ALL event delivery to ALL clients (head-of-line blocking on the listen loop).
Each connect_* now registers a _ClientConnection (bounded asyncio.Queue(256) +
sender task); broadcasts enqueue via put_nowait (drop + structlog warn on
QueueFull) and return immediately. The sender drains the queue with each send
wrapped in wait_for(SEND_TIMEOUT=10s). Unregistered legacy sockets (set
directly into a subscription set, bypassing connect_*) get a timeout-bounded
fallback send task held in _pending_sends (ruff RUF006). disconnect cancels +
drops the sender.
F065: route handlers caught only WebSocketDisconnect with no finally — a
non-clean exit (anyio closed-resource, CancelledError, transport error)
propagated without manager.disconnect, leaking the dead socket into every
subscription set forever. Added finally: manager.disconnect(websocket) to all
5 handlers (disconnect is idempotent).
F066: no server-side heartbeat/idle timeout — a half-open socket from a dead
container blocked receive_text forever and was never reaped. receive_text now
wraps in wait_for(IDLE_TIMEOUT_SECONDS=90s); on TimeoutError, log + fall
through to the F065 finally. Named module constants (no config.py precedent for
WS tuning; callers/tests patch them).
TDD: 22 new tests across 3 files (handler cleanup, idle timeout, send queue),
non-flaky across repeats; 1 existing test adapted with a yield for the new
async fan-out (assertion unchanged). ruff/mypy clean, 421 unit/api tests pass.
No type:ignore/noqa.
* [F022][F023][F024][F025][F026] api: scrub secrets from 422 log, gate a2a/dashboard/orchestrator routes, SSE session-per-query
- middleware: redact known credential fields (git_token/api_key/token/...)
from the 422 request-validation log line; response body unchanged
- a2a: require_any_authenticated_agent on /message/send + /message/stream;
subscribe_to_task opens a short-lived session per poll instead of holding
one asyncpg connection for the full SSE lifetime (pool exhaustion) + auth
- dashboard: gate auditor flag/report mutating routes to Auditor or CEO
- orchestrator: router-level CEO gate on all control routes (spawn/stop/...)
TDD; ruff/mypy clean; 449 unit/api tests green; no type:ignore/noqa.
* [F030] conventions: typescript-scoped custom rules now apply to .tsx files
The validator tags a .tsx file as language 'tsx' (the JSX grammar needs
that tag, distinct from plain 'typescript'), but a custom rule scoped to
'typescript' — the language the scan reports for a React+TS repo — silently
skipped every .tsx file. The two suffix maps were NOT unified: the 'tsx'
tag is load-bearing (grammars.py picks the JSX grammar on it; hygiene.py
keys on it), so unifying would make .tsx fail to parse.
Fix is in check_custom: a one-directional dialect map _DIALECT_OF =
{'tsx': 'typescript'} — a typescript-scoped rule fires on a .tsx file,
but a tsx-scoped (JSX-only) rule still does not fire on plain .ts.
TDD; ruff/mypy clean; 80 unit + 38 integration conventions tests green.
* [F029] websocket: remove broken /api/permissions/check loopback from channel stream
channel_stream called validate_channel_access, which HTTP-loopbacked to
GET /api/permissions/check — a route that does not exist. Every call 404'd
-> False -> the channel stream closed with WS_1008_POLICY_VIOLATION for
EVERY client, so the real-time channel stream was dead. Removed the
function, its call site, and the now-unused httpx + settings imports.
Post-F004 the panel-token gate is the channel-stream authorization (the
CEO panel is the sole WS client and may view every channel), so the
broken loopback is removed rather than replaced with an in-process check
the CEO always passes. The legitimate enforcement.validate_channel_access
(slugs, in-process static ACL) is a different function and is untouched.
F027 is resolved-by-F004 (no code change): all three per-agent streams
gate on _require_panel_token first, so only the authorized CEO panel can
connect — 'any viewer subscribes to any target' is closed.
TDD; ruff/mypy clean; 530 unit/api+enforcement+RBAC tests green.
* [F078] release_executor: deadline every subprocess (git/make/gh/clone)
A hung git/make/gh/clone would block the CEO-gated release loop
indefinitely. Wrap each proc.communicate() in asyncio.wait_for via a
shared _await_proc helper; on expiry proc.kill() the child and return a
non-zero rc (124) so every caller's fail-closed branch fires. Mirrors the
quality-gate _run_one kill-on-timeout idiom.
Deadlines are generous (30min gate / 10min clone / 5min push+gh) so a
legitimate slow op is never wrongly aborted — floor-assertion tests pin
the floors to guard exactly that logical regression. Green path returns
the real rc unchanged.
* [F072] reaper: deadline docker inspect/exec + harden _check_health sweep
A hung Docker daemon (or a stuck container FS) froze the single asyncio
event loop: the reaper runs inline before every dispatch tick and shares
that loop with every background sweeper. Bound each docker subprocess
with asyncio.wait_for; on expiry proc.kill() the child and either raise
(inspect / resolve_container_id — callers apply their own fail-direction)
or return None (the gateway probe — inconclusive, caller declines to act,
matching its existing probe-failure contract). Deadlines generous
(10s inspect / 30s exec) so a legitimate slow docker call is never
wrongly aborted; floor-assertion tests pin the floors.
Also harden _check_health's per-agent loop so one agent's hung inspect
skips that agent, not the whole sweep — preserving the check-all-agents
invariant the timeout-then-raise would otherwise break (without this, a
hung daemon means no agent gets health-checked any tick).
* [F076] say/dm: handler guard rejects all 4 no-comms roles, not just auditor
The say()/dm() defence-in-depth guard only rejected auditor, but CLAUDE.md
mandates the same no-agent-comms invariant for pr_reviewer (posts findings
on the PR), prompter and secretary (human-only, note + evidence). For those
three the manifest was the only gate, so a call bypassing the manifest
(direct API POST, test harness, future routing change) would not be refused
at the handler — admission depended on the agent's slug happening to be
absent from the channel/a2a matrix. Extend the guard to a _NO_COMMS_ROLES
frozenset (auditor + pr_reviewer + prompter + secretary), matching the
explicit role-frozenset gates on commit/notify/pitch/playbook/open_session.
Role-appropriate remediation per role. The claimed defence-in-depth now
covers 4 of 4 silent roles, not 1 of 4.
* [F070] drain fire-and-forget _bg_tasks on shutdown (bounded, data-preserving)
Orchestrator.stop() cancelled only the named loop tasks + agents, then
returned, abandoning in-flight _schedule_bg work. An in-flight
_persist_respawn_record upsert dropped at shutdown meant the last few
gate-mutation strikes never reached the DB; restore_respawn_tracker() on
the next start repopulated a stale lower count and the dispatcher re-burned
the full 4-spawn strike threshold against a still-wedged task — the exact
re-burn the durable tracker exists to stop. Audit-log writes (load-bearing
for cycle-time/rework metrics) were similarly dropped.
Add _drain_bg_tasks(): bounded wait (5s default) lets short DB writes
commit before exit (data preserved), then cancels any stuck task past the
deadline so a hang can't wedge shutdown. return_exceptions=True so one
failing bg task doesn't crash the drain. Wrap the stop_agent loop in
try/except + logger.exception so one bad agent can't skip the drain
(re-introducing the data-loss tail). Floor test pins the deadline >= 3s
so a too-short change can't silently drop a legitimate slow write.
* [F071] abort non-blocking intake/secretary spawn on mid-spawn shutdown
The non-blocking spawn (start_intake_session / start_secretary_session)
schedules _spawn_intake_container_guarded / _spawn_secretary_container_guarded
via _schedule_bg. Those run docker run and only register in _instances at the
END. If shutdown arrived between docker run and the registration line, the
container was started but the orchestrator had no handle — stop() iterates
only _instances, so the container was orphaned (leaked, manual docker rm).
Worse, the F070 drain could let the spawn coroutine complete the
registration AFTER stop() already iterated _instances, landing a live
container into a shutting-down registry nothing tears down.
Add a post-docker-run shutdown guard in _spawn_intake_container and
_spawn_secretary_container: re-check self._running after _run_container_cmd
returns; if the orchestrator began shutting down, remove the just-started
container (by its deterministic name) and raise _SpawnAbortedDuringShutdown
WITHOUT registering. The guarded wrappers catch that BEFORE except Exception
and close the live relay silently (shutdown is not a user-facing failure,
no error pushed to the SSE stream). The F070 stop() drain awaits the bg
spawn coroutine, so the abort surfaces cleanly.
TOCTOU-safe: between the _running check and the _instances assignment there
is no await (config + instance construction are sync), so once the check
passes, registration completes before the event loop can interleave stop().
The normal running path is unchanged (sanity tests pin it).
* [F074] per-agent advisory lock closes claim TOCTOU
_run_claim_guards read the agent's other tasks via unlocked SELECTs
before claim() took its row lock, and claim()'s FOR UPDATE locked only
the TARGET row — so two concurrent i_will_work_on by the SAME agent on
TWO DIFFERENT pending tasks each locked their own row, each read an
empty in_progress set, each passed already_active, each claimed+started
→ the agent ended with two in_progress tasks (the in-process asyncio
Lock is lost on orchestrator-restart split-brain, so it wasn't a
DB-level guarantee).
Fix: TaskService.acquire_claim_lock takes a transaction-scoped
pg_advisory_xact_lock keyed by hashtextextended(agent_id). The gate
acquires it BEFORE the guard reads (for non-coordinator roles only) so
the second concurrent claim's read sees the first's committed
in_progress task and is rejected. Tx-scoped → auto-releases on
commit/rollback, can't outlive the request.
Coordinator exemption (the key logical-regression guard): cell_pm /
main_pm do NOT take the lock — the PM coordinator concurrency feature
lets a PM plan+delegate many roots in parallel, and a per-agent lock
would serialize those claims and regress it. Matches the existing
_COORDINATOR_ROLES already_active/paused guard exemption. A hash
collision only causes benign false serialization, never a false
negative.
Tests: unit (dev acquires lock before guard read; coordinator does
not) + real-PG integration (same-agent serializes, different-agent
does not, releases on rollback).
* [F021] handle SSE transport errors so the intake composer isn't stuck
openStream registered listeners for the server-sent event kinds but not
the EventSource's own transport-level error. The 'error' kind IS in
LIVE_EVENT_KINDS, so a server-sent event:error (JSON MessageEvent) was
handled — but a dropped connection / dead session fires a plain Event
with NO data, which JSON.parse(undefined) swallowed in the try/catch,
so the stream 'stayed open' (EventSource loop-reconnected a session that
no longer existed) and isSending stayed true — the composer was
permanently disabled.
Fix: route the 'error' event by payload. A MessageEvent with string
data is a server-sent error → handleEvent (unchanged). A no-data Event
is a transport error → handleTransportError: clear streamingId/activity,
set isSending false, add a 'connection lost' error message, keep a
draft/batch preview up (so the human can still act on a proposed card)
else land on 'chatting', and close the dead stream so EventSource stops
loop-reconnecting.
Tests: renderHook + a jsdom EventSource double that fires a transport
error (plain Event, no data) vs a server-sent error (MessageEvent +
JSON). RED: transport error left isSending true; GREEN: resets to
false, surfaces the message, closes the stream. The server-sent-JSON
path is unchanged. Full panel suite (129) green; eslint/typecheck/prettier clean.
* [F081] Approve dialog: label notes required (>=20 chars), not optional
The CEO Approve dialog's notes label fell into the default branch
('Notes (optional') for the approve action, but approve actually
requires substantive notes >= 20 chars — enforced client-side
(toast error on < 20) and server-side. So the CEO was told 'optional'
and only learned the real requirement from a toast after hitting
submit with empty notes.
approve and start both require >= 20 chars; reject only requires a
reason. Collapse the label to two branches: reject -> 'Reason for
rejection (required)'; everything else (approve + start) ->
'Approval notes (required, >= 20 characters)'. The approve
placeholder now also signals intent ('Why this is ready to ship...').
Tests: render the queue, click Approve, assert the notes label says
'required' + '20' and does NOT say 'optional'. RED: label read
'Notes (optional)'; GREEN: 'Approval notes (required, >= 20
characters)'. eslint/typecheck/prettier clean.
* [F082] surface release-proposal query failures instead of silent hide
The card collapsed any non-404 backend failure (500 / network drop) onto
`!proposal` and returned null, so the CEO had no idea the release-proposal
endpoint was unreachable. Distinguish the cases: isError + a Retry affordance
vs the 404 null empty state that stays hidden. Mirrors PrReviewQueue.
* [F083] clear stale usage snapshot when /ws/system leaves connected
The hook synced wsState into the store but never dropped usageData when the
stream dropped, so on reconnect wsState flipped to "connected" before any
fresh USAGE_SNAPSHOT arrived and UsageOverviewPanel rendered the prior
session's totals/cost as if they were live. Clear usageData whenever state
leaves "connected" so the panel falls back to the polling summary until a
new snapshot lands. Connected->connected is a no-op clear skip.
* [F084] scope per-control disable to the in-flight mutation, not all
FeatureFlagsCard disabled every switch while any one flag toggle was pending,
and PlaybookReviewQueue disabled every row's Approve while any one approve was
pending — so the operator couldn't act on an independent control during a
slow round-trip. Gate the disable on the in-flight mutation's variables
(matching key / id) so only the control being mutated locks; the others stay
usable. The same-flag double-tap protection is preserved.
* [F085] reject submitting both project_id and product_id
validate() only checked 'at least one of project/product', so the dialog let
both be submitted together. The server silently lets product_id win at routing
and drops project_id, recording a misleading, never-used repo. Add a validator
that refuses the ambiguous submit with a clear error. The at-least-one rule and
the single-pick submit paths are unchanged.
* [F020] kanban: confirm admin-override drags that skip lifecycle preconditions
A drag on the operator kanban routes the status move through the admin
status-override, which bypasses the in-band lifecycle validator entirely.
That override is intentional (it's how an operator recovers a wedged task)
but it also let a careless drag skip material preconditions silently —
completing a task with no open PR, QA-bypassing, finishing docs on a task
whose docs aren't complete.
Leave the override intact but make the bypass explicit: compute the
preconditions the dragged move would skip (open PR, docs complete,
self-verified + commits + progress for submit-qa, visible non-terminal
subtasks for coordination-root targets) and, when any are skipped, hold the
move behind a confirmation dialog that lists exactly what's being skipped.
Precision over recall — only warn on what the panel can verify from the
task and its in-list children; never fabricate a 'satisfied' claim, and
stay silent on benign transitions that gate on nothing we can check.
The admin status-override capability is preserved (Confirm still fires it);
this only surfaces the bypass instead of letting it happen silently. Does
not touch the master-merge invariant — the board's updateTask is the
operator override, not the Main-PM merge path.
* [F086] prompter: restore parked cell content on project toggle off/on
rebuildCellWork appended a blank {summary:'', items:[]} entry for a newly-
selected cell, so toggling a cell's project OFF then back ON in the MegaTask
review card discarded the agent-authored per-cell summary/items — the entry
was dropped on toggle-off and re-added blank on toggle-on.
Park each draft's last per-cell content in client-only BatchProposal state
(parkedCellWork, keyed by draft index — never sent to the backend; confirm
ships only title/drafts/project_ids/route, and it ride-alongs into the
localStorage persist slice so the restore survives a reload mid-review).
rebuildCellWork gains an optional priorByCell map: a re-added cell with no
live entry restores its parked summary/items (with the new project_id) in-
stead of blanking; a live entry still wins over a stale parked copy so an
in-place edit is never regressed. parkCellWork is the pure merge seam
(prevParked seeds, live work overwrites) the setBatchDraftProjects updater
calls — kept pure so the updater stays a thin caller.
Tests: rebuildCellWork restore/blank-fallback/live-wins + parkCellWork
retain/overwrite/merge (6 new), 19 GREEN. eslint/typecheck/prettier clean.
No wire-payload change, no regression to the fill/drop/one-repo-per-cell
invariants.
* Updated domain
* [F087,F088] enforce panel token on live-chat bridges (Phase 5)
Add a CEO-bound, header-token-only gate (require_panel_token) at the route
level of the prompter_live + secretary_live bridges, which were the only
panel-facing API surface that ran unauthenticated. It mirrors the WS
_require_panel_token and _check_agent_auth_token contracts: in dev
(ROBOCO_AGENT_AUTH_REQUIRED unset) a missing token is allowed; a
presented-but-forged token is rejected even in dev; in prod nginx already
injects the CEO-signed X-Agent-Token on /api/ for GET + POST, so the SSE
stream (EventSource can't set headers) and the POSTs are now checked instead
of anonymous. Applied to start/stream/status/messages/stop on both routers;
preview_live_batch switched from CurrentAgentContext+noqa to the route-level
gate (genuinely auth-only). confirm/confirm-batch/re-interview keep
CurrentAgentContext (they use agent.identity). The container->relay /events
callback is intentionally left ungated (internal Docker network, opaque
session id) — gated by a test sentinel so Option B (spawn+SDK token wiring)
is a deliberate future decision. No panel/nginx/spawn/SDK changes; master
merge invariant untouched. 22 new TDD auth tests, 492 api tests green.
* [F089] honest WorkSession agent_id nullability across the read path
The work_sessions.agent_id column is nullable=True with ondelete=SET
NULL — deleting an agent nulls the FK on every session it ever held. The
ORM annotation lied (Mapped[UUID] non-optional), the converter papered
over the lie (typing_cast to a non-optional UUID), and the response
model rejected None outright (WorkSessionResponse.agent_id: UUID). A
session whose agent had been deleted crashed the GET endpoint with a
pydantic ValidationError instead of serializing agent_id: null.
Make the read path honest end-to-end:
- WorkSessionTable.agent_id: Mapped[UUID | None] (matches the column).
- WorkSessionResponse.agent_id: UUID | None (serializes null, no crash).
- session_to_response passes agent_id via typing_cast('UUID | None', ...)
to bridge SQLAlchemy's UUID[Any] to stdlib uuid.UUID while preserving
None-ness (the cast stays for the same mypy-plugin reason every other
field uses one; it no longer narrows away None).
WorkSessionCreate.agent_id stays UUID — at create time the claiming
agent is always known. The unused WorkSession pydantic read model is
left as-is (never materialized from a DB row). task.py:_needs_revision_dev
already None-guards ws.agent_id via to_python_uuid (returns None -> skip).
* [F090] drop auditor from write_roles on main-pm-board / board-private
The auditor is a silent, read-only observer on every channel, but the
channel catalog (roboco/foundation/policy/communications.py) listed it
in write_roles for main-pm-board and board-private 'for parity' with the
legacy CHANNEL_ACCESS table, while the actual silent-observer rule was
enforced only at the say/dm guard (content_actions._NO_COMMS_ROLES) and
PermissionService.can_write_channel's auditor short-circuit.
That left the catalog-only enforcement path — the HTTP messaging route
(messages.py send_message -> validate_channel_access) — authorizing an
auditor write that both the say/dm guard and PermissionService would
have blocked. A reader of the catalog also believed the auditor could
post to those channels, which is false.
Fix: remove Role.AUDITOR from write_roles on both channels (main-pm
+ board remain writers; ceo remains a writer on board-private). The
auditor stays in read_roles, so its silent read is unchanged. silent_roles
is left empty (matches the announcements precedent: auditor reads via
read_roles, not the silent bucket) — the DB seed and silent_observers
field are untouched.
Logical-regression check: the auditor's read access on both channels
is byte-for-byte preserved (still in read_roles, so validate_channel_access
read returns True via the direct list); the legitimate writers (main-pm,
product-owner, head-marketing, ceo) are untouched; CHANNEL_ACCESS is
derived from the spec so the foundation/seed drift tests self-adjust;
PermissionService.can_write_channel already short-circuited auditor to
False everywhere, so no behavior change there; AUDITOR_SILENT_ACCESS is
unchanged (auditor not added to silent_roles -> no DB silent_observers
change -> no group-access behavior change); the say/dm _NO_COMMS_ROLES
guard is unchanged. Tests: 3 new in test_channel_access.py — auditor
write on main-pm-board/board-private now raises ChannelAccessDeniedError
(RED before: returned True), auditor read still True, main-pm/ceo still
write.
* [F091] warn at spawn time when host grok auth.json is missing
GrokCliProvider._append_grok_auth_mount silently skipped the mount when
the host ~/.grok/auth.json was absent. The spawn still succeeded (docker
run returned 0 — the container was created), so the operator had no
spawn-time signal that the agent was doomed: the entrypoint's
`python -m roboco.llm.providers.grok_auth --check` backstop then
refused to start (exit 78) and the failure only surfaced later via the
container's log markers.
Fix: emit a spawn-time WARNING (module logger) naming the missing file
and the remediation (`grok login` on the host, or set
ROBOCO_HOST_GROK_DIR) when the mount is skipped. The spawn outcome is
unchanged — the container still starts and the existing exit-78 -> park
flow (F041) still catches it — but the operator now sees the missing
credential immediately instead of diagnosing a later exit-78.
Logical-regression check: the mount-present path is byte-for-byte
unchanged (auth.json exists -> the -v bind is appended, no warning); the
spawn still succeeds when auth is absent (no raise — the existing
test_grok_spawn_omits_auth_mount_when_absent still passes: no mount, no
crash); the exit-78 entrypoint backstop and the orchestrator's
exit-78-park handling (F041) are untouched; a module-level logger adds no
side effects. Tests: new test_grok_spawn_warns_when_auth_absent uses
caplog to assert a WARNING mentioning auth.json + `grok login` is
emitted on a missing-credential spawn (RED before: no warning; GREEN
after). 102 grok tests green; ruff/mypy clean.
* [F092] decode JWT exp when refresh omits expires_in
xAI's refresh-token response sometimes omits expires_in. Without it the
new access token kept the stale pre-refresh expires_at, so is_valid /
--check forever rejected a fresh token — and the refresh loop re-rotated
the single-use refresh token every tick, killing the credential (F006).
The access token is a JWT whose exp is the authoritative expiry: decode it
when expires_in is absent. Fallback to the documented ~6h TTL + a structlog
warning when the JWT exp is unreadable, so a fresh token is treated as live
instead of stale.
* [F093] serialize concurrent live-chat spawns under a per-agent lock
The intake and secretary agent ids are each a single fixed id, so two
concurrent start_intake_session / start_secretary_session calls raced on
the container name (docker run --name roboco-agent-<id>) and the
_instances[<id>] write: both passed the reap-prior check before either
registered, both ran docker run, and the last _instances write won,
orphaning the other container + its relay.
Add _intake_spawn_lock / _secretary_spawn_lock (asyncio.Lock) and wrap the
_spawn_intake_container / _spawn_secretary_container bodies so the second
start waits for the first to fully register before its own reap-prior check
runs. Distinct from self._lock (which stop_agent takes) to avoid a
reentrancy deadlock: the spawn body holds the spawn lock then calls
stop_agent (acquires self._lock) — lock order is always spawn_lock ->
self._lock, never the reverse.
* [F094] add a persistent-probe-failure escape hatch to provider parking
_on_probe_failure only incremented the failure counter and, at 10 failures,
sent a one-shot CEO notification. It never cleared the tracker, never gave
up, never fell back to time-expiry. _do_probe returns False for any non-2xx
AND any httpx error, so a permanently unreachable probe endpoint (removed
API key, network partition to the probe host, misconfigured base URL) kept
the provider parked forever — every agent on it gated by
_provider_spawn_parked, their tasks reaped to pending but the spawn gate
queuing every spawn, sitting pending forever. The only recovery was the
operator manually clearing the Redis key.
Past _PROBE_GIVE_UP_THRESHOLD (30) persistent failures, fall back to the
same time-expiry optimism the unprobeable-provider path uses (_do_probe
returns True when there is no probe URL): clear the park and resume parked
agents. If the provider is genuinely still down the real workload attempts
re-park via the 429/5xx path, so this is bounded burn — strictly better
than a silent forever-strand. Kept above the CEO-notify threshold (10) so
the operator still gets the notification first.
* [F095] orchestrator: parked-provider spawn short-circuits before expensive prepare
spawn_agent ran the full _prepare_agent_spawn (writes blueprint/settings/
briefing/MCP files, ensures the image, registers a STARTING instance) every
dispatcher tick only to bail at the after-prepare parked-provider check —
wasting all that file I/O while the provider stayed parked and leaving a
STARTING instance registered then downgraded to OFFLINE.
Move the parked check before _prepare_agent_spawn: resolve the route cheaply
via _resolve_agent_route (only provider_type is needed) and bail with a
minimal unregistered OFFLINE instance. The existing-running check stays
first (inside the lock) so a live agent is never replaced; a TOCTOU
re-check guards the unlocked window before prepare; the after-prepare
check is kept as a rare-race defense (a park landing during prepare).
* [F096] orchestrator: serialize fire-and-forget respawn persists per commit order
_persist_respawn_record is fire-and-forget per gate mutation; a respawn loop
fires count 1->2->3->4 in quick succession, scheduling one persist per
increment for the same (agent_slug, task_id). The ON CONFLICT DO UPDATE upsert
is row-level race-free, but the fire-and-forget tasks can still COMMIT out of
order: a slow stale persist (count=2) scheduled first can resolve AFTER a fast
fresh one (count=4) scheduled second, leaving the durable row at the stale low
count and re-burning the strike threshold on restart.
Fix: acquire self._respawn_persist_lock (new asyncio.Lock) as the FIRST await
in _persist_respawn_record, so acquisition order = task creation order (FIFO
ready queue) = logical schedule order, and commits land in that order. The
durable row always ends at the latest logical value. The lock lives in the bg
task, so the dispatcher hot path never blocks; persists are best-effort and
a slow one queuing the rest just delays the durable catch-up (in-memory record
stays authoritative).
* [F097] orchestrator: back off grok re-park retry_after within a rate-limit episode
_probe_target returns (None, {}) for grok — the grok CLI's xAI endpoint is
closed and the SuperGrok OIDC access token is not a valid bearer for the metered
api.x.ai, so a real probe would either no-op or strand grok parked forever.
_do_probe treats url-is-None as success (time-expiry optimism), so once the
60s retry_after passes the probe loop optimistically clears the grok park, a
cleared park dispatches a fresh grok agent that hits the still-active xAI 429,
exits 75, and re-parks — a flat ~90s crash-retry cycle for the whole xAI
rate-limit window (each cycle costs container startup + a rejected grok call).
Fix: track _grok_repark_count + _grok_last_park_at in _park_grok_rate_limited
and back the re-park retry_after off exponentially within one episode
(60 -> 120 -> 240 -> ... capped at 2**4 = ~16min cycle) so the churn dampens. A
gap past _GROK_REPARK_EPISODE_GAP_S (25min, > the capped cycle) means no re-park
for that long => the rate limit actually lifted => a fresh episode resets the
count to the base 60s, so recovery latency isn't penalized across episodes.
The first park in a fresh episode is unchanged at 60s.
* [F098] orchestrator: keep waiting record through a re-park during probe-success resume
resolve_wait deleted the waiting record (in-memory + durable) BEFORE calling
spawn_agent. A re-park in the window between the probe-success clear and the
spawn — the provider's rate limit lifts then immediately re-limits, or a second
provider limit lands — bails spawn with an OFFLINE instance (the parked-provider
short-circuit). Deleting the record first orphaned the agent: with no record
the probe-resume loop can never revive it and the spawn gate bails every tick,
so the agent is lost until the operator intervenes.
Fix: spawn first, then tear down the record only once a container actually
launched (instance.state == ACTIVE). On an OFFLINE bail the record stays so the
next probe-success re-attempts the resume. On a spawn EXCEPTION the record is
torn down + re-raised so the probe loop doesn't keep re-resuming a task that
moved to a different state (e.g. readiness refused -> task auto-blocked) —
matching the pre-fix behavior where the record was deleted before the spawn.
* [F099] wire pr_pass/pr_fail self_review block in the spec gate
The pr_pass/pr_fail ActionSpecs carry self_review_block=True, but
_gate_preflight never populated Context.original_developer_slug, and
actor_slug was read off agent.slug — which GatewayAgentView does not
carry, so it was always None in production. The block was structurally
dormant: a reviewer who was also the original developer of the
assembled PR could pass (or fail) their own work. The service-layer
_validate_not_self_review backstop only covers qa/documenter, not
pr_reviewer, so the spec gate is the only defense.
Set actor_slug=str(reviewer_agent_id) (GatewayAgentView has no slug,
so the UUID is the identity) and original_developer_slug from the
original_developer marker (a UUID stored as a string). Both resolve to
UUID strings, so the spec's string-equality comparison fires when the
reviewer IS the recorded original developer.
The marker is never set on assembled coordination tasks (only on
dev-leaf tasks at QA/doc claim), so the block stays dormant by design
in production — but the gate is now correctly wired to fire if the
marker were ever set to the reviewer. Zero production behavior change;
the dormant-in-production state is pinned by the no-marker test.
* [F100] atomic Redis probe-failure counter via server-side Lua
increment_probe_failures / reset_probe_failures did a non-atomic
get_state (GET) -> mutate -> set (SET) in Python. A concurrent
activate() re-park writes a FRESH episode blob (probe_failures: 0 +
fresh activated_at / retry_after / affected_agents / kind); if the
stale increment's SET landed after the fresh activate's SET, the stale
blob overwrote the fresh episode metadata AND un-reset the counter
(clobbering the new episode).
Redis single-threads a Lua EVAL, so a server-side read-modify-write
is indivisible: activate's SET is serialized entirely before or after
the script, never interleaved between the script's GET and SET. The
two scripts mutate ONLY probe_failures, so every other episode field
survives the bump. activate stays a single atomic SET (a fresh episode
resetting the counter to 0 is correct semantics).
* [F101] enforce PR-open state gate on gateway open_pr (parity with HTTP path)
* [F102] make project_id mandatory on pr_target (close cross-repo pr_number collision)
* [F103] make project_id mandatory on close_pull_request (close cross-repo collision)
* [F104] fail-closed on conventions resolution errors (block gate no longer silently disabled)
* [F106] compound (timestamp, id) keyset cursor for message pagination
get_messages used strict timestamp inequalities with a non-deterministic
order_by(timestamp.desc()), so equal-timestamp messages were cut by limit
on one page and excluded (strict < T / > T) from the next — they vanished
across pages. Bundled the (timestamp, id) pair into a MessageCursor dataclass
so the next page resumes exactly past the cursor's id at the shared
timestamp (or_: strictly-older OR same-timestamp-smaller-id for before; the
mirror for after), with a deterministic order_by(timestamp.desc(), id.desc())
so the last-item cursor is unambiguous. id is None for a legacy timestamp-
only cursor (strict inequality, prior behavior). The route builds cursors
from the flat before/before_id + after/after_id HTTP params; the schema now
carries the tie-breaker ids. Also clears PLR0913 (cursors replace the
before_id/after_id params).
* [F107] defer Redis bus publish until DB commit (no phantom notifications)
deliver() and _persist_and_deliver() ran inside the caller's open
transaction: the notification row was flushed but not committed, yet
NOTIFICATION_SENT was published to the Redis bus immediately. A commit
failure (DB hiccup, constraint, asyncpg error) rolled the row back while
connected WebSocket clients had already received a push for an id that
no longer existed — a phantom notification (notify_get -> NotFoundError).
Added a deferred-publish (transactional-outbox) helper: defer_bus_publish
enqueues the event on session.info and registers one-shot after_commit /
after_rollback listeners on session.sync_session the first time it is
called for that session. On commit, the after_commit listener schedules
the async drain via asyncio.create_task on the running loop (the listener
fires synchronously inside await AsyncSession.commit, so the loop is
active); the task handles are stashed on the session so callers/tests can
await them. On rollback, after_rollback drops the pending queue — a
rolled-back txn emits nothing. deliver() now builds the per-recipient
events up front (data materialized to strings, so deferral is safe even
if the ORM object later expires) and defers each; the delivered_at DB
marker stays in-tx (rolls back with the row). The bus block stays
best-effort (try/except + log) so a bus-init failure never propagates or
rolls back the notification row — matching the prior inline semantics.
This fixes every deliver/_persist_and_deliver caller at once (the two
cited in F107 plus the orchestrator + task.py deliver sites), since they
all commit the session afterward (the deferred publish fires on that
commit; the row is durable by the time the event goes out).
* [F108] atomic replace_chunks: single-txn delete+insert closes reindex race
* [F109] playbook curation status guards: approve/reject draft-only, archive approved-only
* [F110] draft slug TOCTOU: catch IntegrityError on flush -> ConflictError (no 500)
* [F113] collapse WorkSession creation to the validated service path
_create_work_session_if_needed constructed WorkSessionTable directly,
duplicating WorkSessionService.create's validation (existing-active
check, single-active-per-task supersede, project/task existence). The
two sites had drifted. Route through WorkSessionService.create instead,
mapping ConflictError to the idempotent 'if needed' None. Remove the
now-dead _supersede_other_active_sessions (create's
supersede_active_sessions_for_task replaces it).
Fix three pre-existing RED tests surfaced by the sweep (all confirmed
failing on the F110 commit before this change):
- test_fail_qa_work_session_fallback_excludes_qa_session: inserted two
ACTIVE work_sessions per task, violating uq_work_sessions_one_active
_per_task (migration 047). The QA session is now ABANDONED — still in
the fallback query's result set (the query filters by task_id +
agent_id, not status), so the exclude filter (agent_id != qa_id) is
still exercised and the dev is resolved.
- test_ceo_reject_routes_coordination_task_to_main_pm /
test_ceo_reject_routes_batch_umbrella_to_main_pm: ceo_reject emits an
audit row keyed to CEO_AGENT_ID, but the tests never seeded the CEO
agent row (fk_audit_log_agent_id_agents). Seed the CEO agent (get-or-
create, mirroring test_ceo_reject_writes_handoff_journal).
* [F114] single-claimant guard on pr_gate_claim
pr_gate_claim delegated straight to _qa_or_doc_claim, which overwrites
claimed_by / active_claimant_id with no single-claimant check. Two
reviewers race-claiming the same awaiting_pr_review task would
last-write-wins overwrite the first claim, and the first reviewer's
subsequent pr_pass / pr_fail would actor-mismatch against the new owner
(wasting a review cycle). The orchestrator's gate dispatcher already
prevents double-reviewer-dispatch in normal flow (one task -> one team
-> one reviewer + is_agent_active + per-tick spawned set), so the race
is only reachable via direct concurrent API calls (defense-in-depth).
Add a role-aware single-claimant guard in pr_gate_claim: lock the row
FOR UPDATE (serialize concurrent claims, mirroring the dev claim path),
then refuse only when the task is already actively claimed by a
DIFFERENT PR-reviewer. The gate task is owned by the PM at entry
(submit_for_review does not clear ownership, unlike submit_for_qa), so
the guard must distinguish a PM/dev owner — which the first reviewer
legitimately overclaims — from a competing reviewer claim; checking the
existing claimant's role (pr_reviewer) does exactly that. A re-claim by
the same reviewer is idempotent (skipped by the != check). The gateway
claim_gate_review handler already maps a None return to a clean
invalid_state envelope ('it may already be claimed; give_me_work for
the next'), so no gateway change is needed.
TDD: 3 integration tests in test_task_service_basics.py — reject a second
reviewer race-claim (returns None, first claim intact), allow the first
reviewer when the PM owns the root (regression guard for the
PM-owns-at-entry model), idempotent re-claim by the same reviewer.
Confirmed the reject test RED first (race-claim succeeded, overwriting
reviewer1).
* [F115] sample monorepo per (repo,workflow)/(repo,command) not per repo
The CI-watch and dep-update loaders collapsed a monorepo's cell-projects
to one canonical entry per repo (slug-sorted-first), so a repo whose cells
each carry their OWN ci_watch_workflow / dep_update_command had only the
canonical cell's workflow/command sampled — a red on another cell's
workflow or drift on another cell's lockfile was missed (under-count).
Refactor the shared one-per-repo collapse into _projects_one_per_key, keyed
by repo identity for external-PR discovery (unchanged: one review per PR per
repo), by (repo, effective workflow) for CI-watch, and by (repo, command)
for dep-update. Each distinct workflow/command is now sampled once; the
engines' per-git_url fix-task dedup still prevents duplicate fix tasks for
the same repo. _projects_one_per_repo now delegates to _projects_one_per_key.
key_fn uses a string annotation (Callable lives under TYPE_CHECKING, like
the existing Coroutine/Iterable annotations at lines 4193/5279).
* [R115] originate ci_watch/dep_update fix tasks as PLANNING coordination roots
The Main-PM-code-impossibility guard (commit e202ce39, Thread 4 of this
audit) made team=MAIN_PM + task_type=CODE impossible — a Main PM coordinates,
it does not write code. But the ci_watch and dep_update engines still
originated their fix tasks as task_type=TaskType.CODE assigned to main-pm,
so task_svc.create raised MAIN_PM_NO_CODE and NO fix task was ever opened
— a regression introduced by the earlier audit fix (confirmed: the engine
tests pass at e202ce39~1 and fail at HEAD).
Mirror the hardened self_heal_engine precedent (self_heal_engine.py:197)
which already uses task_type=TaskType.PLANNING for its Main-PM coordination
root with an explicit 'decompose the fix and delegate the code work to a
cell dev — the Main PM does not write the fix itself' description. Both
engines now originate PLANNING coordination roots with matching delegation
guidance in the description + acceptance criteria. confirmed_by_human
stays True for both (they ride the normal delivery flow without the CEO
gate, unlike self-heal — intentional per the architecture).
The dedupe/open-cap queries (list_open_ci_watch_tasks /
list_open_dep_update_tasks) key on source + non-terminal status + git_url,
NOT task_type, so the type change does not break dedup (still one open fix
task per repo).
The two source-test fixtures (test_ci_watch_source / test_dep_update_source)
created CODE+MAIN_PM tasks directly to exercise the listing queries — same
guard violation; switched to PLANNING (the queries assert on source/status,
not task_type, so the fixture type matches the engines' corrected type).
* [F116] hold the read-clone lock across the dep-probe local clone
dry_upgrade_changes_lockfile called ensure_read_clone (which syncs the
read clone under the _meta-conventions lock then releases it) and ran
'git clone --local --no-hardlinks <read_clone>' OUTSIDE the lock. A
concurrent ensure_read_clone -> _sync_read_clone (fetch + hard-reset to
origin's default branch) could mutate the read clone's working tree /
object db mid-clone, racing the clone and producing an inconsistent or
failing probe.
Split _probe_lockfile_change into _clone_local_into (the local clone,
run under the read-clone lock) + _probe_lockfile_on_clone (the upgrade +
git status, run without the lock on the now-independent copy). The probe
acquires _ensure_lock_for(slug, '_meta-conventions') — the same lock
ensure_read_clone syncs under — and holds it only for the clone step; the
upgrade operates on the full --no-hardlinks copy and never touches the
read clone, so the lock is released before it to avoid blocking
conventions reads for the upgrade duration.
The tiny gap between ensure_read_clone releasing the lock and the probe
re-acquiring it is safe: any concurrent _sync_read_clone completes under
the lock before the probe acquires, so the clone reads a stable state.
* [F117] stop the orchestrator in lifespan shutdown BEFORE closing the DB
The lifespan shutdown closed OptimalService + the DB, and only THEN did
bootstrap's finally block call orchestrator.stop() — so stop() ran with
the DB already closed. stop() drains fire-and-forget _bg_tasks writes
(respawn_tracker upserts, audit-log rows) and stop_agent finalizes work
sessions / agent state, all needing the DB still open; closing it first
silently dropped those final writes (the durable PM-respawn counter's
last few strikes, the metrics-bearing audit trail tail).
Move orchestrator.stop() into the lifespan shutdown path, BEFORE
close_optimal_service + close_db, guarded by a new get_orchestrator_or_none()
safe accessor (no crash when no orchestrator is wired — tests,
skip_orchestrator). bootstrap's finally-block stop() becomes an idempotent
safety net: stop() gains a _stopped flag (getattr-guarded so __new__-
constructed test instances still stop) so the double-call is a clean no-op,
not a re-stop of already-stopped agents / re-drain of an empty bg set.
* [F118] coerce a lone-string where_to_look into a list
where_to_look is a list-typed handoff field like consequences/next_steps
but was the only one NOT in the _wrap_scalar_in_list field_validator. A
well-intentioned where_to_look='src/api/' 422'd at the route with no
remediation envelope, and the agent's retry loop tripped the do-server
circuit breaker — the exact failure mode the other list fields were
hardened against. Add it to the mode='before' validator so a lone string
is wrapped into a one-element list before type coercion.
* [F119] sender reaps dead sockets on send error instead of waiting for receive idle timeout
* [F120] release a stopped agent's claimed task immediately on budget-kill/shutdown
* [F122] name the already-open PR in submit_up's None-state remediate
submit_up's create_pr pre-side-effect opens the cell→root PR BEFORE
submit_for_review runs (its pr_created gate requires it — lifecycle.py:1338-1343).
When submit_for_review returns None (a concurrent state change raced the task
out of in_progress between the precondition gate and the composed action), the
old remediate ('check task state — must be in_progress with PR ready') hid
that the PR was already open on GitHub — an orphaned external artifact the PM
could not reconcile. Mirror submit_root's F016 None-envelope remediate: name
the open PR, point the PM at re-fetch + reconcile, and note create_pr is
idempotent so a re-issue re-attaches to the existing PR (no duplicate). Pure
message improvement — zero behavior change; reordering is off the table
(create_pr must precede the pr_created gate).
* [F124] re-check dependency state before releasing a dependency-blocked claim
The unmet_dependency guard read dependency state via an unlocked SELECT, then
fired release_dependency_blocked_claim (a state mutation: claimed/in_progress
-> pending, clears branch_name, abandons WorkSession) as a side-effect BEFORE
returning the rejection. An upstream dependency that reached a terminal state
(completed/cancelled) in the microseconds between the read and the release left
the task NEEDLESSLY released — its branch cleared + WorkSession abandoned +
assignee bounced, only to be re-dispatched + re-claimed when the dependency-
completion re-dispatch fired a moment later.
Re-check unmet_dependency_ids immediately before the release and skip it
(returning None — proceed) when the upstream just completed. Dependencies are
monotonic (unmet -> met only; terminal states never reopen), so a fresh read
that now finds them met stays met: safe to proceed without releasing. The
'still unmet' path is byte-for-byte the prior behavior (no regression). The
cross-task residual window (upstream completes between the re-check and the
release) is not closable by a row lock on the dependent, but the re-check
narrows the window from [first read -> release] to [re-check -> release], and
in the common case the first read already sees met (no guard fires). No
committed-work loss either way (a dependency-blocked task has none; the branch
ref + commits persist across the branch_name clear).
* [F125] serialize same-parent delegate via per-parent advisory lock
The delegate sibling-dedup guard read the parent's existing subtasks via an
unlocked get_subtasks SELECT (the dedup read) then created the subtask (the
write) with no DB serialization between them. Two concurrent delegate calls
for the same parent (PM re-delegating while a reaper re-dispatches, or two
orchestrator ticks racing) each read a duplicate-free sibling set, each passed
the dedup guard, and each created a subtask — the parent got the duplicate the
guard exists to prevent (the smoke-run runaway pattern).
Fix: a PostgreSQL transaction-scoped advisory lock keyed by the parent task
id (seed 1, disjoint from the per-agent claim lock's seed 0), acquired at the
top of the delegate body before the first get_subtasks read (the briefing
context read AND the dedup sibling read) and held through create_subtask's
flush + the outer request commit. The second concurrent same-parent delegate
blocks until the first commits, then its dedup read sees the committed
sibling and is rejected.
Per-PARENT (not per-agent): a coordinator PM legitimately delegates many
subtasks under one parent in quick succession and plans many roots in
parallel — a per-agent lock would serialize all of a PM's delegates and
regress the PM coordinator concurrency feature. The per-parent lock
serializes only same-parent delegates (the dedup invariant is per-parent)
and leaves different parents untouched.
TDD: red-first ordering test (lock acquired before first get_subtasks read
and before create_subtask) + no-regression test (create still runs).
* [F127] per-task advisory lock prevents open_pr milestone double-emit
open_pr's idempotent re-entry guard (pr_number is not None) read t.pr_number
from an unlocked fetch. Two concurrent same-task open_pr calls (the
alive-but-unresponsive respawn race) both fetched pr_number=None, both passed
the guard, both ran the runner (GitHub 422 ensures one PR), and both reached
_record_milestone_progress -> a double-emitted 70% 'opened PR #N' entry.
Fix: acquire_task_lock (pg_advisory_xact_lock, seed 2) before the fetch, held
through the runner + milestone + request commit. The second concurrent call
blocks until the first commits, then its fetch sees the committed pr_number
and the idempotent guard short-circuits without re-emitting. Per-task (single-
active-task guard means same-task concurrent open_pr is only the bug case).
* [F128] require active claim on explicit-task content posts
_verify_explicit_task_ownership checked assigned_to, which is stale
across a reap/handoff (persists until reassignment; active_claimant_id is
cleared on release). A reaped agent could keep posting say/dm/note to its
former task. Add the active-claimant check when assigned_to == caller;
assigned_to=None keep its existing allow (read-side inspection between
reassignments uses evidence, which has its own ownership path).
Existing 'active owner' test mocks passed assigned_to=agent_id without
active_claimant_id; production sets both together on claim, so the mocks
were incomplete. Updated to set both — realistic, not a behavior change.
* [F129,F130] harden quality gate _run_one exit status + timeout cleanup
F129: _run_one returned 'proc.returncode or 0', masking a None returncode
(communicate returned without a recorded exit code — process killed
out-of-band) as 0 / success. Treat None as a non-zero failure (fail-closed).
F130: on timeout, _run_one killed the subprocess but never awaited wait()
— communicate() was cancelled so it never closed the stdout/stderr pipes,
leaving a transient zombie + leaked FDs. Await wait() after kill() to reap
the process and close the transports.
* [F132] timeout the conventions validator + reap on hang
_run_conventions_validator awaited proc.communicate() with no timeout —
a hung subprocess (tree-sitter deadlock, huge repo) hung the
i_am_done/pr_pass gate forever and orphaned the python subprocess on
orchestrator restart. Wrap communicate() in wait_for(120s); on timeout
kill+wait the proc and fail closed (could_not_run=True → block gate
refuses the submit), matching the validator's own fail-loud philosophy.
* [F135] re-check activity before sweeper closes a session (TOCTOU)
sweep_timed_out_sessions read last_activity_at once at the candidate
SELECT, then closed. A message landing in that window refreshed
last_activity_at in the DB, but the sweeper closed on its stale in-memory
value — closing a just-used session. Re-read last_activity_at fresh right
before the close and skip if the session is no longer timed out.
* [F136] cancel startup indexing task on OptimalService.close()
close() cancelled only the periodic update task, then cleared the plugins.
The startup _indexing_task (background auto-index, slow Ollama / large repo)
could still be mid-flight at shutdown and write against closed/cleared
plugins. Cancel and await _indexing_task FIRST (its tail starts the periodic
task, so ordering also prevents a late periodic spawn), then the periodic
task, then clear plugins.
* [F139] scope active_task_owns_branch to the polled project
active_task_owns_branch did an unscoped WHERE branch_name = ? — a cross-project
branch_name collision (UUID-derived 8-char prefixes, theoretical) made the
internal-PR reviewer skip the WRONG project's PR (project A's leftover PR
skipped because project B happened to have an active task with the same
branch). Pass project_id (in scope at the orchestrator call site) and add
TaskTable.project_id == project_id to the WHERE. Correct for single-project
tasks and MegaTask multi-repo batches alike: each root-subtask carries its own
project_id matching its own repo, so a branch on project A's repo is owned
only by a task whose project_id == A.
* [sweep] strip Fxxx audit-ID tokens + trim bloated comments/docstrings + add behavior-change docs
Post-audit sweep over the 135 audit-fix commits since 19a474d3:
1. Stripped every # Fxxx: audit-ID token from comments AND every Fxxx token
from docstring openings across 211 blocks / ~626 lines. The CEO flagged
these twice: audit-issue IDs in code confuse future devs/agents. The
descriptive text is preserved; only the Fxxx token is removed (and bloated
narrative blocks trimmed to 1-3 lines keeping the one non-obvious invariant).
2. Trimmed bloated comments/docstrings to the concise standard (1-3 lines).
3. Added missing behavior-change docs for the audit-fix batch: prompts/roles
(documenter, pr_reviewer, qa), user-facing docs (api auth, websockets,
agent-gateway, megatask, merge-model, task-lifecycle, grok, resilience,
conventions, panel, security, troubleshooting), and the RAG corpus (cell-pm,
main-pm, pr-reviewer, qa roles; conventions; messaging-tools; escalation;
megatask; task-claiming workflows).
Comment/docstring/prose ONLY — zero code-line edits (verified: the diff
contains no def/class/return/if/for/await/assignment/call lines). Gates green:
ruff format + ruff check clean, mypy clean on roboco/. The only pytest failures
are the pre-existing sync_branch tracing-decision gap (B1, 250be5c2) — not
sweep-caused and tracked separately.
* [fix] register sync_branch in VERBS_WITHOUT_TRACING
sync_branch (B1, 250be5c2) is a git-only rebase+force-push verb (composes=(),
no DB transition, side_effects=()) but was never registered in the tracing
parity tables, so test_every_intent_verb_has_a_tracing_decision failed.
Mirrors open_pr: a mechanical git op with inline preconditions (ownership),
no journal/plan rationale required.
* chore(release): 0.14.0
* [fix] resolve 16 mypy errors across 9 test files (make quality gate)
type-clean the test files so make quality (mypy roboco/ tests/) is green:
- Any-typed locals for the two TypeError-asserting scoping tests (bypass
the required-arg check without getattr/ruff B009)
- Any-typed view for the shutdown-drain _drain_bg_tasks override (bypass
mypy method-assign without setattr/ruff B010)
- cast("uuid.UUID", ...) / cast("UUID", ...) for SQLAlchemy UUID[Any]
returns (TC006-quoted), config=None for AgentInstance stubs, None-narrowed
await_args, Iterator return on a yielding fixture, UUID annotation on the
_task helper. No type:ignore / noqa.
* [docs] regenerate lifecycle artifacts for sync_branch + branch-keyed submit_root gate
The committed artifacts were stale: lifecycle.py grew the sync_branch verb
(B1) and the branch-keyed submit_root gate description (B2/B3) but the
generated markdown/json were never regenerated. make foundation-check
enforces artifact==generator(lifecycle.py); regenerating restores that.
No source change — pure generator output.
* [refactor] reduce xenon C-rank blocks to A (behavior-preserving)
Extract helpers / flatten conditionals in 11 blocks that rated C(11)+
under xenon --max-absolute B, dropping pr_gate.py module rank B->A in
the process. Pure move-and-call refactors: each extracted helper holds
the original logic verbatim and the caller delegates to it; no control
flow, return values, or side effects changed.
Sites: validators._extract_strs, sequencing.dev_task_collision_edges,
evidence_builder.build_task_handoff, intake_driver._coerce_draft,
task.claim_task_for_agent (2 guards), prompter.create_task_from_draft
(validate+assignee), pr_gate._gate_decision (3 helpers),
orchestrator._handle_stopped_container + _reap_with_service,
_impl._create_subtask_from_inputs + complete.
_impl helper returns tuple[TaskNature, list[str]] to preserve mypy
narrowing of acceptance_criteria at the TaskCreateRequest site.
Also fix vulture: rename unused __aexit__ param tb->_tb in
test_conventions_cache_put.py (was hidden while xenon short-circuited
the gate).
* [security] bash-guard uv run --active deny + CodeQL path-traversal fixes
Fix 1 (be-dev-1 brick prevention): bash-guard now denies 'uv run --active'
and 'uv run'/'uvx' against /app targets. In the agent container
VIRTUAL_ENV=/app/.venv is baked globally, so 'uv run --active' always
resolves onto the image-baked MCP-gateway venv and uv rebuilds it,
deleting /app/.venv/bin and bricking every MCP server spawn. Bare
'uv run' (workspace .venv, cwd-relative) is untouched.
CodeQL fixes:
- docs.py: replace bypassable '..' substring guard with a
resolve-and-contain helper (_resolve_contained_path). An absolute
path made pathlib reset (base / '/etc/passwd' == '/etc/passwd'),
letting read_doc/delete_doc reach arbitrary files. Applied to both
sinks.
- orchestrator.py: _safe_agent_path_segment at the spawn_agent
chokepoint (rejects traversal-shaped agent_id before any fs op) and
inside _remove_container (slug guard before the log-dir mkdir,
defense-in-depth).
- agent_sdk/server.py: /usage/sync transcript_path now resolved and
contained under ROBOCO_TRANSCRIPT_DIR with a .jsonl suffix requirement
(was Path(raw) — unauthenticated endpoint could stat arbitrary files).
TDD RED->GREEN across all four; make quality green (4890 passed).
* [fix] enum-parity gate: drop false-green mask, skip empty/unmigrated DB
The foundation-check gate ran the enum verifier behind
`|| echo "(skipped — postgres unreachable)"`, which swallows ANY
non-zero exit — including real drift — and prints 'All quality gates
passed'. On a host with a dockerized but empty/unmigrated `roboco` DB
(0 tables: the agentrole/team enum types don't exist), the verifier
connected, found every foundation value 'missing', exited 1, and the
mask relabeled it 'skipped' → false-green.
Fix:
- scripts/verify_postgres_enums.py: move skip semantics INTO the script.
Distinguish unreachable (skip, exit 0), DB-not-migrated/both-enum-types-
absent (skip, exit 0), real drift (exit 1), match (exit 0). Extract
pure enum_drift + should_skip_for_unmigrated helpers + a type_exists
probe so an empty DB is 'no migrated target', not drift.
- Makefile: drop the `|| echo` mask — real drift now fails the gate.
TDD RED->GREEN (10 tests); make quality green (10906 passed).
* [security] docs path guard: reject '.'/empty segments for clean 400
_resolve_contained_path used an '..' substring ban, which (a) left rel='.'
passing the guard — read_doc/delete_doc then got the base DIRECTORY itself
and raised IsADirectoryError (500) instead of a clean ValidationError, and
(b) false-rejected legit filenames containing '..' like 'v1..v2.md'.
Replace the substring ban with a raw-segment check (rel.split('/')) that
rejects any '.', '..', or empty segment. Path(rel).parts was the wrong tool
— pathlib collapses '.' and empty segments on 3.13, hiding them. The split
check catches '.' / 'a/./b' / 'a//b' / '..' / 'a/../b' while allowing
'v1..v2.md' ('..' inside a filename, no bad segment). The post-resolve
parents-containment check (the real defense) is unchanged.
TDD RED->GREEN (4 new tests); make quality green (10910 passed).
Follow-up to the CodeQL path-traversal review: the two CodeQL 'High' alerts
on this guard are false-positives-on-the-fix (resolve-and-contain already
contains the bypass); this hardening closes the one genuine low residual
(rel='.' -> 500) the review surfaced, which CodeQL did not flag.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
{"sessionId":"ab5768a5-9435-4c8f-ba8e-e57e202f2e22","pid":23656,"procStart":"Fri May 8 00:44:40 2026","acquiredAt":1778357552778}
|
||||
+1
-1
@@ -80,7 +80,7 @@ ROBOCO_REDIS_DB=0
|
||||
# For docker compose use the container name (roboco-ollama); locally, localhost.
|
||||
ROBOCO_OLLAMA_BASE_URL=http://localhost:11434
|
||||
ROBOCO_LOCAL_LLM_BASE_URL=http://localhost:11434/v1
|
||||
ROBOCO_LOCAL_LLM_MODEL=glm-5:cloud
|
||||
ROBOCO_LOCAL_LLM_MODEL=glm-5.2:cloud
|
||||
ROBOCO_DEFAULT_EMBEDDING_MODEL=qwen3-embedding:0.6b
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Claude Code
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/agents/*
|
||||
.claude/commands/*
|
||||
.claude/hooks/*
|
||||
|
||||
@@ -6,10 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.14.0] - 2026-06-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-level MegaTask sequencing — a batch now runs in the right order, structurally, not by luck.** A MegaTask that spans several cells (and may mix per-cell projects from different products or OSS libraries) can now be routed per-cell without standing up a Product for it: each root-subtask carries an ad-hoc per-cell project map (`task_cell_projects`, migration 052, with a panel per-cell project picker), then cuts `feature/main_pm/{root}` and opens a root→master PR per repo exactly like a Product fan-out. On top of that map the dependency graph now carries the sequencing edges that collision and migration ordering need, enforced in the DAG rather than hoped for in the prompt: a dev task declares its collision surface (`intends_to_touch` globs, `adds_migration`, `touches_shared`, migration 046) on `delegate`; file-overlap serializes (more-important first), migration-adders chain serially, and a shared-surface edit runs after each non-shared task it overlaps — independent tasks still run in parallel — with cell-task wave chains and a by-osmosis edge completing the multi-level chain. Two new gate-level verbs close the "agent started out of order / drifted behind base" hole that no amount of prompting fixed: `sync_branch` (a dev gate verb that rebases the task branch onto its base and force-pushes, through the gate — raw git stays denied), and an `i_am_done` behind-base submit gate that structurally refuses to submit a task whose branch has fallen behind its base. Single-task intake is byte-for-byte unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A whole-codebase logic-gap audit — roughly 140 concurrency, scoping, signal, and lifecycle gaps fixed.** The dominant body of this release. The categories: **cross-repo PR scoping** — `pr_number` and `branch_name` are per-repo but were stored and looked up unscoped, so two tasks on different repos sharing a PR number could merge the wrong repo's PR or skip the org's own in-flight integration PR; every PR-merge and branch-ownership lookup is now `project_id`-scoped, and `close_pull_request` / `pr_target` make `project_id` mandatory. **Advisory locks closing TOCTOU races** — per-agent on claim, per-parent on `delegate`, per-task on `open_pr` (preventing a milestone double-emit), plus an atomic server-side Redis probe-failure counter and a single-transaction `replace_chunks` (delete+insert) closing a reindex race. **Audit-row transactionality** — status-transition audit rows and the rework counter are written in-session in the caller's transaction (the old fire-and-forget path is gone), so the audit trail can't diverge from the state change. **Signal gaps** — `pr_fail` now pushes the reviewer's issues to the owning cell PM (the re-submit loop where a PM respawned into `needs_revision` blind and re-submitted the same PR is closed), and `fail_qa` routes a `needs_revision` dev task back to the dev, never the pool. **Asyncio cleanup** — `OptimalService.close()` cancels its startup indexing task before the periodic task and the plugin clear, so it can't write against closed plugins. **Conventions standard** — the validator now times out and reaps on hang, and the gate fails closed on resolution errors (a broken standard can no longer silently disable the gate). **WebSocket** — fan-out is non-blocking with finally-disconnect, idle-timeout, and dead-socket reaping on send error. **Orchestrator runtime** — it drains its fire-and-forget background set on shutdown and stops in lifespan shutdown before closing the DB; the probe-resume loop actually revives parked agents; the grok auth token is refreshed before expiry and parked (not crash-retried) when missing. **Release executor** — every subprocess (git/make/gh/clone) is deadline-bounded and it fails closed on a git add/commit before push. Dozens more across org-memory (private-leak closures, playbook index/unindex as a post-commit step so the RAG corpus never leads the status transaction), the reaper, the provider-park/overload break, and the live-chat bridges. The full categorized tracker lives in `docs/internal` (gitignored).
|
||||
|
||||
- **The 2026-06-27 live-run meltdown cluster — root-caused and closed.** A run hit several compounding wedges at once, each TDD-fixed and verified green: a `main_pm` assigned a `code`-typed task is a structural impossibility (a coordinator PM does no coding) and is now hard-rejected at the gate; `cell_pm_complete` resolved a merge by global `pr_number` and merged the wrong repo's PR (closed by the cross-repo `project_id` scoping above); `submit_root` re-submitted an unchanged PR into an infinite `pr_fail` loop (now hard-gated); `fail_qa` bounced a dev task to the pool instead of back to the dev; a `note(scope='handoff')` with an empty section crashed the note path and tripped a PM respawn loop; the MegaTask four-layer hierarchy (umbrella → root → cell → dev) hit a depth cap sized for three layers; and the durable respawn counter's persist raced under fire-and-forget (an atomic upsert closes it).
|
||||
|
||||
- **The CEO, prompter, and secretary can no longer be spawned as agent containers.** These are human-only roles (the CEO is the human; the prompter is the on-demand intake interviewer; the secretary is the on-demand chief-of-staff) with no delivery lifecycle, yet a `_dispatch_a2a_work` path that spawned any notification target — plus an `_is_agent_active('ceo')` that always returned false — could nonetheless launch them and burn a container on a role that has no work to do. A chokepoint in `spawn_agent` plus a dispatcher skip on human-only assignees closes it at both the spawn and the dispatch layer.
|
||||
|
||||
- **The PM-respawn loop breaker now survives an orchestrator restart.** The circuit breaker that stops RoboCo from respawning the same PM on the same wedged task forever (`_pm_respawn_tracker`) lived only in memory, so a deploy/crash/OOM reset a task's strike count to 1 and re-burned the whole threshold — four full agent spawns × container cost — against the still-broken task before the gate fired again. The counter is now write-through-persisted to a new `respawn_tracker` table (migration 051) on every mutation and restored at startup, validated against live tasks so a stale counter can't resurrect against a fixed one. Best-effort and inert when empty (a DB hiccup degrades to exactly the prior in-memory behaviour); it can only ever suppress a spawn, never manufacture one.
|
||||
|
||||
- **The `mypy` / `ruff` quality gate is green again, with no `type: ignore` suppressions in `tests/`.** A round of pre-existing type errors in the test suite (ORM `<row>.id` passed where `uuid.UUID` was expected, missing annotations, `None`-attribute accesses) and every remaining `# type: ignore` in `tests/` are cleared, so `make quality` passes cleanly and the no-suppression convention holds.
|
||||
|
||||
### Security
|
||||
|
||||
- **Phase 5 — the live-chat bridges now enforce the CEO-signed panel token.** The intake (`prompter_live`) and secretary (`secretary_live`) panel-facing endpoints were the only API surface that ran unauthenticated at the route layer — their SSE stream (`GET /stream`) carried no identity at all (browser `EventSource` cannot set headers), and the start / status / messages / stop endpoints took no auth dependency. They now require the existing CEO-signed HMAC panel token (`require_panel_token`, the HTTP sibling of the WS `_require_panel_token`): nginx already injects `X-Agent-Token` on `/api/` in prod, so the browser never holds the secret and no panel/nginx change was needed; in dev a missing token is allowed but a forged one is still rejected. This closes the last ungated panel-facing surface using the existing scheme verbatim — no new auth, no client changes.
|
||||
|
||||
- **Agent-token gates and secret-scrubbing hardened across the API.** The HMAC agent-token gate is now enforced on the `do` content routes and the WebSocket streams (not just the a2a message routes); the orchestrator signs its own `X-Agent-Token` on self-API calls; 422 error logs are scrubbed of secrets; the a2a / dashboard / orchestrator routes are gated; and SSE runs one session per query. With the Phase 5 bridge gate above, no panel-facing or inter-agent HTTP surface is now unauthenticated when auth is required.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The local LLM was bumped to `glm-5.2` and the Ollama fleet defaults swapped off minimax.** The in-house RAG / hybrid-retrieval model and the default fleet model assignment move to `glm-5.2:cloud`; a stale minimax default that no longer matched the running fleet is cleared.
|
||||
|
||||
## [0.13.0] - 2026-06-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -80,7 +80,7 @@ pnpm test
|
||||
| Cache/Queue | Redis |
|
||||
| Container Runtime | Docker + Docker Compose |
|
||||
| Cloud LLM | Claude API (claude-opus-4-6) + xAI Grok (official `grok` CLI, SuperGrok subscription) |
|
||||
| Local LLM | Ollama (glm-5:cloud for RAG/hybrid retrieval) |
|
||||
| Local LLM | Ollama (glm-5.2:cloud for RAG/hybrid retrieval) |
|
||||
| Embeddings | qwen3-embedding:0.6b (1024 dim) |
|
||||
| Frontend | Next.js 16 + TypeScript + Tailwind + Radix UI (in `panel/`) |
|
||||
| Edge / Proxy | nginx (single entry point on port 3000) |
|
||||
@@ -341,7 +341,7 @@ Each agent gets a **spawn manifest** at `/app/tool-manifest.json` listing the ve
|
||||
|
||||
| Role | Flow verbs (beyond `i_am_idle`) |
|
||||
|---------------|--------------------------------------------------------------------------------------------------|
|
||||
| developer | `give_me_work`, `i_will_work_on`, `open_pr`, `i_am_done`, `i_am_blocked`, `resume`, `unclaim` |
|
||||
| developer | `give_me_work`, `i_will_work_on`, `open_pr`, `i_am_done`, `i_am_blocked`, `resume`, `sync_branch`, `unclaim` |
|
||||
| qa | `give_me_work`, `claim_review`, `pass_review`, `fail_review`, `i_am_blocked`, `resume`, `unclaim` |
|
||||
| documenter | `give_me_work`, `claim_doc_task`, `i_documented`, `i_am_blocked`, `resume`, `unclaim` |
|
||||
| cell_pm | `give_me_work`, `i_will_plan`, `delegate`, `complete`, `submit_up`, `triage`, `unblock`, `escalate_up`, `reassign`, `resume`, `unclaim` |
|
||||
@@ -464,7 +464,7 @@ ROBOCO_RAG_USE_HYBRID_SEARCH=true
|
||||
|
||||
# AI/LLM
|
||||
ROBOCO_DEFAULT_EMBEDDING_MODEL=qwen3-embedding:0.6b
|
||||
ROBOCO_LOCAL_LLM_MODEL=glm-5:cloud
|
||||
ROBOCO_LOCAL_LLM_MODEL=glm-5.2:cloud
|
||||
ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1
|
||||
ROBOCO_OLLAMA_BASE_URL=http://roboco-ollama:11434
|
||||
```
|
||||
@@ -524,7 +524,7 @@ The startup order is critical due to dependencies:
|
||||
postgres ──┐
|
||||
redis ─────┼──> ollama ──> ollama-init ──> orchestrator ──> panel ──> nginx
|
||||
│ │ │
|
||||
│ │ └── Pulls qwen3-embedding:0.6b, glm-5:cloud
|
||||
│ │ └── Pulls qwen3-embedding:0.6b, glm-5.2:cloud
|
||||
│ └── Healthcheck: ollama list
|
||||
└── Healthcheck: pg_isready, redis-cli ping
|
||||
```
|
||||
|
||||
@@ -537,8 +537,8 @@ foundation-check:
|
||||
@$(MAKE) lifecycle
|
||||
@git diff --exit-code -- docs/rag/lifecycle panel/lib/lifecycle.json agents/prompts/_generated/lifecycle-*.md \
|
||||
|| (echo "Lifecycle artifacts are out of date. Run 'make lifecycle' and commit the diff." && exit 1)
|
||||
@echo "==> postgres enum parity (offline-skip if no DB)"
|
||||
uv run python scripts/verify_postgres_enums.py || echo " (skipped — postgres unreachable)"
|
||||
@echo "==> postgres enum parity (skip if no migrated DB)"
|
||||
uv run python scripts/verify_postgres_enums.py
|
||||
@echo "All foundation drift checks passed."
|
||||
|
||||
# Backwards-compatible alias — prior CI / scripts called `ci-lifecycle-check`.
|
||||
|
||||
@@ -129,7 +129,7 @@ Choose the registry and version with two env vars (defaults shown):
|
||||
|
||||
```bash
|
||||
ROBOCO_REGISTRY=ghcr.io/rennf93 # or docker.io/renzof93
|
||||
ROBOCO_VERSION=latest # or a pinned release, e.g. 0.13.0
|
||||
ROBOCO_VERSION=latest # or a pinned release, e.g. 0.14.0
|
||||
```
|
||||
|
||||
The orchestrator spawns the matching pre-built agent images on demand — no build toolchain or source compile on your host.
|
||||
@@ -178,7 +178,7 @@ ROBOCO_WORKSPACE_AUTO_CLONE=true
|
||||
|
||||
# RAG/LLM
|
||||
ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1
|
||||
ROBOCO_LOCAL_LLM_MODEL=glm-5:cloud
|
||||
ROBOCO_LOCAL_LLM_MODEL=glm-5.2:cloud
|
||||
|
||||
# Feature flags (default-off unless noted; toggle from Settings → Feature Flags)
|
||||
ROBOCO_CONVENTIONS_ENABLED=false # per-project architectural conventions standard
|
||||
@@ -283,7 +283,7 @@ uv run mypy roboco/
|
||||
| Cache/Queue | Redis |
|
||||
| RAG Engine | in-house (asyncpg + pgvector, hybrid retrieval) |
|
||||
| Embeddings | qwen3-embedding:0.6b (Ollama) |
|
||||
| Local LLM | Ollama (glm-5:cloud) |
|
||||
| Local LLM | Ollama (glm-5.2:cloud) |
|
||||
| Cloud LLM | Claude API (Anthropic) + xAI Grok (official `grok` CLI, SuperGrok subscription) |
|
||||
| Package Manager | uv |
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `approve_playbook` | `approve_playbook(playbook_id: UUID)` |
|
||||
| `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` |
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| Verb | Body schema |
|
||||
|------|-------------|
|
||||
| `complete` | `complete(task_id: UUID, notes: str)` |
|
||||
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None)` |
|
||||
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, intends_to_touch: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
|
||||
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
|
||||
| `give_me_work` | `give_me_work()` |
|
||||
| `i_am_idle` | `i_am_idle()` |
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None, steps: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` |
|
||||
| `open_pr` | `open_pr(task_id: UUID)` |
|
||||
| `resume` | `resume(task_id: UUID)` |
|
||||
| `sync_branch` | `sync_branch(task_id: UUID)` |
|
||||
| `unclaim` | `unclaim(task_id: UUID)` |
|
||||
|
||||
### Content (do) tools
|
||||
@@ -21,7 +22,7 @@
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `commit` | `commit(message: str, files: list[str] | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `commit` | `commit(message: str, files: list[str] | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
|
||||
@@ -10,4 +10,5 @@ other verb will be rejected with a Decision telling you the right one.
|
||||
- **i_will_work_on**: Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation.
|
||||
- **open_pr**: Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done.
|
||||
- **resume**: Resume a paused task you own. paused -> in_progress.
|
||||
- **sync_branch**: Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base — e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned — resolve by hand, commit, then sync_branch again.
|
||||
- **unclaim**: Voluntarily release a claim back to pending. The work-in-progress branch is preserved.
|
||||
|
||||
@@ -11,7 +11,7 @@ other verb will be rejected with a Decision telling you the right one.
|
||||
- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks.
|
||||
- **i_will_plan**: PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.
|
||||
- **resume**: Resume a paused task you own. paused -> in_progress.
|
||||
- **submit_root**: Main PM opens the root→master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. Only for code roots; branchless coordination roots skip the gate and complete directly.
|
||||
- **submit_root**: Main PM opens the root→master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed — a Main-PM root is planning-typed, never code.
|
||||
- **triage**: List actionable tasks in your scope.
|
||||
- **triage_all**: List actionable tasks across all teams (Main PM only).
|
||||
- **unblock**: PM unblocks a blocked task; restores pre-block state.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| Verb | Body schema |
|
||||
|------|-------------|
|
||||
| `complete` | `complete(task_id: UUID, notes: str)` |
|
||||
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None)` |
|
||||
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, intends_to_touch: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
|
||||
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
|
||||
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
|
||||
| `give_me_work` | `give_me_work()` |
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| `give_me_work` | `give_me_work()` |
|
||||
| `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str, blocker_type: str | None = None, what_needed: str | None = None)` |
|
||||
| `i_am_idle` | `i_am_idle()` |
|
||||
| `pass_review` | `pass_review(task_id: UUID, notes: str, ac_verdicts: list[str] | None = None)` |
|
||||
| `pass_review` | `pass_review(task_id: UUID, notes: str, ac_verdicts: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None)` |
|
||||
| `resume` | `resume(task_id: UUID)` |
|
||||
| `unclaim` | `unclaim(task_id: UUID)` |
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
|
||||
@@ -23,6 +23,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None, steps: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` |
|
||||
| `open_pr` | `open_pr(task_id: UUID)` |
|
||||
| `resume` | `resume(task_id: UUID)` |
|
||||
| `sync_branch` | `sync_branch(task_id: UUID)` |
|
||||
| `unclaim` | `unclaim(task_id: UUID)` |
|
||||
|
||||
### Content (do) tools
|
||||
@@ -30,7 +31,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `commit` | `commit(message: str, files: list[str] | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
@@ -54,7 +55,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `give_me_work` | `give_me_work()` |
|
||||
| `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str, blocker_type: str | None = None, what_needed: str | None = None)` |
|
||||
| `i_am_idle` | `i_am_idle()` |
|
||||
| `pass_review` | `pass_review(task_id: UUID, notes: str, ac_verdicts: list[str] | None = None)` |
|
||||
| `pass_review` | `pass_review(task_id: UUID, notes: str, ac_verdicts: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None)` |
|
||||
| `resume` | `resume(task_id: UUID)` |
|
||||
| `unclaim` | `unclaim(task_id: UUID)` |
|
||||
|
||||
@@ -62,7 +63,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
@@ -92,7 +93,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `commit` | `commit(message: str, files: list[str] | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
@@ -112,7 +113,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| Verb | Body schema |
|
||||
|------|-------------|
|
||||
| `complete` | `complete(task_id: UUID, notes: str)` |
|
||||
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None)` |
|
||||
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, intends_to_touch: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
|
||||
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
|
||||
| `give_me_work` | `give_me_work()` |
|
||||
| `i_am_idle` | `i_am_idle()` |
|
||||
@@ -128,7 +129,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
@@ -150,7 +151,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| Verb | Body schema |
|
||||
|------|-------------|
|
||||
| `complete` | `complete(task_id: UUID, notes: str)` |
|
||||
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None)` |
|
||||
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, intends_to_touch: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
|
||||
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
|
||||
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
|
||||
| `give_me_work` | `give_me_work()` |
|
||||
@@ -167,7 +168,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
@@ -196,7 +197,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
@@ -223,7 +224,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
@@ -249,7 +250,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `approve_playbook` | `approve_playbook(playbook_id: UUID)` |
|
||||
| `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` |
|
||||
@@ -276,7 +277,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
|
||||
@@ -25,7 +25,7 @@ When the briefing carries `company_goals`, let the charter guide how you scope a
|
||||
|---|---|---|
|
||||
| `give_me_work()` | Returns your highest-priority task (your own pending PM task, or a subtask in `awaiting_pm_review` for you to merge). | None. |
|
||||
| `i_will_plan(task_id, plan, approach, sub_tasks, technical_considerations?, risks?, open_questions?)` | Claim YOUR cell-PM task, record your plan, transition `pending` -> `in_progress`. Always call this before `delegate`. **The gate REJECTS thin plans:** `approach` must be **≥150 chars** explaining HOW you decompose + route + sequence (not a one-liner); `sub_tasks` is a non-empty list of `{title, description}` where **every `description` is ≥60 chars saying what that step actually does** — each sub_task is both a `delegate` target AND a progress-checklist item, so it must be a real step. Also fill `technical_considerations`, `risks` (`{risk, mitigation}`), `open_questions` (`{question, answered}`). Example sub_task: `{"title": "Add timestamp comment to README", "description": "be-dev-1 edits README.md, prepends an HTML comment <!-- smoke-test: <date> --> above the H1, leaving the rest of the file untouched"}`. Empty/thin values are rejected, not just an empty Plan tab. | Task assigned to you; task in `pending`/`needs_revision`. |
|
||||
| `delegate(parent_task_id, title, description, assigned_to, team, task_type, nature, acceptance_criteria, estimated_complexity, covers_parent_criteria?)` | Create a subtask under your cell-PM task and assign it to a dev in your cell. `nature` ∈ `technical`/`non_technical`. `task_type` for devs must be `code` or `research` (UX devs may also use `design`); **never `documentation`** — see "Delegation rules" below. `covers_parent_criteria` is the list of YOUR criterion ids (from the briefing's `parent_ac_coverage`) this subtask satisfies — see "Coverage" below. Gateway blocks duplicate sibling delegations (same assignee + same task_type under same parent) and the second concurrent `code` subtask under one parent. | Parent claimed by you and `in_progress`; assignee is a dev slug in your cell. |
|
||||
| `delegate(parent_task_id, title, description, assigned_to, team, task_type, nature, acceptance_criteria, estimated_complexity, covers_parent_criteria?, intends_to_touch?, adds_migration?, touches_shared?, depends_on?)` | Create a subtask under your cell-PM task and assign it to a dev in your cell. `nature` ∈ `technical`/`non_technical`. `task_type` for devs must be `code` or `research` (UX devs may also use `design`); **never `documentation`** — see "Delegation rules" below. `covers_parent_criteria` is the list of YOUR criterion ids (from the briefing's `parent_ac_coverage`) this subtask satisfies — see "Coverage" below. `intends_to_touch`/`adds_migration`/`touches_shared` are the **collision surface** — see "Collision surface" below; fill them on every `code` subtask so the system can sequence sibling dev tasks that touch the same files into a conflict-free order. `depends_on` is a list of sibling subtask IDs this one must wait on (cross-reroute gate). Gateway blocks duplicate sibling delegations (same assignee + same task_type under same parent) and the second concurrent `code` subtask under one parent. | Parent claimed by you and `in_progress`; assignee is a dev slug in your cell. |
|
||||
| `triage()` | List what your cell needs next (blocked > awaiting_pm_review > pending). | None. |
|
||||
| `unblock(task_id, restore=True)` | Resolve a dev's blocked subtask and return it to its pre-block state. | Subtask is in your cell. |
|
||||
| `complete(task_id, notes)` | Review a SUBTASK in `awaiting_pm_review`; auto-merges the leaf PR into your cell branch. | All descendants of the subtask terminal; PR open and mergeable. |
|
||||
@@ -138,6 +138,18 @@ A criterion that fits none of the three is dropped scope — you under-decompose
|
||||
This is the same discipline the `submit_up` checklist enforces at the end — pulled to the front, where a gap costs one extra `delegate` instead of a full cell revision loop.
|
||||
|
||||
**The gateway now backs this up.** Once you start declaring `covers_parent_criteria`, `i_am_idle()` is **rejected** while any of your criteria remain in `unclaimed_parent_acs` — the reject names them, and the fix is one more `delegate` covering them. Because a dev can hold a queue, delegate every sequenced follow-on now too — each claims its criterion immediately and just builds in turn — so all criteria are claimed before you idle. Check `parent_ac_coverage` in the response after each `delegate`: when `unclaimed_parent_acs` is empty, your decomposition covers the task and you may idle. (Mapping coverage is opt-in by design — if you never pass `covers_parent_criteria`, the gate stays silent — but declaring it is the expected practice and the only way the cell self-checks for dropped scope.)
|
||||
|
||||
### Collision surface — declare it on every `code` subtask so siblings sequence (READ THIS BEFORE DELEGATING)
|
||||
|
||||
When two of your devs touch the **same files** in parallel, the second one's branch starts from a base that doesn't have the first one's merged work — the PR can't merge cleanly and the first dev's changes go missing from the second (the 2026-06-27 out-of-order dev-task break). The system prevents that by sequencing sibling dev tasks that collide into a conflict-free order (the dependency-gate holds the later one until the earlier lands) — but it can only do that from the **collision surface** you declare on each `delegate`:
|
||||
|
||||
- `intends_to_touch` — the files/dirs this subtask will modify (globs are fine, e.g. `["roboco/services/git.py", "roboco/api/routes/**"]`). Read the brief and the code you can see; name the real paths.
|
||||
- `adds_migration` — `true` if it adds a DB migration / new column (migration-adders chain serially per project).
|
||||
- `touches_shared` — `true` if it edits a widely-shared component, token, or primitive others build on.
|
||||
- `depends_on` — a list of sibling subtask IDs this one must wait on, when you already know the order (the analyzer also derives edges from file overlap; use this for explicit ordering the analyzer can't infer, e.g. a logical dependency with no file overlap).
|
||||
|
||||
**Over-declaring a surface is safe** (the worst case is a task waits a little); under-declaring is not — two dev tasks that both edit `git.py` with no declared overlap run in parallel and collide. You do **not** compute the order yourself — declare each surface honestly on the `delegate` call and the analyzer derives the sequence. Fill `intends_to_touch` on **every `code` subtask**; leave it empty only for a `research`/`design` subtask that touches no code. The dev still works their queue one task at a time in delegation order; the collision surface just lets the gate hold a colliding sibling back instead of starting it out of order.
|
||||
|
||||
7. `i_am_idle()` -> wait. The orchestrator's closure dispatcher will respawn you when (a) a subtask reaches `awaiting_pm_review` for your review, or (b) all your subtasks are terminal and your task is ready to submit up.
|
||||
8. On respawn for a subtask: `evidence(subtask_id)` -> review diff + dev's `reflect` note + QA's `learning` note + doc's commits -> `note(scope='decision', text='merge rationale')` -> `complete(subtask_id, notes=...)`. The leaf PR auto-merges into your cell branch.
|
||||
9. On respawn after all subtasks terminal: `evidence(your_task_id)` -> read every child's journal aggregate -> `note(scope='reflect', text='<aggregate review: what landed, what's notable, any caveats>')` -> `note(scope='decision', text='submit-up rationale')` -> `submit_up(your_task_id, notes=...)`. Main PM takes over.
|
||||
@@ -166,7 +178,7 @@ The PM journal is what makes the cell legible to Main PM and CEO. Skipping entri
|
||||
|
||||
## When a branch is behind its base
|
||||
|
||||
A task branch is brought current with its base automatically when it is CLAIMED — neither you nor your devs have a rebase, pull, or merge verb. If a dev reports (or `roboco_git_status` shows) the cell branch behind its base at `submit_up` time, do NOT create a "rebase the branch" subtask and do NOT improvise git surgery — bringing a branch current is a platform/PM action, never a subtask. Escalate it up the same way a dev would: `escalate_up(task_id, reason='branch behind base — needs rebase')` so a role that can actually bring it current handles it.
|
||||
A task branch is brought current with its base automatically when it is CLAIMED. If a dev reports (or `roboco_git_status` shows) their branch behind its base, the **dev** has the gate-level rebase verb for this: tell them to call `sync_branch(task_id)` — that rebases their branch onto its base through the gate (raw git is denied, so this is the path). Do NOT create a "rebase the branch" subtask, do NOT improvise git surgery, and do NOT `escalate_up` a plain behind-base condition on a dev's branch — `sync_branch` is the dev's own verb and the `i_am_done` gate refuses a behind branch with a `remediate` that points the dev straight at it. (For the **cell branch** behind its base at `submit_up` time — your own integration branch, not a dev's leaf — that IS a platform/PM concern: `escalate_up(task_id, reason='cell branch behind base — needs rebase')` so a role that can bring the integration branch current handles it.)
|
||||
|
||||
## Channels
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ You write code; you do not coordinate. If you find yourself thinking "let me als
|
||||
| `i_am_blocked(task_id, reason, blocker_type?, what_needed?)` | Records the blocker, escalates to your PM, idles you. `blocker_type` ∈ `external` (waiting on a 3rd-party API/service), `internal` (a teammate or process), `question` (need clarification), `dependency` (waiting on another task). `what_needed` is a one-sentence concrete unblock request. Both fields are pre-gateway parity — PMs triage by class. | Task is yours and active. |
|
||||
| `unclaim(task_id)` | Release this claim back to pending. Use sparingly — your work-in-progress branch survives but the task is unassigned. | Task assigned to you and in claimed/in_progress. |
|
||||
| `resume(task_id)` | Resume a paused task. Transitions paused → in_progress. | Task assigned to you and in paused state. |
|
||||
| `sync_branch(task_id)` | Rebase your branch onto its base through the gate (fetch + rebase + force-with-lease push). Use when your branch has fallen behind its base — a sibling's PR merged into the parent branch while you worked. No lifecycle transition; after it returns, keep editing + `commit`, then `open_pr` / `i_am_done` as normal. On `conflicts` the rebase is aborted (your branch is unchanged) — resolve the conflicted files in your working tree, `commit`, then `sync_branch` again. | Task is yours and carries a `branch_name` (claimed/in_progress). |
|
||||
| `note(text, scope?)` | Journal entry (`scope ∈ note|decision|reflect|learning|struggle`). | None. |
|
||||
| `say(channel, text)` / `dm(recipient, text, skill?)` | Channel post / direct message. | Channel slug without `#`. |
|
||||
| `evidence(task_id)` | Fetches PR diff, commits, files changed, dev summary. | None. |
|
||||
@@ -122,7 +123,7 @@ If a finding is a genuine false positive, clear it by committing a `waiver` in `
|
||||
|
||||
## When your branch is behind its base
|
||||
|
||||
Your task branch is brought current with its base automatically when you CLAIM it — you have NO rebase, pull, or merge verb at the agent layer. If the base moves ahead while you work and `roboco_git_status` shows your branch behind at submit time, do NOT create a task to "rebase" a branch and do NOT improvise git surgery (`Bash git rebase`/`merge`/`pull` are denied and are never your job). Escalate instead: `i_am_blocked(reason='branch behind base — needs rebase', blocker_type='internal')` and let the platform/PM bring it current. (Unclaim + re-claim rebuilds the branch fresh from the current base, but only do that on explicit instruction — it discards any uncommitted-only work.)
|
||||
Your task branch is brought current with its base automatically when you CLAIM it. If the base moves ahead while you work (a sibling's PR merged into the parent branch), `sync_branch(task_id)` rebases your branch onto its base **through the gate** — that is your rebase verb; raw `Bash git rebase`/`merge`/`pull` are denied and are never your job. Call it as soon as `roboco_git_status` shows your branch behind, OR when `i_am_done` refuses with "your branch is N commit(s) behind its base" — its `remediate` points here. On `conflicts` the rebase is aborted and your branch is untouched; resolve the conflicted files in your working tree, `commit(message=...)`, then `sync_branch(task_id)` again. Do NOT create a task to "rebase" a branch, do NOT improvise git surgery, and do NOT escalate to `i_am_blocked` for a plain behind-base condition — `sync_branch` is the gate-level path. (Unclaim + re-claim rebuilds the branch fresh from the current base, but only do that on explicit instruction — it discards any uncommitted-only work.)
|
||||
|
||||
## Channels
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ You do NOT re-implement the developer's work. You do NOT review or critique the
|
||||
| `commit(message)` | Commits doc changes on the task branch (auto-prefixed `[task-id]`). | Task in `in_progress`; on the task branch. |
|
||||
| `pr_update(task_id, title?, body?, reviewers?)` | Update the PR's title, body, or reviewer list (e.g. to add a doc-relevant summary). At least one field must be set. **Do NOT bash-shim `gh pr edit`** — use this verb. | Task has `pr_number`; you are claimant on the doc task. |
|
||||
| `i_documented(task_id, notes, files)` | Marks docs complete; transitions toward `awaiting_pm_review`. | At least one doc file in `files`; `notes` >= 20 chars. |
|
||||
| `i_am_blocked(task_id, reason, blocker_type?, what_needed?)` | Record a blocker, escalate to your PM, idle. `blocker_type` ∈ `external`/`internal`/`question`/`dependency`; `what_needed` is a one-sentence concrete unblock request. Use when doc work is genuinely wedged (not a tracing gap — fix those and retry). | Task is yours and active. |
|
||||
| `unclaim(task_id)` | Release this claim back to pending. Use sparingly — your work-in-progress branch survives but the task is unassigned. | Task assigned to you and in claimed/in_progress. |
|
||||
| `resume(task_id)` | Resume a paused task. Transitions paused → in_progress. | Task assigned to you and in paused state. |
|
||||
| `note(text, scope?)` | Journal entry. | None. |
|
||||
@@ -96,4 +97,4 @@ Errors include `error`, `message`, `remediate`, `missing`. Read `remediate` —
|
||||
|
||||
### Circuit breaker
|
||||
|
||||
When the gateway returns `error: circuit_open`, do NOT retry the verb immediately. The breaker tracks repeated rejections of the same verb (same kind, e.g. `tracing_gap` or `incomplete_input`) within 60 seconds. Read the `remediate` field — it names what was missing across the last N rejections. Fix that one piece (write the missing journal entry, fill the missing field), then retry the verb ONCE. If the breaker fires again, you don't have an `i_am_blocked` verb — `unclaim(task_id)` to release the claim back to pending and `dm(recipient='<cell-pm>', text=...)` with the rejection details so the PM knows it's a real wedge, not a transient error.
|
||||
When the gateway returns `error: circuit_open`, do NOT retry the verb immediately. The breaker tracks repeated rejections of the same verb (same kind, e.g. `tracing_gap` or `incomplete_input`) within 60 seconds. Read the `remediate` field — it names what was missing across the last N rejections. Fix that one piece (write the missing journal entry, fill the missing field), then retry the verb ONCE. If the breaker fires again, `i_am_blocked(task_id, reason='<rejection details>')` to escalate the wedge to your PM (or `unclaim(task_id)` if you'd rather release the claim back to pending) and `dm(recipient='<cell-pm>', text=...)` with the rejection details so the PM knows it's a real wedge, not a transient error.
|
||||
|
||||
@@ -167,7 +167,7 @@ You are the integration layer between Cells and CEO. Your journal is what tells
|
||||
|
||||
## When a branch is behind its base
|
||||
|
||||
A task branch is brought current with its base automatically when it is CLAIMED — there is no rebase, pull, or merge verb anywhere at the agent layer. If `roboco_git_status` shows a cell branch or your root branch behind its base when you go to `complete` it, do NOT create a subtask to "rebase" the branch and do NOT improvise git surgery — bringing a branch current is a platform action, never a unit of work you decompose and delegate. Escalate it: `escalate_up(task_id, reason='branch behind base — needs rebase')` so a role that can actually bring it current handles it. A "rebase subtask" is always a mistake.
|
||||
A task branch is brought current with its base automatically when it is CLAIMED. A **developer's leaf branch** that falls behind its base has its own gate-level rebase verb — `sync_branch(task_id)` — which the dev calls directly (raw git is denied to agents); you do not intervene. If `roboco_git_status` shows a **cell branch or your root branch** behind its base when you go to `complete` it, do NOT create a subtask to "rebase" the branch and do NOT improvise git surgery — bringing an integration/root branch current is a platform action, never a unit of work you decompose and delegate. Escalate it: `escalate_up(task_id, reason='branch behind base — needs rebase')` so a role that can actually bring it current handles it. A "rebase subtask" is always a mistake.
|
||||
|
||||
## Channels
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ The PR is from an outside contributor: its code is **untrusted**. Until a human
|
||||
| `give_me_work()` | Returns an external-PR review task or `idle`. | None. |
|
||||
| `claim_pr_review(task_id)` | Claims the review task and starts it. `pending → claimed → in_progress`. Returns the PR diff inline. | Task is an `external_pr` review task in `pending`. |
|
||||
| `post_pr_review(task_id, body, findings=[...])` | Posts ONE complete change-request and finishes the review. `in_progress → completed`. `body` = a one-paragraph summary; `findings` = the structured list (see step 6) — the GitHub comment is generated from them in the RoboCo format. | Task claimed by you; findings cover every relevant criterion. |
|
||||
| `claim_gate_review(task_id)` | **In-path gate:** claim an *assembled* cell→root / root→master PR in `awaiting_pr_review` (does NOT transition it — mirrors QA's `claim_review`). Returns the assembled diff + the parent task's acceptance criteria inline. | Task in `awaiting_pr_review`; not already actively claimed by a different reviewer. |
|
||||
| `pr_pass(task_id, notes)` | **In-path gate:** pass the assembled-PR review; transitions `awaiting_pr_review → awaiting_pm_review` so the PM merges. | Task claimed by you via `claim_gate_review`; `notes` >= 20 chars. |
|
||||
| `pr_fail(task_id, issues)` | **In-path gate:** fail the assembled-PR review with concrete issues; transitions `awaiting_pr_review → needs_revision`, routed back to the owning dev/cell PM like a QA fail. | Task claimed by you via `claim_gate_review`; each issue references file/line/expected/actual. |
|
||||
| `note(text, scope?)` | Journal entry. Record your reasoning. | None. |
|
||||
| `evidence(task_id)` | Re-fetch the PR diff if you need more detail. | None. |
|
||||
| `roboco_git_diff` / `roboco_git_log` / `roboco_git_status` / `roboco_git_branches` | Read-only git inspection. | None. |
|
||||
@@ -46,6 +49,19 @@ The PR is from an outside contributor: its code is **untrusted**. Until a human
|
||||
- ❌ Being lax on the architectural standard. Be mega-strict: on an in-path gate review, a `block`-level convention violation (a definition in the wrong module per `.roboco/conventions.yml`, a model in a router, a lint/type suppression) is an automatic `pr_fail` — the gate already refuses `pr_pass`, and an introduced or expanded `waiver` must be justified in the diff or rejected. Hold placement and house-style to the same bar as correctness.
|
||||
- ❌ Letting a non-modular assembled change through. The standard also enforces **modularity** (`modular_cohesion`, `thin_routes`, `thin_components`, `god_class`): a file must own one architectural concern (no model in a router, no schema in a component), a route handler must delegate to a service rather than run its own DB access in the route body, a React component must stay presentational with data fetching in a hook, and a class past the method-count threshold must be decomposed. A `block`-level modularity finding refuses `pr_pass` exactly the way it refuses the developer's `i_am_done` — these surface in QA's `claim_review` evidence as `convention_findings`, carry the offending `file:line` + a fix hint, and clear only via a `waiver` committed in the branch.
|
||||
|
||||
## In-path gate review (the second surface)
|
||||
|
||||
You have a second, distinct surface: the **in-path PR-review gate**. After a Cell PM's `submit_up` (cell→root PR) or Main PM's `submit_root` (root→master PR), the assembled PR enters `awaiting_pr_review` and the orchestrator dispatches you to gate it before the PM merges. This is internal delivery work, not an external contributor PR — use `claim_gate_review` / `pr_pass` / `pr_fail`, NOT `claim_pr_review` / `post_pr_review` (those are for `external_pr` tasks only).
|
||||
|
||||
1. `give_me_work()` → a task in `awaiting_pr_review`.
|
||||
2. `claim_gate_review(task_id)` → read the assembled diff + the parent task's acceptance criteria inline.
|
||||
3. Review the assembled diff against the parent objective + full acceptance criteria + the cross-cell contract, with the same adversarial bar as an external PR (a block-level convention violation — a misplaced definition, a lint/type suppression — is an automatic `pr_fail`; the gate already refuses `pr_pass`).
|
||||
4. `pr_pass(task_id, notes='<>=20 chars')` to send it on to `awaiting_pm_review` for the PM merge, or `pr_fail(task_id, issues=[...])` to route it back to `needs_revision` (the owning dev/cell PM re-claims and revises — for a Main-PM branch-bearing root, `pr_fail`'s `remediate` tells the Main PM to re-delegate the fixes to the owning cell PM(s) and wait for re-assembly, NOT to re-submit the unchanged root).
|
||||
|
||||
**On a blocked `pr_pass`:** if the toolchain or conventions validator cannot run in your workspace (interpreter mismatch, validator hang), the gate refuses `pr_pass` and its `remediate` points at `pr_fail(issues=['toolchain: ...'])` — your reject lever, since you have no `i_am_blocked` verb. Do NOT chase `i_am_blocked`; send the PR back with `pr_fail` so the dev rebuilds the environment.
|
||||
|
||||
**Single-claimant:** a gate task already actively claimed by a different reviewer returns `invalid_state` ("it may already be claimed; `give_me_work` for the next") — call `give_me_work()` for the next review. A re-claim by the same reviewer is idempotent.
|
||||
|
||||
## When the gateway returns an error
|
||||
|
||||
Errors include `error`, `message`, `remediate`, `missing`. Read `remediate` — it names the literal next call. Fix that one piece and retry the same verb.
|
||||
|
||||
@@ -89,6 +89,12 @@ When you are scoped to a **MegaTask**, the CEO wants several distinct tasks work
|
||||
|
||||
Over-declaring a surface is safe (the worst case is a task waits a little); under-declaring is not. You do **not** compute the order yourself — declare each surface honestly and the analyzer derives the waves. Present all the tasks in prose first (a short paragraph each), then call `propose_batch` once. If the conversation changes the set, call it again with the full updated batch.
|
||||
|
||||
**Each draft in a MegaTask becomes a Main-PM coordination root-subtask** — the Main PM coordinates it and delegates the actual code to the cells; the Main PM never writes the code itself. So draft each root-subtask as the coordination it is, not as code the Main PM will implement:
|
||||
|
||||
- `task_type`: `"planning"` (the system coerces `code`→`planning` for a Main-PM root anyway, but draft it correctly — a Main PM task is never `code`).
|
||||
- `acceptance_criteria`: **coordination-level**, not code-level. Write criteria the Main PM can satisfy by delegating and assembling — e.g. *"the chart-first Metrics refactor is delegated to fe-pm and lands on a cell PR"*, *"the cell→root PR is assembled and passes the in-path review gate"*, *"all cell subtasks are terminal and the root→master PR is merged"*. Do **not** write code-level criteria on the root — specific file paths (`frontend/src/components/timeseries-chart.tsx`), "lint/build clean", exact APIs — those belong on the **cell/dev subtasks** the Main PM delegates to, not on the root. A root carrying code-level ACs is the structural mismatch behind the 2026-06-27 meltdown (the gate reviewed code the Main PM couldn't fix → an infinite re-submit loop).
|
||||
- `the_work` still names the per-cell breakdown (which cell does what) — that's the delegation plan the Main PM executes; it's correct here because it *is* the coordination spec.
|
||||
|
||||
## What happens after you call `propose_draft`
|
||||
|
||||
A draft card appears for the human with three choices: **Keep chatting**, **Board review & Start**, or **Approve & Start**. **Choosing is the human's action, not yours** — you cannot create, start, or route the task. If they pick **Board review & Start**, it becomes a pending task owned by the Board (Product Owner + Head of Marketing) to review first; if they pick **Approve & Start**, it becomes a pending task that goes straight to the Main PM to delegate to the cells. Either way, your job ends the moment you call `propose_draft`. Do not say you'll "kick it off", "send it to the PM chain", or route it anywhere — you have no such ability, and which path it takes is the human's choice on the card.
|
||||
|
||||
@@ -19,7 +19,8 @@ A pass without evidence is a betrayal of your role: the entire downstream chain
|
||||
| `give_me_work()` | Returns a task in `awaiting_qa` for your team or `idle`. | None. |
|
||||
| `claim_review(task_id)` | Claims the QA task; returns PR data inline. | Task in `awaiting_qa`; you are not the original developer. |
|
||||
| `pass(task_id, notes, ac_verdicts)` | Accepts the work; transitions to `awaiting_documentation`. `ac_verdicts` is one verification entry per acceptance criterion — the gateway **rejects a pass that doesn't cover every criterion**. | Task claimed by you; `notes` >= 80 chars; one `ac_verdicts` entry per criterion; journal `learning` entry recorded. |
|
||||
| `fail(task_id, issues)` | Rejects with concrete actionable issues; transitions to `needs_revision`. | Task claimed by you; each issue references criterion/file/line. |
|
||||
| `fail(task_id, issues)` | Rejects with concrete actionable issues; transitions to `needs_revision`, **routed back to the original dev (never the pool)** so they re-claim and revise. | Task claimed by you; each issue references criterion/file/line. |
|
||||
| `i_am_blocked(task_id, reason, blocker_type?, what_needed?)` | Record a blocker, escalate to your PM, idle. `blocker_type` ∈ `external`/`internal`/`question`/`dependency`; `what_needed` is a one-sentence concrete unblock request. Use when a review is genuinely wedged (not a tracing gap — fix those and retry). | Task is yours and active. |
|
||||
| `unclaim(task_id)` | Release this claim back to pending. Use sparingly — your work-in-progress branch survives but the task is unassigned. | Task assigned to you and in claimed/in_progress. |
|
||||
| `resume(task_id)` | Resume a paused task. Transitions paused → in_progress. | Task assigned to you and in paused state. |
|
||||
| `note(text, scope?)` | Journal entry. Required: `scope='learning'` before `pass`/`fail`. | None. |
|
||||
@@ -101,4 +102,4 @@ Errors include `error`, `message`, `remediate`, `missing`. Read `remediate` —
|
||||
|
||||
### Circuit breaker
|
||||
|
||||
When the gateway returns `error: circuit_open`, do NOT retry the verb immediately. The breaker tracks repeated rejections of the same verb (same kind, e.g. `tracing_gap` or `incomplete_input`) within 60 seconds. Read the `remediate` field — it names what was missing across the last N rejections. Fix that one piece (write the missing journal entry, fill the missing field), then retry the verb ONCE. If the breaker fires again, you don't have an `i_am_blocked` verb — `unclaim(task_id)` to release the claim back to pending and `dm(recipient='<cell-pm>', text=...)` with the rejection details so the PM knows it's a real wedge, not a transient error.
|
||||
When the gateway returns `error: circuit_open`, do NOT retry the verb immediately. The breaker tracks repeated rejections of the same verb (same kind, e.g. `tracing_gap` or `incomplete_input`) within 60 seconds. Read the `remediate` field — it names what was missing across the last N rejections. Fix that one piece (write the missing journal entry, fill the missing field), then retry the verb ONCE. If the breaker fires again, `i_am_blocked(task_id, reason='<rejection details>')` to escalate the wedge to your PM (or `unclaim(task_id)` if you'd rather release the claim back to pending) and `dm(recipient='<cell-pm>', text=...)` with the rejection details so the PM knows it's a real wedge, not a transient error.
|
||||
|
||||
@@ -25,7 +25,17 @@ down_revision = "015_drop_task_execution_outputs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TEAM_ENUM = sa.Enum(
|
||||
# Reuse the existing Postgres "team" enum in place (created in 001_initial_schema,
|
||||
# widened since by later migrations). ``create_type=False`` MUST be set on the
|
||||
# postgres-native ``postgresql.ENUM``: it's that class's ``create_type`` attribute
|
||||
# that ``_check_for_name_in_memos`` reads to suppress the redundant ``CREATE TYPE``
|
||||
# on ``op.create_table`` (checkfirst=False, so the has_type probe is skipped). On
|
||||
# the generic ``sa.Enum`` the kwarg is silently dropped, so the CREATE TYPE would
|
||||
# fire and crash a boot against a DB where the enum pre-exists ("type 'team'
|
||||
# already exists"). The member list is inert under create_type=False (it never
|
||||
# creates/alters the type), so it reflects the enum as it stood at 016's time,
|
||||
# not the later-widened set. See project_migration_enum_create_type_gotcha.
|
||||
_TEAM_ENUM = postgresql.ENUM(
|
||||
"backend",
|
||||
"frontend",
|
||||
"ux_ui",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Add the task_cell_projects table — ad-hoc per-cell project map for a task.
|
||||
|
||||
A MegaTask root-subtask that spans multiple cells (and may mix per-cell projects
|
||||
from different products / OSS libs) needs a per-cell routing map without standing
|
||||
up a Product for it. ``task_cell_projects`` mirrors ``product_projects`` but is
|
||||
owned by the task: one Project per cell per task (``UNIQUE (task_id, team)``). The
|
||||
root-subtask then cuts ``feature/main_pm/{root}`` per repo and opens a root->master
|
||||
PR per repo exactly like a Product fan-out root — only the map's source differs.
|
||||
``team`` reuses the existing Postgres "team" enum (create_type=False).
|
||||
|
||||
Revision ID: 052_task_cell_projects
|
||||
Revises: 051_respawn_tracker
|
||||
Create Date: 2026-06-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "052_task_cell_projects"
|
||||
down_revision = "051_respawn_tracker"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Reuse the existing Postgres "team" enum in place (created in 001_initial_schema,
|
||||
# widened since by later migrations). ``create_type=False`` MUST be set on the
|
||||
# postgres-native ``postgresql.ENUM``: it's that class's ``create_type`` attribute
|
||||
# that ``_check_for_name_in_memos`` reads to suppress the redundant ``CREATE TYPE``
|
||||
# on ``op.create_table`` (checkfirst=False, so the has_type probe is skipped). On
|
||||
# the generic ``sa.Enum`` the kwarg is silently dropped, so the CREATE TYPE fires
|
||||
# and crashes a real orchestrator boot ("type 'team' already exists").
|
||||
_TEAM_ENUM = postgresql.ENUM(
|
||||
"backend",
|
||||
"frontend",
|
||||
"ux_ui",
|
||||
"board",
|
||||
"main_pm",
|
||||
"fullstack",
|
||||
"marketing",
|
||||
"system",
|
||||
name="team",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"task_cell_projects",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"task_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("team", _TEAM_ENUM, nullable=False),
|
||||
sa.Column(
|
||||
"project_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("projects.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.UniqueConstraint("task_id", "team", name="uq_task_cell_projects_task_team"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("task_cell_projects")
|
||||
@@ -96,14 +96,14 @@ services:
|
||||
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
[ -n "$$status" ] && echo " $$status"
|
||||
done || echo " (pull failed — relying on the cached model)"
|
||||
echo "=== Pulling LLM model (glm-5:cloud) — best-effort ==="
|
||||
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5:cloud"}' | while read -r line; do
|
||||
echo "=== Pulling LLM model (glm-5.2:cloud) — best-effort ==="
|
||||
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5.2:cloud"}' | while read -r line; do
|
||||
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
[ -n "$$status" ] && echo " $$status"
|
||||
done || echo " (pull failed — relying on the cached model)"
|
||||
echo "=== Verifying models are present (the real success gate) ==="
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" || { echo "FATAL: qwen3-embedding missing and could not be pulled"; exit 1; }
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" || { echo "FATAL: glm-5 missing and could not be pulled"; exit 1; }
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "glm-5.2" || { echo "FATAL: glm-5.2 missing and could not be pulled"; exit 1; }
|
||||
echo "=== All models ready! ==="
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
ROBOCO_AGENT_AUTH_SECRET: ${ROBOCO_AGENT_AUTH_SECRET:?ROBOCO_AGENT_AUTH_SECRET is required}
|
||||
ROBOCO_AGENT_AUTH_REQUIRED: ${ROBOCO_AGENT_AUTH_REQUIRED:-false}
|
||||
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
|
||||
ROBOCO_LOCAL_LLM_MODEL: glm-5:cloud
|
||||
ROBOCO_LOCAL_LLM_MODEL: glm-5.2:cloud
|
||||
ROBOCO_DEFAULT_EMBEDDING_MODEL: qwen3-embedding:0.6b
|
||||
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
|
||||
# Spawn the PRE-BUILT agent images from the same registry instead of
|
||||
|
||||
+4
-4
@@ -83,14 +83,14 @@ services:
|
||||
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
[ -n "$$status" ] && echo " $$status"
|
||||
done || echo " (pull failed — relying on the cached model)"
|
||||
echo "=== Pulling LLM model (glm-5:cloud) — best-effort ==="
|
||||
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5:cloud"}' | while read -r line; do
|
||||
echo "=== Pulling LLM model (glm-5.2:cloud) — best-effort ==="
|
||||
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5.2:cloud"}' | while read -r line; do
|
||||
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
[ -n "$$status" ] && echo " $$status"
|
||||
done || echo " (pull failed — relying on the cached model)"
|
||||
echo "=== Verifying models are present (the real success gate) ==="
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" || { echo "FATAL: qwen3-embedding missing and could not be pulled"; exit 1; }
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" || { echo "FATAL: glm-5 missing and could not be pulled"; exit 1; }
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "glm-5.2" || { echo "FATAL: glm-5.2 missing and could not be pulled"; exit 1; }
|
||||
echo "=== All models ready! ==="
|
||||
|
||||
# ==========================================================================
|
||||
@@ -303,7 +303,7 @@ services:
|
||||
ROBOCO_AGENT_AUTH_REQUIRED: ${ROBOCO_AGENT_AUTH_REQUIRED:-false}
|
||||
# Ollama (use container name)
|
||||
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
|
||||
ROBOCO_LOCAL_LLM_MODEL: glm-5:cloud
|
||||
ROBOCO_LOCAL_LLM_MODEL: glm-5.2:cloud
|
||||
ROBOCO_DEFAULT_EMBEDDING_MODEL: qwen3-embedding:0.6b
|
||||
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
|
||||
# Host paths for spawning agent containers (required for Docker-in-Docker)
|
||||
|
||||
+4
-4
@@ -83,14 +83,14 @@ services:
|
||||
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
[ -n "$$status" ] && echo " $$status"
|
||||
done || echo " (pull failed — relying on the cached model)"
|
||||
echo "=== Pulling LLM model (glm-5:cloud) — best-effort ==="
|
||||
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5:cloud"}' | while read -r line; do
|
||||
echo "=== Pulling LLM model (glm-5.2:cloud) — best-effort ==="
|
||||
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5.2:cloud"}' | while read -r line; do
|
||||
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
[ -n "$$status" ] && echo " $$status"
|
||||
done || echo " (pull failed — relying on the cached model)"
|
||||
echo "=== Verifying models are present (the real success gate) ==="
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" || { echo "FATAL: qwen3-embedding missing and could not be pulled"; exit 1; }
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" || { echo "FATAL: glm-5 missing and could not be pulled"; exit 1; }
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "glm-5.2" || { echo "FATAL: glm-5.2 missing and could not be pulled"; exit 1; }
|
||||
echo "=== All models ready! ==="
|
||||
|
||||
# ==========================================================================
|
||||
@@ -303,7 +303,7 @@ services:
|
||||
ROBOCO_AGENT_AUTH_REQUIRED: ${ROBOCO_AGENT_AUTH_REQUIRED:-false}
|
||||
# Ollama (use container name)
|
||||
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
|
||||
ROBOCO_LOCAL_LLM_MODEL: glm-5:cloud
|
||||
ROBOCO_LOCAL_LLM_MODEL: glm-5.2:cloud
|
||||
ROBOCO_DEFAULT_EMBEDDING_MODEL: qwen3-embedding:0.6b
|
||||
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
|
||||
# Host paths for spawning agent containers (required for Docker-in-Docker)
|
||||
|
||||
@@ -338,4 +338,21 @@ if echo "$low" | grep -qE '(^|[[:space:];&|])(uv[[:space:]]+(sync|lock|add|remov
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# `uv run` retargeting onto /app/.venv — same brick as the package-mutation
|
||||
# block above, via a verb that block doesn't list. In the agent container
|
||||
# VIRTUAL_ENV=/app/.venv is baked globally, so `uv run --active` ALWAYS
|
||||
# resolves onto /app/.venv and uv rebuilds it (be-dev-1 root cause,
|
||||
# 2026-06-29). Also catch `uv run`/`uvx` with an explicit /app target.
|
||||
# Bare `uv run` (workspace .venv, cwd-relative) is untouched.
|
||||
if echo "$low" | grep -qE '(^|[[:space:];&|])uv[[:space:]]+run([[:space:]]|$)' && \
|
||||
echo "$low" | grep -qE '(^|[[:space:]=])--active([[:space:]]|$)'; then
|
||||
echo "Denied: \`uv run --active\` retargets onto VIRTUAL_ENV=/app/.venv (the image-baked MCP-gateway venv) and rebuilds it, bricking your own gateway tools. Use bare \`uv run\` (it uses your workspace .venv under /data/workspaces, never /app). If /app's environment looks broken, report it via your blocked / escalation verb." >&2
|
||||
exit 2
|
||||
fi
|
||||
if echo "$low" | grep -qE '(^|[[:space:];&|])(uv[[:space:]]+run|uvx)([[:space:]]|$)' && \
|
||||
echo "$low" | grep -qE '(/app/\.venv|--project[[:space:]=]+"?/app([^a-z]|$)|--directory[[:space:]=]+"?/app([^a-z]|$)|uv_project_environment="?/app([^a-z]|$)|(^|[[:space:];&|])cd[[:space:]]+"?/app([^a-z]|$))'; then
|
||||
echo "Denied: running uv against /app targets the image-baked MCP-gateway venv (/app/.venv) and rebuilds it, bricking your own gateway tools. Use bare \`uv run\` from your workspace clone under /data/workspaces. If /app's environment looks broken, report it via your blocked / escalation verb." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -27,12 +27,22 @@ if ! ( cd /app && python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROM
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auth fail-fast guard. The SuperGrok token (~/.grok/auth.json, mounted read-only)
|
||||
# has a ~6h TTL; on an expired/missing token headless grok does NOT refresh — it
|
||||
# hangs forever at an interactive "Waiting for authorization..." prompt, which
|
||||
# reads as a silent zombie container. The orchestrator refreshes the host token
|
||||
# on a loop; this is the in-container backstop: exit 78 (EX_CONFIG) immediately
|
||||
# so _handle_stopped_container surfaces it, instead of hanging for hours.
|
||||
# Auth fail-fast guard. The SuperGrok token (~/.grok/auth.json) has a ~6h TTL;
|
||||
# on an expired/missing token headless grok does NOT refresh — it hangs forever
|
||||
# at an interactive "Waiting for authorization..." prompt, which reads as a
|
||||
# silent zombie container. The orchestrator refreshes the host token on a loop;
|
||||
# this is the in-container backstop: exit 78 (EX_CONFIG) immediately so
|
||||
# _handle_stopped_container surfaces it, instead of hanging for hours.
|
||||
#
|
||||
# F005: the orchestrator mounts the host ~/.grok DIRECTORY read-only at
|
||||
# /home/agent/.grok-auth-ro (a single-file bind mount pins the inode, so the
|
||||
# atomic auth.json refresh never reached a running container). Symlink
|
||||
# ~/.grok/auth.json at that RO mount so grok + the --check backstop read the
|
||||
# LIVE credential (the directory mount sees the host-side rename), while grok's
|
||||
# own writable state (config.toml, sessions/) still lands in the image's
|
||||
# ~/.grok. `rm -f` first in case the image baked a stub auth.json.
|
||||
rm -f /home/agent/.grok/auth.json
|
||||
ln -s /home/agent/.grok-auth-ro/auth.json /home/agent/.grok/auth.json
|
||||
if ! ( cd /app && python -m roboco.llm.providers.grok_auth --check ); then
|
||||
echo "[grok] auth token missing or expired — refusing to run (would hang at" \
|
||||
"the login prompt). Refresh ~/.grok/auth.json (orchestrator auto-refresh or" \
|
||||
|
||||
@@ -92,6 +92,25 @@ run_case "deny wget api.github" 2 "wget https://api.github.com/repos/foo"
|
||||
run_case "deny rm -rf /app" 2 "rm -rf /app/roboco"
|
||||
run_case "deny rm -rf /etc" 2 "rm -rf /etc"
|
||||
|
||||
# ---------- /app venv protection (exit 2) ----------
|
||||
# /app/.venv is the image-baked MCP-gateway venv. Retargeting uv onto it
|
||||
# rebuilds + bricks every gateway tool (be-dev-1 root cause, 2026-06-29).
|
||||
# The package-mutation block (uv sync/pip install + /app target) and the
|
||||
# uv-run block (uv run --active, or uv run with an explicit /app target).
|
||||
run_case "deny uv sync --project /app" 2 "uv sync --project /app"
|
||||
run_case "deny uv pip install /app venv" 2 "uv pip install --python /app/.venv/bin/python foo"
|
||||
run_case "deny cd /app && uv sync" 2 "cd /app && uv sync"
|
||||
# uv run --active: in the agent VIRTUAL_ENV=/app/.venv is baked globally, so
|
||||
# --active ALWAYS retargets onto /app/.venv → uv rebuilds it → bricked gateway.
|
||||
run_case "deny uv run --active" 2 "uv run --active pytest"
|
||||
run_case "deny uv run --active ruff" 2 "uv run --active ruff check ."
|
||||
run_case "deny env venv /app uv run active" 2 "VIRTUAL_ENV=/app/.venv uv run --active pytest"
|
||||
# uv run with an explicit /app target (same brick, literal /app in the command).
|
||||
run_case "deny uv run --project /app" 2 "uv run --project /app pytest"
|
||||
run_case "deny uv run --directory /app" 2 "uv run --directory /app pytest"
|
||||
run_case "deny cd /app && uv run" 2 "cd /app && uv run pytest"
|
||||
run_case "deny UV_PROJECT_ENV=/app uv run" 2 "UV_PROJECT_ENVIRONMENT=/app/.venv uv run pytest"
|
||||
|
||||
# ---------- ALLOW cases (exit 0) — must NOT be denied ----------
|
||||
run_case "allow set -e" 0 "set -e"
|
||||
run_case "allow set -euo pipefail" 0 "set -euo pipefail"
|
||||
@@ -100,6 +119,10 @@ run_case "allow env VAR=val cmd" 0 "env FOO=bar uv run pytest"
|
||||
run_case "allow env -i cmd" 0 "env -i HOME=/tmp ls /tmp"
|
||||
run_case "allow ls" 0 "ls -la /workspace"
|
||||
run_case "allow uv run ruff" 0 "uv run ruff check ."
|
||||
run_case "allow uv run pytest" 0 "uv run pytest -q"
|
||||
run_case "allow uv run --no-sync" 0 "uv run --no-sync pytest -q"
|
||||
run_case "allow uv run --with dep" 0 "uv run --with httpx python -c 'pass'"
|
||||
run_case "allow uv run in workspace" 0 "cd /data/workspaces/proj && uv run pytest"
|
||||
run_case "allow pnpm typecheck" 0 "pnpm typecheck"
|
||||
run_case "allow rm in workspace" 0 "rm -rf /workspace/tmp"
|
||||
run_case "allow declare -a arr" 0 "declare -a arr=(a b c)"
|
||||
|
||||
+7
-5
@@ -39,14 +39,16 @@ Set `ROBOCO_AGENT_AUTH_REQUIRED=true` to require a signed token on every REST re
|
||||
!!! tip "How the panel authenticates as the CEO"
|
||||
The control panel acts as the CEO agent. In secure mode, nginx injects the panel's CEO `X-Agent-Token` so your browser session is authenticated without you handling the secret — you just use the panel as normal.
|
||||
|
||||
## The WebSocket caveat
|
||||
## The WebSocket + live-chat streams
|
||||
|
||||
Token enforcement is **REST-only**. The [WebSocket streams](./websockets.md) do not check the HMAC token:
|
||||
Secure mode extends beyond REST. When `ROBOCO_AGENT_AUTH_REQUIRED=true`:
|
||||
|
||||
- The per-resource sockets (`/ws/channels|agents|sessions|notifications/{id}`) validate their `agent_id`/`viewer_id` query param against the database and channel access, but not a token.
|
||||
- `/ws/system` is fully unauthenticated.
|
||||
- The **per-resource WebSocket streams** (`/ws/channels|agents|sessions|notifications/{id}`) require the **CEO panel token** — the same signed `X-Agent-Token` nginx injects for the panel. An agent on the Docker network can no longer subscribe to another agent's notifications with no auth. They still validate `agent_id`/`viewer_id` against the DB and channel access on top.
|
||||
- The **`/api/v1/do/*` content routes** require a valid per-agent HMAC token bound to `X-Agent-ID` (the do router serves every role, so the gate is token-only, not role-specific).
|
||||
- The **live-chat bridges** (`/prompter/live/*`, `/secretary/live/*`) — the prompter/secretary intake chats — require the CEO panel token on their start/stream/status/messages/stop endpoints. They were the last panel-facing API surface that ran unauthenticated.
|
||||
- **`/ws/system`** stays operator-only and read-only by design (it carries system telemetry and accepts nothing from the client); it is not token-gated.
|
||||
|
||||
The streams are read-only and carry no control surface or secrets, so this isn't a privilege-escalation path the way the REST headers are — but it does mean the orchestrator port should stay trusted-network-only until WebSocket auth lands, even when you've enabled secure-mode REST.
|
||||
A presented-but-forged token is rejected even in dev (header-trust) mode, so you can roll out tokens before flipping the switch without breaking anything. The container→relay internal callback is left ungated by design (internal Docker network, opaque session id).
|
||||
|
||||
## What to do
|
||||
|
||||
|
||||
@@ -8,16 +8,16 @@ There are four per-resource streams plus one operator-wide stream:
|
||||
|
||||
| Endpoint | Stream | Auth |
|
||||
|----------|--------|------|
|
||||
| `/ws/channels/{channel_id}` | Live messages in a team channel | `agent_id` query param, validated against the DB + channel access |
|
||||
| `/ws/agents/{agent_id}` | An agent's output and lifecycle events | `viewer_id`/`agent_id` query param, validated against the DB |
|
||||
| `/ws/sessions/{session_id}` | Messages in a communication session | `agent_id` query param, validated |
|
||||
| `/ws/notifications/{agent_id}` | An agent's notifications | `agent_id` query param, validated |
|
||||
| `/ws/system` | Operator/system-wide stream — no per-agent keying | **Unauthenticated, read-only** |
|
||||
| `/ws/channels/{channel_id}` | Live messages in a team channel | `agent_id` query param, validated against the DB + channel access; **CEO panel token required in secure mode** |
|
||||
| `/ws/agents/{agent_id}` | An agent's output and lifecycle events | `viewer_id`/`agent_id` query param, validated against the DB; **CEO panel token required in secure mode** |
|
||||
| `/ws/sessions/{session_id}` | Messages in a communication session | `agent_id` query param, validated; **CEO panel token required in secure mode** |
|
||||
| `/ws/notifications/{agent_id}` | An agent's notifications | `agent_id` query param, validated; **CEO panel token required in secure mode** |
|
||||
| `/ws/system` | Operator/system-wide stream — no per-agent keying | **Unauthenticated, read-only** (operator-only by design; not token-gated) |
|
||||
|
||||
All sockets support a `ping`/`pong` keepalive: send `{"type": "ping"}` and you'll get a `pong` back.
|
||||
|
||||
!!! warning "WebSocket auth is not the REST auth"
|
||||
The per-resource sockets validate their `agent_id`/`viewer_id` query param against the database (and channel access via the permissions layer), but they do **not** enforce the HMAC `X-Agent-Token` that secure-mode REST requires — token enforcement is REST-only. `/ws/system` is intentionally fully unauthenticated. None of the streams carry a control surface or secrets, so they're read-only by design, but the orchestrator port should be treated as trusted-network-only until WebSocket auth lands. See [Authentication](./auth.md) and [Security](../troubleshooting/security.md).
|
||||
!!! info "Secure mode now covers the per-agent streams"
|
||||
When `ROBOCO_AGENT_AUTH_REQUIRED=true`, the four per-resource sockets require the **CEO panel token** (the signed `X-Agent-Token` nginx injects for the panel) on top of their `agent_id`/`viewer_id` DB validation — an agent on the Docker network can no longer subscribe to another agent's stream unauthenticated. `/ws/system` is intentionally left operator-only and read-only. A forged token is rejected even in dev mode. See [Authentication](./auth.md).
|
||||
|
||||
## How events reach the sockets
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ Two more read-only servers give agents a read-only view of git (`status`, `log`,
|
||||
|
||||
At spawn, every agent is handed a **manifest** listing exactly the verbs its role may call — and nothing else. The manifest is built from a server-side role configuration and mounted read-only into the container. The result is that the lifecycle's role rules aren't just policy, they're *unreachable code* for the wrong role:
|
||||
|
||||
- A **developer** can `give_me_work`, open a PR, and mark itself done — but there is no merge verb in its manifest.
|
||||
- **QA** can claim a review and pass or fail it — but it has no `commit`.
|
||||
- A **developer** can `give_me_work`, open a PR, mark itself done, and `sync_branch` (rebase its branch onto its base through the gate) — but there is no merge verb in its manifest.
|
||||
- **QA** can claim a review and pass or fail it — but it has no `commit`. QA and Documenters also get `i_am_blocked` as their escape hatch when they're stuck.
|
||||
- A **PR reviewer** can pass or fail an assembled PR and post its review on the PR — but it never gets agent chat verbs.
|
||||
- The **Auditor** is restricted to leaving a private note and reading evidence; it cannot `say` or `dm`. It observes; it does not participate.
|
||||
|
||||
@@ -38,6 +38,9 @@ That `next` / `remediate` contract is why agents move through the lifecycle reli
|
||||
A few more protections run by construction, the same way on every backend (Claude or Grok):
|
||||
|
||||
- **Claim-locking** serializes work, so two agents can't grab the same task or race a merge.
|
||||
- **Content posts require an active claim.** `commit`, `note`, `say`, `dm`, and `evidence` on a specific task are refused unless the agent holds that task's active claim — an agent can't write to a task it hasn't locked.
|
||||
- **Human-only roles are never spawned.** The CEO, the Intake (prompter), and the Secretary are human-driven, so `spawn_agent` structurally refuses them — a notification addressed to the CEO can never launch a CEO container that acts as the human. Intake and Secretary run through their own dedicated, guarded chat paths instead.
|
||||
- **Notifications can't target human-only roles.** `notify` rejects the CEO/prompter/secretary as recipients — there is no agent acknowledgement path for them, so a notification to them is a no-op rather than a stuck ack.
|
||||
- **The token never enters the container.** Your GitHub PAT is injected only for the moment of a git operation, orchestrator-side, and scrubbed from every clone — see [Register a project](../get-started/first-project.md#what-happens-under-the-hood).
|
||||
- **A prompt-injection guard** screens task prompts, and a bash guard blocks credential-exfiltration and identity-forgery patterns.
|
||||
- **Rate limits and overloads park, they don't crash-loop.** If a provider returns a 429 or a persistent overload, RoboCo *queues* that agent's work and probes for recovery instead of burning tokens retrying. You'll see an amber banner; the work resumes automatically when the provider does.
|
||||
|
||||
@@ -25,6 +25,8 @@ For each task it proposes, the agent declares a small **collision surface**: whi
|
||||
|
||||
The waves are just ordinary task dependencies, so the same dependency-gate that already paces the rest of the company runs them: a wave starts only once the previous wave's tasks have reached a terminal state — normally each one's pull request is merged (a cancelled task releases the next wave too).
|
||||
|
||||
The same collision-aware sequencing follows the work **down the chain**, not just at the top level. When a cell PM delegates a root-subtask into developer tasks, the dev-task collision surfaces flow through the same DAG — file-overlap serializes, migration-adders chain, shared-surface edits wait their turn — and cell tasks themselves wave-chain off their sibling root-subtasks. So a batch that spans a shared codebase stays ordered all the way to the leaves, not only at the umbrella. The task hierarchy is capped at four layers (umbrella → root → cell → dev) to fit this MegaTask shape.
|
||||
|
||||
## What gets created
|
||||
|
||||
When you confirm, RoboCo creates one **umbrella** task that groups the batch, and one **root-subtask** per piece of work:
|
||||
|
||||
@@ -40,6 +40,15 @@ graph BT
|
||||
|
||||
Each of those assembled pull requests passes through the [in-path PR-review gate](task-lifecycle.md#the-in-path-pr-review-gate) before its PM merges it.
|
||||
|
||||
## Submit gates that keep the chain clean
|
||||
|
||||
Two gate-level checks stop a stale branch from sneaking through:
|
||||
|
||||
- **Behind-base gate on `i_am_done`.** If a sibling's PR merged into the parent branch while the developer worked, the dev's branch is now behind its base and the assembled PR won't merge cleanly. The gate refuses `i_am_done` in that state and steers the developer to `sync_branch` — the gate-level rebase verb that rebases the branch onto its base (raw shell git is denied to agents, so the rebase goes through the gate, traced and evidenced). Conflicts abort with no force-push and point the dev at resolve-by-hand. The gate fails open on a flaky fetch so a transient git error can't strand a task at the submit gate.
|
||||
- **Unchanged-PR gate on `submit_root`.** When a Main-PM root PR is `pr_fail`'d and re-submitted byte-identical, the loop would repeat forever. The gate refuses the re-submit when the assembled root PR's head SHA is unchanged since the last `pr_fail` (no new cell work → identical diff); a different SHA means the branch advanced and the submit proceeds. Every ambiguous case fails open.
|
||||
|
||||
PR operations are also **scoped per project** — `open_pr`, `pr_target`, `close_pull_request`, and `merge_pr` all require the project and resolve the PR number within it, so two tasks in different repos that happen to share a PR number can never collide and merge the wrong repository's PR.
|
||||
|
||||
## Only the CEO merges to master
|
||||
|
||||
The final pull request — root → master — is the one place the company stops and hands the decision back to you. It lands in your **CEO Approval Queue** and waits.
|
||||
|
||||
@@ -54,6 +54,8 @@ stateDiagram-v2
|
||||
|
||||
Rejection isn't a dead end — it's a loop. When **QA fails** a task, or a **PR reviewer rejects** an assembled pull request, the task drops back to `needs_revision`, the developer reworks it, and it re-enters the flow. The same is true when *you* request changes from the CEO Approval Queue. Nothing is lost; the task carries its history, branch, and pull request with it the whole way around.
|
||||
|
||||
A failed developer task is routed back to **the developer who worked it** (resolved from the work session), not the pool — so the revision lands with whoever has the context, rather than being re-claimed cold by a cell PM. Only a task no developer ever touched falls back to the pool.
|
||||
|
||||
## The in-path PR-review gate
|
||||
|
||||
Most leaf developer tasks are reviewed by QA and never need a separate PR review. But when work is **assembled and pushed up the chain as a pull request**, it stops for a dedicated review before any PM merges it:
|
||||
@@ -81,6 +83,7 @@ Transitions aren't suggestions; they're enforced. A handful of the rules:
|
||||
- **`pr_pass` / `pr_fail`** are PR-reviewer-only.
|
||||
- **Merging** (`awaiting_pm_review → completed`) is PM-only; **escalating to the CEO** and the final **approve / request-changes / cancel** are CEO-only.
|
||||
- **Cancelling** is PM-only.
|
||||
- **A Main-PM coordination root can never be `task_type=code`.** The Main PM coordinates; it doesn't write code itself, so the combination is rejected at creation — a structural guard, not a hint.
|
||||
|
||||
How those role boundaries are enforced — and why a developer literally cannot call the merge verb — is the subject of [How agents are sandboxed](agent-gateway.md).
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Two variables choose what you pull (defaults shown):
|
||||
|
||||
```bash
|
||||
ROBOCO_REGISTRY=ghcr.io/rennf93 # or docker.io/renzof93
|
||||
ROBOCO_VERSION=latest # or a pinned release, e.g. 0.13.0
|
||||
ROBOCO_VERSION=latest # or a pinned release, e.g. 0.14.0
|
||||
```
|
||||
|
||||
The orchestrator then spawns the **matching** pre-built agent images on demand (it reads `ROBOCO_AGENT_IMAGE_REGISTRY` / `ROBOCO_AGENT_IMAGE_TAG`, which the registry compose wires to the same registry and version). Pin `ROBOCO_VERSION` to a release tag in production so an upstream `latest` push can't silently change your fleet.
|
||||
|
||||
@@ -74,7 +74,7 @@ A feature flag set in `.env` takes effect on the next backend restart. The env-g
|
||||
| `ROBOCO_ANTHROPIC_API_KEY` | *(unset)* | Optional Anthropic key. Agents use the mounted Claude Code auth, not a metered key. |
|
||||
| `ROBOCO_DEFAULT_EMBEDDING_MODEL` | `qwen3-embedding:0.6b` | Embedding model (1024-dim). |
|
||||
| `ROBOCO_EMBEDDING_DIMENSIONS` | `1024` | Embedding dimensions — must match the model. |
|
||||
| `ROBOCO_LOCAL_LLM_MODEL` | `glm-5:cloud` | Local LLM for RAG answer synthesis. |
|
||||
| `ROBOCO_LOCAL_LLM_MODEL` | `glm-5.2:cloud` | Local LLM for RAG answer synthesis. |
|
||||
| `ROBOCO_LOCAL_LLM_BASE_URL` | `http://roboco-ollama:11434/v1` | Ollama OpenAI-compatible endpoint. |
|
||||
| `ROBOCO_OLLAMA_BASE_URL` | `http://roboco-ollama:11434` | Ollama native endpoint (embeddings, model management). |
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ The Grok access token has a fixed ~6-hour, server-set lifetime, and the CLI can'
|
||||
- As a backstop, each agent's entrypoint runs `python -m roboco.llm.providers.grok_auth --check` and **refuses to start** on a missing or expired token instead of hanging.
|
||||
|
||||
!!! warning "The orchestrator's `~/.grok` mount must be writable"
|
||||
The orchestrator rewrites `auth.json` when it refreshes the token, so the orchestrator's own mount of `~/.grok` must be **read-write**. (The per-agent mount stays read-only — agents only read the credential.) If the orchestrator can't write it, the token will expire and Grok agents will fail their start-up `--check`.
|
||||
The orchestrator rewrites `auth.json` when it refreshes the token, so the orchestrator's own mount of `~/.grok` must be **read-write**. (The per-agent mount stays read-only — agents only read the credential.) If the orchestrator can't write it, the token will expire and Grok agents will fail their start-up `--check`. If the host `auth.json` is missing entirely at spawn time, the orchestrator logs a loud warning — a missing credential is the most common Grok misconfiguration, so it's surfaced early rather than as a fleet of failed starts.
|
||||
|
||||
## Per-fleet tuning
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ The crucial property: **work is queued, never dropped.** Parked tasks wait; the
|
||||
!!! tip "Parked is not stuck"
|
||||
If a run goes quiet, check the banner before assuming something broke. A parked provider with a counting-down timer is RoboCo waiting out a rate limit on purpose. The work is held and will resume — there's nothing for you to do.
|
||||
|
||||
!!! info "Escape hatch for a probe that never recovers"
|
||||
Park-and-probe assumes the provider comes back. If a provider's probe fails persistently (the secret was rotated, the endpoint moved), an escape hatch releases the parked work back to the pool instead of holding it forever — so a permanently-dead provider doesn't strand its tasks. Grok auth-missing (exit 78) is parked the same way rather than crash-retried straight back into the same missing-credential failure.
|
||||
|
||||
## Disk housekeeping: dangling-image prune
|
||||
|
||||
Every agent-image rebuild leaves the previous build behind as a dangling (`<none>`) Docker image. Left alone they pile up and eat disk. The orchestrator's background sweep prunes them on a throttle (~6h): it removes **only** dangling images — a tagged image, or one still backing a running container, is never touched. It is gated by `ROBOCO_IMAGE_PRUNE_ENABLED`, which is **on by default**. This isn't a feature flag you opt into; it's an always-on safety net you can disable if you'd rather manage image cleanup yourself.
|
||||
|
||||
@@ -14,7 +14,7 @@ The rules live in a per-project `.roboco/conventions.yml` with four curated part
|
||||
|------|-----------|
|
||||
| **Module map** | Path prefixes mapped to a human purpose and the definition *kinds* forbidden there (`model`, `route`, `helper`, `business_logic`, `component`). "`routers/` is for HTTP routes — no models, no helpers." |
|
||||
| **Rules** | A toggleable rule set. Each rule fires at `warn` (advisory, never blocks) or `block` (refuses the gate). |
|
||||
| **Custom rules** | Project-specific regex rules — a pattern, a message, and a level, optionally scoped to languages. |
|
||||
| **Custom rules** | Project-specific regex rules — a pattern, a message, and a level, optionally scoped to languages. TypeScript-scoped custom rules apply to both `.ts` and `.tsx` files. |
|
||||
| **Waivers** | Accountable per-`(path, rule)` escape hatches with a written reason — the sanctioned way to relieve a false positive, reviewed in the PR. |
|
||||
|
||||
### Placement, hygiene, and modularity checks
|
||||
@@ -34,7 +34,7 @@ The validator runs four check families over each changed file:
|
||||
| `god_class` | A class grows past 15 methods (single-responsibility smell) | `warn` |
|
||||
|
||||
!!! info "Precision over recall"
|
||||
Every check fires only on a confident, structural signal, and abstains when it is uncertain — so a `block`-level gate is never tripped by a guess. If the validator genuinely *cannot* run on a diff (a parse or grammar error), it is **fail-loud**: it exits non-zero and the gate blocks rather than passing silently.
|
||||
Every check fires only on a confident, structural signal, and abstains when it is uncertain — so a `block`-level gate is never tripped by a guess. If the validator genuinely *cannot* run on a diff (a parse or grammar error), it is **fail-loud**: it exits non-zero and the gate blocks rather than passing silently. The validator is also **time-bounded** — a hung run (a tree-sitter deadlock, an enormous repo) is killed after 120s and treated as `could_not_run`, so a stuck subprocess can't hang the `i_am_done` / `pr_pass` gate forever or orphan a process on restart. And if the *effective map itself* can't be resolved (a conventions-service error), the gate **fails closed** rather than silently disabling the standard for that task.
|
||||
|
||||
## The effective map: defaults, present, absent, or partial
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ A swim-lane board of the delivery pipeline, switched with the `?view=` query par
|
||||
| **PR Review** | `pr-review` | assembled PRs at the in-path review gate |
|
||||
| **PM** | `pm` | tasks awaiting PM review and merge |
|
||||
|
||||
Each board is a read-at-a-glance view of where work sits in the [lifecycle](../company/task-lifecycle.md). Switching tabs updates the URL, so a specific board is shareable.
|
||||
Each board is a read-at-a-glance view of where work sits in the [lifecycle](../company/task-lifecycle.md). Switching tabs updates the URL, so a specific board is shareable. A drag that would skip a lifecycle precondition (moving a task past a gate it hasn't passed) opens a confirmation dialog first, so an accidental drop can't silently bypass the flow.
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ Environment variables for RoboCo (prefix: `ROBOCO_`).
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ROBOCO_LOCAL_LLM_MODEL` | `glm-5:cloud` | Local LLM for RAG |
|
||||
| `ROBOCO_LOCAL_LLM_MODEL` | `glm-5.2:cloud` | Local LLM for RAG |
|
||||
| `ROBOCO_LOCAL_LLM_BASE_URL` | `http://roboco-ollama:11434/v1` | OpenAI-compat API |
|
||||
| `ROBOCO_OLLAMA_BASE_URL` | `http://roboco-ollama:11434` | Native Ollama API |
|
||||
|
||||
|
||||
@@ -73,6 +73,8 @@ Escalate to your role's escalation_target.
|
||||
|
||||
**Composes:** (no atomic actions)
|
||||
|
||||
**Preconditions:** non_terminal
|
||||
|
||||
|
||||
## fail_review
|
||||
|
||||
@@ -162,7 +164,7 @@ Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no
|
||||
|
||||
**Side effects:** push_branch, create_pr
|
||||
|
||||
**Preconditions:** commits>=1, no_prior_pr, owns_task
|
||||
**Preconditions:** commits>=1, no_prior_pr, owns_task, pr_open_state
|
||||
|
||||
|
||||
## pass_review
|
||||
@@ -221,7 +223,7 @@ Resume a paused task you own. paused -> in_progress.
|
||||
|
||||
## submit_root
|
||||
|
||||
Main PM opens the root→master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. Only for code roots; branchless coordination roots skip the gate and complete directly.
|
||||
Main PM opens the root→master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed — a Main-PM root is planning-typed, never code.
|
||||
|
||||
**Allowed roles:** main_pm
|
||||
|
||||
@@ -241,6 +243,17 @@ Cell PM opens the cell→root PR and moves the cell task into the PR-review gate
|
||||
**Pre side effects:** create_pr
|
||||
|
||||
|
||||
## sync_branch
|
||||
|
||||
Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base — e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned — resolve by hand, commit, then sync_branch again.
|
||||
|
||||
**Allowed roles:** developer
|
||||
|
||||
**Composes:** (no atomic actions)
|
||||
|
||||
**Preconditions:** owns_task
|
||||
|
||||
|
||||
## triage
|
||||
|
||||
List actionable tasks in your scope.
|
||||
|
||||
@@ -150,12 +150,28 @@ notify(target="be-dev-1", text="Please prioritise task X by EOD.",
|
||||
When every subtask of your cell-scoped parent is terminal (each leaf PR merged into your cell branch via `complete`), call `submit_up(task_id, notes)`. This opens the **cell→root PR** and moves the parent into the in-path PR-review gate (`awaiting_pr_review`), where your cell's **PR reviewer** reviews the assembled diff:
|
||||
|
||||
- `pr_pass` → the parent moves to `awaiting_pm_review`; you then `complete(task_id, notes)` to merge the cell→root PR into the root branch.
|
||||
- `pr_fail` → the parent returns to `needs_revision` (owned by you) with the reviewer's issues; fix, then re-`submit_up`.
|
||||
- `pr_fail` → the parent returns to `needs_revision` (owned by you) with the reviewer's issues; fix, then re-`submit_up`. The reviewer's verdict + issues are carried in your task handoff, so you are not blind on the rework.
|
||||
|
||||
Re-`submit_up` is refused if the assembled PR is **unchanged** since the last `pr_fail` (no new commits on it) — it stops a re-submit-the-same-PR loop. Fix the issues and commit before re-submitting.
|
||||
|
||||
You merge your own cell→root PR — the Main PM does **not** merge your cell branch. The Main PM owns the **root** task: once every cell's parent is terminal, it runs the same gate one level up (`submit_root` → main reviewer → escalate to CEO) and only the CEO merges to `master`. You never open or merge a master PR yourself.
|
||||
|
||||
`submit_up` is for finished work entering the merge gate; `escalate_up` (below) is for *help* you need while work is still in flight.
|
||||
|
||||
### Sequencing dev-task collisions
|
||||
|
||||
When you `delegate` a dev subtask you may pass the collision surface so the sequencing DAG orders siblings that touch the same files:
|
||||
|
||||
```python
|
||||
delegate(parent_task_id=..., ...,
|
||||
intends_to_touch=["roboco/api/routes/*.py"], # file globs
|
||||
adds_migration=False, # adds a DB migration
|
||||
touches_shared=True, # edits a shared module
|
||||
depends_on=["<sibling-task-id>"]) # explicit ordering
|
||||
```
|
||||
|
||||
Siblings whose `intends_to_touch` globs overlap are serialized (more-important first); migration-adders chain serially; a shared-surface edit runs after each non-shared task it overlaps. Omit these and only the weak assignee-keyed spawn barrier orders your dev tasks (the 2026-06-27 out-of-order break).
|
||||
|
||||
## Escalating to Main PM
|
||||
|
||||
Use `escalate_up(task_id, reason)` when:
|
||||
|
||||
@@ -58,7 +58,7 @@ i_am_idle() → no work in your queue right now
|
||||
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `give_me_work`, `i_will_work_on`, `open_pr`, `i_am_done`, `i_am_blocked`, `unclaim`, `resume`, `i_am_idle` |
|
||||
| `roboco-flow` | `give_me_work`, `i_will_work_on`, `open_pr`, `i_am_done`, `i_am_blocked`, `unclaim`, `resume`, `sync_branch`, `i_am_idle` |
|
||||
| `roboco-do` | `commit`, `note`, `say`, `dm`, `evidence` |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
|
||||
|
||||
@@ -111,7 +111,7 @@ master ← feature/main_pm/{root} ← feature/{cell}/{root}/{cell-pm} ←
|
||||
```
|
||||
|
||||
- A cell PM's `complete` merges a leaf PR into its cell branch; after the cell gate, its `complete` merges the cell→root PR into your root branch. You do not merge cell branches.
|
||||
- Once every cell's parent is terminal, **`submit_root(root_task_id, notes)`** opens the root→master PR and enters the in-path gate (`awaiting_pr_review`). The **main PR reviewer** checks the assembled root diff: `pr_pass` → `awaiting_pm_review`; `pr_fail` → `needs_revision` (owned by you, fix + re-`submit_root`).
|
||||
- Once every cell's parent is terminal, **`submit_root(root_task_id, notes)`** opens the root→master PR and enters the in-path gate (`awaiting_pr_review`). The **main PR reviewer** checks the assembled root diff: `pr_pass` → `awaiting_pm_review`; `pr_fail` → `needs_revision` (owned by you, fix + re-`submit_root`). The reviewer's verdict + issues are carried in your task handoff, and re-`submit_root` is refused if the root PR is **unchanged** since the last `pr_fail` — fix and commit before re-submitting.
|
||||
- After `pr_pass`, `complete(root_task_id, notes)` escalates the root to the CEO (`awaiting_ceo_approval`) — it does **not** merge. A branchless coordination root (product fan-out, no repo) skips the gate and `complete` escalates directly.
|
||||
- The CEO approves and merges the root→master PR from the panel. Only the CEO ever merges to `master`.
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ The `pr_reviewer` role also runs the **in-path gate** on the org's OWN assembled
|
||||
|
||||
### Gate enforcement
|
||||
|
||||
When the architectural-conventions standard is enabled, `pr_pass` is refused on any block-level convention finding, the same way the developer's `i_am_done` is. When toolchain matching is enabled, `pr_pass` is likewise refused on a "broken" toolchain status. Your verdict note is a mandatory structured field (`pr_reviewer_notes`) written at `pr_pass` / `pr_fail`; it is persisted structured with a derived text mirror.
|
||||
When the architectural-conventions standard is enabled, `pr_pass` is refused on any block-level convention finding, the same way the developer's `i_am_done` is — the remediation hint points you at the offending `file:line` + the `pr_fail` verb (not `i_am_blocked`). When toolchain matching is enabled, `pr_pass` is likewise refused on a "broken" toolchain status. Your verdict note is a mandatory structured field (`pr_reviewer_notes`) written at `pr_pass` / `pr_fail`; it is persisted structured with a derived text mirror.
|
||||
|
||||
You cannot `pr_pass` / `pr_fail` an assembled PR you authored (self-review guard, same shape as QA's). A `claim_gate_review` on your own work returns `not_authorized`.
|
||||
|
||||
## What You CAN Do
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
- Read-only inspect git via `roboco_git_status / _log / _diff / _branch_list`
|
||||
- Search the knowledge base via `roboco_ask_mentor` / `roboco_kb_search`
|
||||
- Note evidence via `note(text=..., scope="...")` and `evidence(...)`
|
||||
- Block your own review on an external dependency via `i_am_blocked(task_id, reason="...")` (Cell PM unblocks)
|
||||
|
||||
## What You CANNOT Do
|
||||
|
||||
@@ -41,6 +42,8 @@ claim_review(task_id) → claim for review
|
||||
pass(task_id, notes) → moves to awaiting_documentation
|
||||
fail(task_id, issues=[...]) → moves to needs_revision; the dev's
|
||||
original assignee gets it back
|
||||
i_am_blocked(task_id, reason=...) → external blocker (broken env, can't
|
||||
reproduce); Cell PM unblocks
|
||||
unclaim(task_id) / resume(task_id) / i_am_idle()
|
||||
```
|
||||
|
||||
@@ -48,7 +51,7 @@ unclaim(task_id) / resume(task_id) / i_am_idle()
|
||||
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `give_me_work`, `claim_review`, `pass`, `fail`, `unclaim`, `resume`, `i_am_idle` |
|
||||
| `roboco-flow` | `give_me_work`, `claim_review`, `pass`, `fail`, `i_am_blocked`, `unclaim`, `resume`, `i_am_idle` |
|
||||
| `roboco-do` | `note`, `say`, `dm`, `evidence` (no `commit`, no `notify`) |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
|
||||
@@ -118,4 +121,4 @@ dm(recipient="be-pm",
|
||||
task_id="...")
|
||||
```
|
||||
|
||||
If the situation is unresolvable from the QA side (e.g. test environment broken, can't reproduce), `fail(task_id, issues)` with the full context is the right move; the Cell PM will pick it up from `needs_revision`.
|
||||
For an external blocker (test environment broken, can't reproduce, missing infra), use `i_am_blocked(task_id, reason="...")` — your Cell PM is notified and `unblock`s you. If the work itself is wrong, `fail(task_id, issues)` with the full context is the right move; the Cell PM picks it up from `needs_revision`.
|
||||
|
||||
@@ -54,7 +54,7 @@ A single Python CLI classifies every changed definition with tree-sitter (Python
|
||||
python -m roboco.conventions check --root <repo> --files <a> <b> ...
|
||||
```
|
||||
|
||||
It favours precision over recall — it abstains when it cannot classify a definition, so a `block` gate can never strand a task on a guess — and it fails loud: a validator that cannot run exits non-zero so the gate blocks rather than silently passing.
|
||||
It favours precision over recall — it abstains when it cannot classify a definition, so a `block` gate can never strand a task on a guess — and it fails loud: a validator that cannot run exits non-zero so the gate blocks rather than silently passing. A **hung** validator is reaped after a timeout and the gate blocks the same way, and a conventions **resolution error** (effective-map build failure) fails closed — the gate is never silently disabled by an upstream error.
|
||||
|
||||
## Modularity
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ Don't invent channel slugs. Call `channels()` first if unsure:
|
||||
channels() # -> {"writable": [...], "readable": [...]}
|
||||
```
|
||||
|
||||
**Active-claim required (explicit `task_id`):** when you pass an explicit `task_id`, `say` / `dm` / `note` check that you are the task's **active claimant** — not just `assigned_to`, which goes stale across a reap/handoff. A reaped or reassigned agent can no longer post to a former task; if you see `not_authorized` on a content post, re-`claim` the task first (or drop the explicit `task_id` for a general channel post).
|
||||
|
||||
Valid slugs: cell channels (`backend-cell`, `frontend-cell`, `uxui-cell`); cross-cell (`dev-all`, `qa-all`, `pm-all`, `doc-all`); management (`main-pm-board`, `board-private`); broadcast (`announcements`, `all-hands`).
|
||||
|
||||
## Direct message (A2A) — `dm`
|
||||
@@ -40,6 +42,8 @@ notify(target="be-dev-1", text="Task ready for you", priority="normal", task_id=
|
||||
|
||||
`priority` is `normal | high | urgent`. `task_id` auto-injects from the active task when omitted.
|
||||
|
||||
`notify` rejects **human-only recipients** (`prompter`, `secretary`) — they have no agent ack path, so an ack-required alert to them would sit unacked forever. The CEO is allowed (acks via the panel).
|
||||
|
||||
## Receiving notifications
|
||||
|
||||
Every role with an inbox gets these (so `i_am_idle()` doesn't soft-block on unread items):
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| `git checkout` of a task branch | None — branch is auto-checked-out by `i_will_work_on(task_id)` (devs) or `i_will_plan(task_id, plan)` (PMs) |
|
||||
| Open a PR | None — PR is opened by the choreographer when the dev calls `open_pr(task_id)` |
|
||||
| Merge a PR | `complete(task_id, notes)` (PMs only) — Cell PM merges leaf PR; Main PM merges parent and escalates to CEO |
|
||||
| `git fetch` / `git pull` / `git rebase` | None at the agent layer — there is no pull/rebase verb. If your branch is **behind its base**, ESCALATE (`escalate_up` / `i_am_blocked`) — that, not unclaim, is the fix. Use `unclaim` + re-`claim` only to rebuild a branch fresh from the current base, and only on instruction |
|
||||
| `git fetch` / `git pull` / `git rebase` | Devs have `sync_branch(task_id)` — the gate-level rebase (fetch + rebase + force-with-lease push). If your branch is **behind its base**, call `sync_branch(task_id)`; do NOT improvise shell git. PMs have no rebase verb — for a cell/root integration branch behind its base, `escalate_up(...)`. Use `unclaim` + re-`claim` only to rebuild a branch fresh from the current base, and only on instruction |
|
||||
|
||||
## Write/Edit Outside Workspace
|
||||
|
||||
|
||||
@@ -52,11 +52,13 @@ Don't checkout by hand — there is no `roboco_git_checkout` tool.
|
||||
|
||||
## Branch Behind Its Base / master
|
||||
|
||||
**Symptom:** `roboco_git_status` shows `behind > 0` against the base branch when you go to submit.
|
||||
**Symptom:** `roboco_git_status` shows `behind > 0` against the base branch when you go to submit, OR `i_am_done` refuses with "your branch is N commit(s) behind its base".
|
||||
|
||||
**Cause:** The base (cell branch or master) advanced after your branch was cut, so your branch is stale.
|
||||
**Cause:** The base (cell branch or master) advanced after your branch was cut, so your branch is stale — a sibling's PR merged into the parent branch while you worked.
|
||||
|
||||
**Fix:** You have no rebase verb — do **not** create a rebase task or improvise with shell git. Bringing the branch current is a platform/PM action. Escalate: devs `i_am_blocked(reason="branch behind base — needs rebase")`; PMs `escalate_up(...)`.
|
||||
**Fix (devs):** Call `sync_branch(task_id)` — that is your gate-level rebase verb (raw `Bash git rebase` is denied). It fetches, rebases your branch onto its base, and force-pushes with-lease. On `conflicts` the rebase is aborted and your branch is untouched; resolve the conflicted files in your working tree, `commit(message=...)`, then `sync_branch(task_id)` again. Do **not** create a rebase task or improvise shell git. After it returns `rebased`, continue editing + `commit`, then `open_pr` / `i_am_done` as normal.
|
||||
|
||||
**Fix (PMs, for a cell/root integration branch — NOT a dev leaf):** `escalate_up(task_id, reason='branch behind base — needs rebase')` — bringing an integration branch current is a platform action. A dev's own leaf branch is the dev's to `sync_branch`.
|
||||
|
||||
## src refspec does not match any (during open_pr)
|
||||
|
||||
@@ -70,7 +72,7 @@ Don't checkout by hand — there is no `roboco_git_checkout` tool.
|
||||
|
||||
**Cause:** Your branch is behind its remote, so the push can't fast-forward.
|
||||
|
||||
**Fix:** Escalate rather than improvise — devs `i_am_blocked(...)`, PMs `escalate_up(...)`. There is no agent-layer pull/rebase to reconcile it.
|
||||
**Fix (devs):** Call `sync_branch(task_id)` to rebase onto your base through the gate, then retry the push-bearing verb (`open_pr` / `i_am_done`). **PMs** escalate: `escalate_up(...)`. There is no agent-layer pull — `sync_branch` is the dev's rebase path.
|
||||
|
||||
## NO_PR on pass / fail
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ escalate_up(
|
||||
|
||||
Auto-routes to your escalation target (you cannot choose it).
|
||||
|
||||
`escalate_up` is refused on a **terminal** task (`completed` / `cancelled`) — it returns `invalid_state` rather than resurrecting a finished task. Escalate live work only.
|
||||
|
||||
## When to Escalate
|
||||
|
||||
| Situation | Escalate To |
|
||||
|
||||
@@ -14,7 +14,7 @@ Hierarchy: Umbrella (Main PM) → Root-subtasks (Main PM) → Cell tasks (cell P
|
||||
- The umbrella does **no git**. It is exempt from the branch gate (it reaches `in_progress` with no branch) and you must **not** call `submit_root` on it — it assembles no PR. Each root-subtask opens and is reviewed on its own PR.
|
||||
- The umbrella **completes** only when every root-subtask is terminal; then it escalates to the CEO (PR requirement waived).
|
||||
- The root-subtasks are sequenced: a wave's tasks dispatch only once the previous wave's tasks reach a terminal state (ordinary dependency-gating). You do not reorder them — the analyzer set the order at create time.
|
||||
- On the Board route the root-subtasks are held in `backlog` until the CEO approves the umbrella, then released to `pending`. On the Approve & Start route they start immediately.
|
||||
- On the Board route the root-subtasks are held in `backlog` until the CEO approves the umbrella, then released to `pending`. On the Approve & Start route they start immediately. On Board-route activation a `code`-typed root-subtask is **retyped to `planning`** — a Main PM never owns a `code` task (the `main_pm + code` combo is the 2026-06-27 meltdown trigger).
|
||||
|
||||
## For the Main PM
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ The claim verb both claims and starts the task — there is no separate `start`
|
||||
|
||||
## Claiming Rules
|
||||
|
||||
- **One at a time (workers only)**: Developers, QA, and documenters can't hold multiple in-progress tasks at once. **PM coordinators are exempt** — a Main / Cell PM plans and delegates many roots in parallel, so it may hold several at once; only a real upstream **sequence dependency** (an unfinished task it depends on) holds one of its roots back.
|
||||
- **One at a time (workers only)**: Developers, QA, and documenters can't hold multiple in-progress tasks at once. A **blocked** task still counts as active — a blocked dev cannot `claim` a second task; unblock or `unclaim` first. **PM coordinators are exempt** — a Main / Cell PM plans and delegates many roots in parallel, so it may hold several at once; only a real upstream **sequence dependency** (an unfinished task it depends on) holds one of its roots back.
|
||||
- **Self-review prevention**: QA cannot `claim_review` tasks they developed
|
||||
- **Self-documentation prevention**: Documenter cannot claim tasks they developed
|
||||
- **Branch requirement**: Branch auto-created on `i_will_work_on`
|
||||
|
||||
@@ -9,7 +9,7 @@ When something goes wrong, the failure is almost always one of a handful of thin
|
||||
| Agents spawn but do nothing useful (tool-discovery churn) | The role's tool-manifest didn't mount; the agent falls back to discovering verbs | Check the manifest mount (below) |
|
||||
| Agent containers respawn in a loop, MCP servers stuck "pending" | MCP server launched without `--no-sync` against a workspace clone | Already fixed in the orchestrator; verify your image is current (below) |
|
||||
| A provider's agents go quiet all at once | The provider is **parked-and-probing** after a 429 / overload / session-limit — not hung | Wait; it self-resumes. See [Resilience](../models/resilience.md) |
|
||||
| A task sits **blocked: "branch behind base / needs rebase"** | The agent's branch fell behind its base while it worked; there is no agent-layer rebase verb, so the agent escalates instead of improvising | Rebase it yourself from the panel **Git** tab (below) |
|
||||
| A task sits **blocked: "branch behind base / needs rebase"** | The agent's branch fell behind its base while it worked; devs now self-rebase via the `sync_branch` gate verb, so a leaf branch rarely blocks here. A **cell/root integration branch** behind its base still escalates (PMs have no rebase verb) | Rebase the integration branch from the panel **Git** tab (below) |
|
||||
| KB / `ask_mentor` returns nothing | Ollama unhealthy or models not pulled | Check `ollama-init` logs (below) |
|
||||
| Agent containers exit immediately | `~/.claude` not mounted, or a Grok token expired | Check the mount / refresh the token (below) |
|
||||
| Clone fails, agent can't reach the repo | Missing or invalid project PAT, or HTTPS URL with no token | Set the project token (below) |
|
||||
@@ -76,7 +76,7 @@ When the orchestrator won't come up at all on a fresh deploy:
|
||||
|
||||
## A task is stuck on a branch behind its base
|
||||
|
||||
Agents have no rebase, pull, or merge verb — a task branch is brought current with its base only at claim. If the base (a cell branch, or master) moves forward while the agent works, the branch falls behind, and the agent escalates rather than improvising git surgery: the task surfaces **blocked** with a reason like *"branch behind base — needs rebase."* That escalation is by design — bringing the branch current is your call, not a unit of work the company decomposes. Rebase it from the panel **Git** tab — select the branch and **Rebase** it onto its base (or master) — and the task resumes on the next dispatch. (Automatic rebase-at-spawn, so a stale branch never reaches you at all, is on the roadmap.)
|
||||
A task branch is brought current with its base only at claim. If the base moves forward while the agent works, the branch falls behind. **Developer leaf branches** now self-rebase through the `sync_branch` gate verb — the dev calls `sync_branch(task_id)` (or `i_am_done` refuses a behind branch and points them at it), so a leaf rarely escalates to you. What still escalates is a **cell or root integration branch** behind its base: PMs have no rebase verb, so a PM `escalate_up`s and the task surfaces **blocked** with a reason like *"branch behind base — needs rebase."* That escalation is by design for an integration branch — bringing it current is your call, not a unit of work the company decomposes. Rebase it from the panel **Git** tab — select the branch and **Rebase** it onto its base (or master) — and the task resumes on the next dispatch. (Automatic rebase-at-spawn, so a stale branch never reaches you at all, is on the roadmap.)
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -45,9 +45,16 @@ Project GitHub tokens are **encrypted the moment you save them** (with `ROBOCO_E
|
||||
|
||||
This is the guarantee that makes it safe to hand RoboCo a private repo: **your GitHub PAT is never present inside an agent container.** The orchestrator decrypts the token only at the moment of a git operation, injects it for that operation, and immediately after cloning **scrubs the token out of the clone's git config** — then verifies no token byte survives anywhere under `.git/`, destroying the workspace if one did. A compromised or misbehaving agent has nothing to exfiltrate, because the credential was never on its disk. The clone scrub is described in [Register a project](../get-started/first-project.md#what-happens-under-the-hood), and the broader sandboxing model in [the gateway](../company/agent-gateway.md).
|
||||
|
||||
## WebSocket auth caveat
|
||||
## WebSocket + live-chat auth
|
||||
|
||||
The per-resource WebSocket streams are keyed to a resource, but the operator stream is not authenticated. **`/ws/system` carries no per-agent keying and no token** even when `ROBOCO_AGENT_AUTH_REQUIRED=true` — secure mode does not extend to it. It is **read-only** (it carries system events like rate-limit lifecycle and usage snapshots; it accepts nothing from the client), so the exposure is limited to a reader seeing system telemetry. It is, however, one more reason the system must sit on a trusted network: anyone who can open that socket can watch the operator stream.
|
||||
Secure mode (`ROBOCO_AGENT_AUTH_REQUIRED=true`) extends to the live streams and the content routes, not just REST:
|
||||
|
||||
- The **per-resource WebSocket streams** (`/ws/channels|agents|sessions|notifications/{id}`) require the **CEO panel token** — the signed `X-Agent-Token` nginx injects for the panel — on top of their `agent_id`/`viewer_id` DB validation. An agent on the Docker network can no longer subscribe to another agent's notifications with no auth.
|
||||
- The **`/api/v1/do/*` content routes** require a per-agent HMAC token bound to `X-Agent-ID`.
|
||||
- The **live-chat bridges** (`/prompter/live/*`, `/secretary/live/*`) require the CEO panel token — they were the last panel-facing surface that ran unauthenticated.
|
||||
- **`/ws/system`** is the one exception: it stays operator-only and read-only by design (system telemetry, no client input), and is not token-gated. It is one more reason the system must sit on a trusted network: anyone who can open that socket can watch the operator stream.
|
||||
|
||||
A presented-but-forged token is rejected even in header-trust (dev) mode, so rolling out tokens before flipping the switch breaks nothing.
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Build / generated output
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
# Deps + lockfiles
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Generated
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -334,7 +334,7 @@
|
||||
"composes": [
|
||||
"submit_for_review"
|
||||
],
|
||||
"description": "Main PM opens the root\u2192master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. Only for code roots; branchless coordination roots skip the gate and complete directly.",
|
||||
"description": "Main PM opens the root\u2192master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed \u2014 a Main-PM root is planning-typed, never code.",
|
||||
"name": "submit_root",
|
||||
"pre_side_effects": [
|
||||
"create_root_pr"
|
||||
@@ -355,6 +355,16 @@
|
||||
],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"developer"
|
||||
],
|
||||
"composes": [],
|
||||
"description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base \u2014 e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned \u2014 resolve by hand, commit, then sync_branch again.",
|
||||
"name": "sync_branch",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"auditor",
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "roboco-panel",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.25.0",
|
||||
"scripts": {
|
||||
@@ -10,7 +10,9 @@
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -66,6 +68,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
|
||||
Generated
+13
-3
@@ -109,7 +109,7 @@ importers:
|
||||
version: 10.1.0(@types/react@19.2.8)(react@19.2.3)
|
||||
recharts:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
|
||||
version: 3.8.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1)
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -162,6 +162,9 @@ importers:
|
||||
jsdom:
|
||||
specifier: ^29.1.1
|
||||
version: 29.1.1
|
||||
prettier:
|
||||
specifier: ^3.8.5
|
||||
version: 3.8.5
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.1.18
|
||||
@@ -3230,6 +3233,11 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier@3.8.5:
|
||||
resolution: {integrity: sha512-zxcTTCedNGJM4R8sj/Cq/F0W/c4iE0afWBcBwMTRtw4WHYP9TWkYjdiH3npPRUYsXQCPR0hTU9yjovOu+E6EQA==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
pretty-format@27.5.1:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
@@ -7228,6 +7236,8 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@3.8.5: {}
|
||||
|
||||
pretty-format@27.5.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
@@ -7317,7 +7327,7 @@ snapshots:
|
||||
|
||||
react@19.2.3: {}
|
||||
|
||||
recharts@3.8.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
|
||||
recharts@3.8.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
|
||||
clsx: 2.1.1
|
||||
@@ -7327,7 +7337,7 @@ snapshots:
|
||||
immer: 10.2.0
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-is: 16.13.1
|
||||
react-is: 17.0.2
|
||||
react-redux: 9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
|
||||
@@ -2,12 +2,32 @@
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useAgentStatus, useStopAgent, useSpawnAgent, useAgentDefinition } from "@/hooks/use-agents";
|
||||
import {
|
||||
useAgentStatus,
|
||||
useStopAgent,
|
||||
useSpawnAgent,
|
||||
useAgentDefinition,
|
||||
} from "@/hooks/use-agents";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ArrowLeft, Play, Square, AlertTriangle, Clock, RefreshCw, User, Users } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Play,
|
||||
Square,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
User,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AgentStatusCards,
|
||||
@@ -50,13 +70,19 @@ export default function AgentDetailPage() {
|
||||
|
||||
// Get display values from definition or fallback
|
||||
const displayName = definition?.name || agentId;
|
||||
const roleLabel = definition?.role ? ROLE_LABELS[definition.role] || definition.role : null;
|
||||
const teamLabel = definition?.team ? TEAM_LABELS[definition.team] || definition.team : null;
|
||||
const roleLabel = definition?.role
|
||||
? ROLE_LABELS[definition.role] || definition.role
|
||||
: null;
|
||||
const teamLabel = definition?.team
|
||||
? TEAM_LABELS[definition.team] || definition.team
|
||||
: null;
|
||||
|
||||
const handleStop = async (graceful: boolean) => {
|
||||
try {
|
||||
await stopAgent.mutateAsync({ agentId, graceful });
|
||||
toast.success(graceful ? "Agent stopping gracefully" : "Agent force stopped");
|
||||
toast.success(
|
||||
graceful ? "Agent stopping gracefully" : "Agent force stopped",
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to stop agent");
|
||||
}
|
||||
@@ -97,7 +123,9 @@ export default function AgentDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = agent && ["running", "ready", "starting", "waiting_long"].includes(agent.state);
|
||||
const isActive =
|
||||
agent &&
|
||||
["running", "ready", "starting", "waiting_long"].includes(agent.state);
|
||||
const isWaiting = agent?.state === "waiting_long";
|
||||
|
||||
return (
|
||||
@@ -111,7 +139,9 @@ export default function AgentDetailPage() {
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{displayName}</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{displayName}
|
||||
</h1>
|
||||
{roleLabel && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
@@ -199,7 +229,9 @@ export default function AgentDetailPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-lg font-semibold text-red-600">{agent.error_count} error(s)</p>
|
||||
<p className="text-lg font-semibold text-red-600">
|
||||
{agent.error_count} error(s)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -213,20 +245,23 @@ export default function AgentDetailPage() {
|
||||
Agent Waiting for Input
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This agent is blocked and waiting for human input or external resolution.
|
||||
This agent is blocked and waiting for human input or external
|
||||
resolution.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
Use the "Resolve Wait" button above to provide the information or decision
|
||||
the agent needs to continue execution.
|
||||
Use the "Resolve Wait" button above to provide the
|
||||
information or decision the agent needs to continue execution.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Agent Stream Viewer */}
|
||||
{isActive && <AgentStreamViewer agentId={agentId} agentName={displayName} />}
|
||||
{isActive && (
|
||||
<AgentStreamViewer agentId={agentId} agentName={displayName} />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -32,11 +32,11 @@ export default function AgentsPage() {
|
||||
const { data: usageRows } = useAgentUsage();
|
||||
|
||||
// Check if it's a connection error (backend not running)
|
||||
const isOffline = error && (
|
||||
error.message?.includes("Network Error") ||
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
error.message?.includes("ECONNREFUSED") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK"
|
||||
);
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
// Convert agents array to a record keyed by agent_id for easy lookup
|
||||
const agentStatuses = useMemo(() => {
|
||||
@@ -86,7 +86,9 @@ export default function AgentsPage() {
|
||||
<OrchestratorStatusCards status={status} isLoading={isLoading} />
|
||||
|
||||
{/* Waiting Agents Alert */}
|
||||
{waitingAgents && <WaitingAgentsAlert waitingAgents={waitingAgents} />}
|
||||
{waitingAgents && (
|
||||
<WaitingAgentsAlert waitingAgents={waitingAgents} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ function BusinessPageContent() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Business</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Company goals, your chief-of-staff Secretary, and Board pitches — all in
|
||||
one place.
|
||||
Company goals, your chief-of-staff Secretary, and Board pitches — all
|
||||
in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ function SessionDetailContent() {
|
||||
const groupId = searchParams.get("group");
|
||||
|
||||
// Build back URL preserving context
|
||||
const backUrl = channelId && groupId
|
||||
const backUrl =
|
||||
channelId && groupId
|
||||
? `/communications?channel=${channelId}&group=${groupId}`
|
||||
: "/communications";
|
||||
const queryClient = useQueryClient();
|
||||
@@ -52,12 +53,20 @@ function SessionDetailContent() {
|
||||
// is what stops the panel from accumulating a 404 storm across every dead
|
||||
// session it has opened. `refetchMessages` (the manual Refresh button) stays
|
||||
// available for live sessions.
|
||||
const { data: session, isLoading: loadingSession, refetch: refetchSession } = useSession(sessionId);
|
||||
const { data: messagesData, isLoading: loadingMessages, refetch: refetchMessages } = useSessionMessages(sessionId);
|
||||
const {
|
||||
data: session,
|
||||
isLoading: loadingSession,
|
||||
refetch: refetchSession,
|
||||
} = useSession(sessionId);
|
||||
const {
|
||||
data: messagesData,
|
||||
isLoading: loadingMessages,
|
||||
refetch: refetchMessages,
|
||||
} = useSessionMessages(sessionId);
|
||||
|
||||
// Sort messages chronologically (oldest first for chat UI)
|
||||
const messages = [...(messagesData?.items || [])].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
// Track if we've done the initial scroll
|
||||
@@ -73,11 +82,19 @@ function SessionDetailContent() {
|
||||
|
||||
// Send message mutation
|
||||
const sendMessage = useMutation({
|
||||
mutationFn: async ({ content, type }: { content: string; type: string }) => {
|
||||
mutationFn: async ({
|
||||
content,
|
||||
type,
|
||||
}: {
|
||||
content: string;
|
||||
type: string;
|
||||
}) => {
|
||||
return messagesApi.send(sessionId, content, type);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["messages", "list", sessionId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["messages", "list", sessionId],
|
||||
});
|
||||
toast.success("Message sent");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -95,7 +112,8 @@ function SessionDetailContent() {
|
||||
};
|
||||
|
||||
// Get primary task
|
||||
const primaryTask = session?.task_links?.find(t => t.is_primary) || session?.task_links?.[0];
|
||||
const primaryTask =
|
||||
session?.task_links?.find((t) => t.is_primary) || session?.task_links?.[0];
|
||||
|
||||
if (loadingSession) {
|
||||
return (
|
||||
@@ -122,7 +140,8 @@ function SessionDetailContent() {
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">Session Not Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The session you're looking for doesn't exist or has been deleted.
|
||||
The session you're looking for doesn't exist or has
|
||||
been deleted.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -162,7 +181,9 @@ function SessionDetailContent() {
|
||||
<Card className="mb-4 shrink-0">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Badge variant={session.status === "active" ? "default" : "secondary"}>
|
||||
<Badge
|
||||
variant={session.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{session.status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
@@ -175,7 +196,8 @@ function SessionDetailContent() {
|
||||
</div>
|
||||
{session.closed_at && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
Closed: {format(new Date(session.closed_at), "MMM d, yyyy h:mm a")}
|
||||
Closed:{" "}
|
||||
{format(new Date(session.closed_at), "MMM d, yyyy h:mm a")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -190,7 +212,8 @@ function SessionDetailContent() {
|
||||
href={`/tasks/${primaryTask.task_id}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{primaryTask.task_title || `Task ${primaryTask.task_id.slice(0, 8)}`}
|
||||
{primaryTask.task_title ||
|
||||
`Task ${primaryTask.task_id.slice(0, 8)}`}
|
||||
</Link>
|
||||
)}
|
||||
{session.task_links.length > 1 && (
|
||||
@@ -232,7 +255,9 @@ function SessionDetailContent() {
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No messages in this session</p>
|
||||
<p className="text-sm">Use the composer below to start the conversation</p>
|
||||
<p className="text-sm">
|
||||
Use the composer below to start the conversation
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -37,12 +37,17 @@ interface ChannelListProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ChannelList({ channels, selectedId, onSelect, isLoading }: ChannelListProps) {
|
||||
function ChannelList({
|
||||
channels,
|
||||
selectedId,
|
||||
onSelect,
|
||||
isLoading,
|
||||
}: ChannelListProps) {
|
||||
const cellChannels = channels.filter((c) => c.type === "cell");
|
||||
const crossCellChannels = channels.filter((c) => c.type === "cross_cell");
|
||||
const managementChannels = channels.filter((c) => c.type === "management");
|
||||
const otherChannels = channels.filter(
|
||||
(c) => !["cell", "cross_cell", "management"].includes(c.type)
|
||||
(c) => !["cell", "cross_cell", "management"].includes(c.type),
|
||||
);
|
||||
|
||||
const renderGroup = (title: string, items: Channel[]) => {
|
||||
@@ -208,7 +213,8 @@ function SessionList({ channelId, groupId }: SessionListProps) {
|
||||
<div className="font-medium text-sm truncate">
|
||||
{session.task_links?.length > 0 ? (
|
||||
<>
|
||||
{session.task_links.find(l => l.is_primary)?.task_title ||
|
||||
{session.task_links.find((l) => l.is_primary)
|
||||
?.task_title ||
|
||||
session.task_links[0]?.task_title ||
|
||||
`Task ${session.task_links[0]?.task_id.slice(0, 8)}`}
|
||||
</>
|
||||
@@ -222,7 +228,9 @@ function SessionList({ channelId, groupId }: SessionListProps) {
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Badge
|
||||
variant={session.status === "active" ? "default" : "secondary"}
|
||||
variant={
|
||||
session.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{session.status}
|
||||
@@ -243,7 +251,13 @@ function SessionList({ channelId, groupId }: SessionListProps) {
|
||||
// Empty State Components
|
||||
// =============================================================================
|
||||
|
||||
function EmptyPanel({ icon: Icon, message }: { icon: typeof MessageSquare; message: string }) {
|
||||
function EmptyPanel({
|
||||
icon: Icon,
|
||||
message,
|
||||
}: {
|
||||
icon: typeof MessageSquare;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
@@ -267,10 +281,10 @@ function CommunicationsPageContent() {
|
||||
|
||||
const { data: channels, isLoading, error, refetch } = useChannels();
|
||||
|
||||
const isOffline = error && (
|
||||
error.message?.includes("Network Error") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK"
|
||||
);
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
@@ -285,18 +299,24 @@ function CommunicationsPageContent() {
|
||||
const query = params.toString();
|
||||
router.push(query ? `/communications?${query}` : "/communications");
|
||||
},
|
||||
[router, searchParams]
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSelectChannel = useCallback((id: string) => {
|
||||
const handleSelectChannel = useCallback(
|
||||
(id: string) => {
|
||||
updateParams({ channel: id, group: null });
|
||||
}, [updateParams]);
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleSelectGroup = useCallback((id: string) => {
|
||||
const handleSelectGroup = useCallback(
|
||||
(id: string) => {
|
||||
updateParams({ group: id });
|
||||
}, [updateParams]);
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const selectedChannel = channels?.find(c => c.id === channelId);
|
||||
const selectedChannel = channels?.find((c) => c.id === channelId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
|
||||
@@ -347,7 +367,10 @@ function CommunicationsPageContent() {
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Groups</span>
|
||||
{selectedChannel && (
|
||||
<Badge variant="outline" className="ml-auto text-xs font-normal">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto text-xs font-normal"
|
||||
>
|
||||
{selectedChannel.name}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -379,7 +402,11 @@ function CommunicationsPageContent() {
|
||||
) : (
|
||||
<EmptyPanel
|
||||
icon={MessageSquare}
|
||||
message={channelId ? "Select a group" : "Select a channel and group"}
|
||||
message={
|
||||
channelId
|
||||
? "Select a group"
|
||||
: "Select a channel and group"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -394,7 +421,8 @@ function CommunicationsPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function CommunicationsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
@@ -420,7 +448,8 @@ export default function CommunicationsPage() {
|
||||
<Card className="col-span-12 lg:col-span-6" />
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<CommunicationsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -144,7 +144,10 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
|
||||
<span>Related Task</span>
|
||||
</div>
|
||||
<Link href={`/tasks/${entry.task_id}`}>
|
||||
<Badge variant="outline" className="hover:bg-muted cursor-pointer">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="hover:bg-muted cursor-pointer"
|
||||
>
|
||||
Task #{entry.task_id.slice(0, 8)}
|
||||
</Badge>
|
||||
</Link>
|
||||
|
||||
@@ -89,7 +89,8 @@ function JournalsPageContent() {
|
||||
}, [selectedAgentId, agentSearch, urlType, taskFilter]);
|
||||
|
||||
// Update URL params
|
||||
const updateParams = useCallback((updates: Record<string, string | null>) => {
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
@@ -100,26 +101,40 @@ function JournalsPageContent() {
|
||||
});
|
||||
const query = params.toString();
|
||||
router.push(query ? `/journals?${query}` : "/journals");
|
||||
}, [router, searchParams]);
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSelectAgent = useCallback((agentId: string | null) => {
|
||||
const handleSelectAgent = useCallback(
|
||||
(agentId: string | null) => {
|
||||
// Only reset filters when changing to a different agent
|
||||
if (agentId !== selectedAgentId) {
|
||||
updateParams({ agent: agentId, type: null, task: null });
|
||||
}
|
||||
}, [updateParams, selectedAgentId]);
|
||||
},
|
||||
[updateParams, selectedAgentId],
|
||||
);
|
||||
|
||||
const handleAgentSearch = useCallback((value: string) => {
|
||||
const handleAgentSearch = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({ q: value || null });
|
||||
}, [updateParams]);
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback((value: JournalEntryType | "all") => {
|
||||
const handleTypeChange = useCallback(
|
||||
(value: JournalEntryType | "all") => {
|
||||
updateParams({ type: value === "all" ? null : value });
|
||||
}, [updateParams]);
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleTaskChange = useCallback((value: string | null) => {
|
||||
const handleTaskChange = useCallback(
|
||||
(value: string | null) => {
|
||||
updateParams({ task: value });
|
||||
}, [updateParams]);
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// Filter agents by search
|
||||
const filteredAgents = (agents ?? []).filter((agent) => {
|
||||
@@ -211,7 +226,8 @@ function JournalsPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function JournalsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -239,7 +255,8 @@ export default function JournalsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<JournalsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { DevKanban, QaKanban, PrReviewKanban, PmKanban } from "@/components/kanban";
|
||||
import {
|
||||
DevKanban,
|
||||
QaKanban,
|
||||
PrReviewKanban,
|
||||
PmKanban,
|
||||
} from "@/components/kanban";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Code, TestTube, GitPullRequest, ClipboardList } from "lucide-react";
|
||||
@@ -66,7 +71,8 @@ function KanbanPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function KanbanPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
@@ -75,7 +81,8 @@ export default function KanbanPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<KanbanPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,10 @@ import {
|
||||
Coins,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { UsageProjection as UP, CacheEfficiencyResponse as CER } from "@/types";
|
||||
import type {
|
||||
UsageProjection as UP,
|
||||
CacheEfficiencyResponse as CER,
|
||||
} from "@/types";
|
||||
|
||||
// ─── Humanized number formatting ─────────────────────────────────────────────
|
||||
|
||||
@@ -71,7 +74,14 @@ interface MetricCardProps {
|
||||
trendValue?: string;
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, subtitle, icon, trend, trendValue }: MetricCardProps) {
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon,
|
||||
trend,
|
||||
trendValue,
|
||||
}: MetricCardProps) {
|
||||
const displayValue = typeof value === "number" ? humanizeCount(value) : value;
|
||||
return (
|
||||
<Card>
|
||||
@@ -87,10 +97,19 @@ function MetricCard({ title, value, subtitle, icon, trend, trendValue }: MetricC
|
||||
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
|
||||
)}
|
||||
{trend && trendValue && (
|
||||
<div className={"flex items-center gap-1 mt-2 text-xs " +
|
||||
(trend === "up" ? "text-green-600" : trend === "down" ? "text-red-600" : "text-gray-500")
|
||||
}>
|
||||
<TrendingUp className={"h-3 w-3 " + (trend === "down" ? "rotate-180" : "")} />
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-1 mt-2 text-xs " +
|
||||
(trend === "up"
|
||||
? "text-green-600"
|
||||
: trend === "down"
|
||||
? "text-red-600"
|
||||
: "text-gray-500")
|
||||
}
|
||||
>
|
||||
<TrendingUp
|
||||
className={"h-3 w-3 " + (trend === "down" ? "rotate-180" : "")}
|
||||
/>
|
||||
{trendValue}
|
||||
</div>
|
||||
)}
|
||||
@@ -106,8 +125,13 @@ interface TeamHealthCardProps {
|
||||
completedToday: number;
|
||||
}
|
||||
|
||||
function TeamHealthCard({ team, activeTasks, blockedTasks, completedToday }: TeamHealthCardProps) {
|
||||
const healthScore = Math.max(0, 100 - (blockedTasks * 20));
|
||||
function TeamHealthCard({
|
||||
team,
|
||||
activeTasks,
|
||||
blockedTasks,
|
||||
completedToday,
|
||||
}: TeamHealthCardProps) {
|
||||
const healthScore = Math.max(0, 100 - blockedTasks * 20);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -144,12 +168,16 @@ function TeamHealthCard({ team, activeTasks, blockedTasks, completedToday }: Tea
|
||||
|
||||
function PerformanceTabContent() {
|
||||
const { data: tasks, error: tasksError, refetch: refetchTasks } = useTasks();
|
||||
const { data: status, error: statusError, refetch: refetchStatus } = useOrchestratorStatus();
|
||||
const {
|
||||
data: status,
|
||||
error: statusError,
|
||||
refetch: refetchStatus,
|
||||
} = useOrchestratorStatus();
|
||||
|
||||
const isOffline = (tasksError || statusError) && (
|
||||
tasksError?.message?.includes("Network Error") ||
|
||||
statusError?.message?.includes("Network Error")
|
||||
);
|
||||
const isOffline =
|
||||
(tasksError || statusError) &&
|
||||
(tasksError?.message?.includes("Network Error") ||
|
||||
statusError?.message?.includes("Network Error"));
|
||||
|
||||
const refetch = () => {
|
||||
refetchTasks();
|
||||
@@ -176,17 +204,35 @@ function PerformanceTabContent() {
|
||||
}).length;
|
||||
|
||||
// Task status counts
|
||||
const pending = taskList.filter((t) => t.status === TaskStatus.PENDING).length;
|
||||
const inProgress = taskList.filter((t) => t.status === TaskStatus.IN_PROGRESS).length;
|
||||
const blocked = taskList.filter((t) => t.status === TaskStatus.BLOCKED).length;
|
||||
const awaitingQa = taskList.filter((t) => t.status === TaskStatus.AWAITING_QA).length;
|
||||
const completed = taskList.filter((t) => t.status === TaskStatus.COMPLETED).length;
|
||||
const pending = taskList.filter(
|
||||
(t) => t.status === TaskStatus.PENDING,
|
||||
).length;
|
||||
const inProgress = taskList.filter(
|
||||
(t) => t.status === TaskStatus.IN_PROGRESS,
|
||||
).length;
|
||||
const blocked = taskList.filter(
|
||||
(t) => t.status === TaskStatus.BLOCKED,
|
||||
).length;
|
||||
const awaitingQa = taskList.filter(
|
||||
(t) => t.status === TaskStatus.AWAITING_QA,
|
||||
).length;
|
||||
const completed = taskList.filter(
|
||||
(t) => t.status === TaskStatus.COMPLETED,
|
||||
).length;
|
||||
|
||||
// Agent counts
|
||||
const runningAgents = status?.by_state?.running || agentList.filter((a) => a.state === "running").length;
|
||||
const idleAgents = status?.by_state?.idle || agentList.filter((a) => a.state === "idle" || a.state === "stopped").length;
|
||||
const waitingAgents = status?.waiting_count || agentList.filter((a) => a.state === "waiting_long").length;
|
||||
const errorAgents = status?.by_state?.error || agentList.filter((a) => a.state === "error").length;
|
||||
const runningAgents =
|
||||
status?.by_state?.running ||
|
||||
agentList.filter((a) => a.state === "running").length;
|
||||
const idleAgents =
|
||||
status?.by_state?.idle ||
|
||||
agentList.filter((a) => a.state === "idle" || a.state === "stopped").length;
|
||||
const waitingAgents =
|
||||
status?.waiting_count ||
|
||||
agentList.filter((a) => a.state === "waiting_long").length;
|
||||
const errorAgents =
|
||||
status?.by_state?.error ||
|
||||
agentList.filter((a) => a.state === "error").length;
|
||||
|
||||
// Team metrics
|
||||
const teamMetrics = Object.values(Team).map((team) => {
|
||||
@@ -194,9 +240,10 @@ function PerformanceTabContent() {
|
||||
return {
|
||||
team,
|
||||
activeTasks: teamTasks.filter((t) =>
|
||||
[TaskStatus.IN_PROGRESS, TaskStatus.CLAIMED].includes(t.status)
|
||||
[TaskStatus.IN_PROGRESS, TaskStatus.CLAIMED].includes(t.status),
|
||||
).length,
|
||||
blockedTasks: teamTasks.filter((t) => t.status === TaskStatus.BLOCKED).length,
|
||||
blockedTasks: teamTasks.filter((t) => t.status === TaskStatus.BLOCKED)
|
||||
.length,
|
||||
completedToday: teamTasks.filter((t) => {
|
||||
if (!t.completed_at) return false;
|
||||
const c = new Date(t.completed_at);
|
||||
@@ -242,7 +289,11 @@ function PerformanceTabContent() {
|
||||
/>
|
||||
<MetricCard
|
||||
title="Completion Rate"
|
||||
value={taskList.length > 0 ? Math.round((completed / taskList.length) * 100) + "%" : "0%"}
|
||||
value={
|
||||
taskList.length > 0
|
||||
? Math.round((completed / taskList.length) * 100) + "%"
|
||||
: "0%"
|
||||
}
|
||||
subtitle="Of all tasks"
|
||||
icon={<Activity className="h-4 w-4 text-purple-500" />}
|
||||
/>
|
||||
@@ -341,7 +392,8 @@ function TokenUsageCostsSection() {
|
||||
const { data: sessions, isLoading: loadingSessions } = useUsageSessions(100);
|
||||
const { data: modelUsage, isLoading: loadingModels } = useModelUsage("24h");
|
||||
const { data: projection, isLoading: loadingProj } = useUsageProjection();
|
||||
const { data: cacheStats, isLoading: loadingCache } = useCacheEfficiency("24h");
|
||||
const { data: cacheStats, isLoading: loadingCache } =
|
||||
useCacheEfficiency("24h");
|
||||
|
||||
const trendUp = (summary?.trend_pct ?? 0) >= 0;
|
||||
|
||||
@@ -369,7 +421,11 @@ function TokenUsageCostsSection() {
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Trend vs Prior"
|
||||
value={summary ? (trendUp ? "+" : "") + summary.trend_pct.toFixed(1) + "%" : undefined}
|
||||
value={
|
||||
summary
|
||||
? (trendUp ? "+" : "") + summary.trend_pct.toFixed(1) + "%"
|
||||
: undefined
|
||||
}
|
||||
icon={
|
||||
trendUp ? (
|
||||
<TrendingUp className="h-4 w-4 text-red-500" />
|
||||
@@ -387,7 +443,11 @@ function TokenUsageCostsSection() {
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Cache Saved"
|
||||
value={cacheStats ? "$" + cacheStats.cost_saved_by_cache_usd.toFixed(4) : undefined}
|
||||
value={
|
||||
cacheStats
|
||||
? "$" + cacheStats.cost_saved_by_cache_usd.toFixed(4)
|
||||
: undefined
|
||||
}
|
||||
icon={<Sparkles className="h-4 w-4 text-purple-500" />}
|
||||
isLoading={loadingCache}
|
||||
/>
|
||||
@@ -429,11 +489,19 @@ interface SummaryCardProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function SummaryCard({ title, value, icon, trend, isLoading }: SummaryCardProps) {
|
||||
function SummaryCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
trend,
|
||||
isLoading,
|
||||
}: SummaryCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -479,7 +547,9 @@ function ProjectionCard({ projection, isLoading }: ProjectionCardProps) {
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-3xl font-bold">
|
||||
{projection != null ? "$" + projection.projected_monthly_cost_usd.toFixed(2) : "—"}
|
||||
{projection != null
|
||||
? "$" + projection.projected_monthly_cost_usd.toFixed(2)
|
||||
: "—"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Based on {projection?.basis_days ?? 7}-day rolling average ($
|
||||
@@ -497,7 +567,10 @@ interface CacheEfficiencyCardProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps) {
|
||||
function CacheEfficiencyCard({
|
||||
cacheStats,
|
||||
isLoading,
|
||||
}: CacheEfficiencyCardProps) {
|
||||
const pct = cacheStats ? cacheStats.cache_hit_rate * 100 : 0;
|
||||
|
||||
return (
|
||||
@@ -515,8 +588,9 @@ function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{pct.toFixed(1)}%</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache reads ·
|
||||
saved ${cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
|
||||
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache
|
||||
reads · saved $
|
||||
{cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
|
||||
</p>
|
||||
<Progress value={pct} className="mt-2" />
|
||||
</div>
|
||||
@@ -530,7 +604,11 @@ function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps
|
||||
|
||||
type MetricsTab = "performance" | "token-usage" | "delivery";
|
||||
|
||||
const VALID_METRICS_TABS: MetricsTab[] = ["performance", "token-usage", "delivery"];
|
||||
const VALID_METRICS_TABS: MetricsTab[] = [
|
||||
"performance",
|
||||
"token-usage",
|
||||
"delivery",
|
||||
];
|
||||
|
||||
function isValidMetricsTab(value: string | null): value is MetricsTab {
|
||||
return VALID_METRICS_TABS.includes(value as MetricsTab);
|
||||
@@ -544,7 +622,9 @@ function MetricsPageContent() {
|
||||
|
||||
// Read ?tab= from URL, default to "performance"
|
||||
const rawTab = searchParams.get("tab");
|
||||
const activeTab: MetricsTab = isValidMetricsTab(rawTab) ? rawTab : "performance";
|
||||
const activeTab: MetricsTab = isValidMetricsTab(rawTab)
|
||||
? rawTab
|
||||
: "performance";
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -590,7 +670,8 @@ function MetricsPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function MetricsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -615,7 +696,8 @@ export default function MetricsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<MetricsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -34,21 +34,38 @@ import { toast } from "sonner";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
|
||||
const typeIcons: Record<NotificationType, React.ReactNode> = {
|
||||
[NotificationType.TASK_ASSIGNMENT]: <ListTodo className="h-4 w-4 text-green-500" />,
|
||||
[NotificationType.PRIORITY_CHANGE]: <ArrowUpCircle className="h-4 w-4 text-orange-500" />,
|
||||
[NotificationType.BLOCKER_ESCALATION]: <AlertTriangle className="h-4 w-4 text-red-500" />,
|
||||
[NotificationType.REVIEW_REQUEST]: <Check className="h-4 w-4 text-purple-500" />,
|
||||
[NotificationType.DOCUMENTATION_REQUEST]: <Info className="h-4 w-4 text-blue-500" />,
|
||||
[NotificationType.ALERT]: <AlertTriangle className="h-4 w-4 text-yellow-500" />,
|
||||
[NotificationType.TASK_ASSIGNMENT]: (
|
||||
<ListTodo className="h-4 w-4 text-green-500" />
|
||||
),
|
||||
[NotificationType.PRIORITY_CHANGE]: (
|
||||
<ArrowUpCircle className="h-4 w-4 text-orange-500" />
|
||||
),
|
||||
[NotificationType.BLOCKER_ESCALATION]: (
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
),
|
||||
[NotificationType.REVIEW_REQUEST]: (
|
||||
<Check className="h-4 w-4 text-purple-500" />
|
||||
),
|
||||
[NotificationType.DOCUMENTATION_REQUEST]: (
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
),
|
||||
[NotificationType.ALERT]: (
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
),
|
||||
[NotificationType.BROADCAST]: <Bell className="h-4 w-4 text-gray-500" />,
|
||||
[NotificationType.KNOWLEDGE_SHARE]: <BookOpen className="h-4 w-4 text-cyan-500" />,
|
||||
[NotificationType.KNOWLEDGE_SHARE]: (
|
||||
<BookOpen className="h-4 w-4 text-cyan-500" />
|
||||
),
|
||||
[NotificationType.MENTION]: <AtSign className="h-4 w-4 text-indigo-500" />,
|
||||
};
|
||||
|
||||
const priorityColors: Record<NotificationPriority, string> = {
|
||||
[NotificationPriority.NORMAL]: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
[NotificationPriority.HIGH]: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
||||
[NotificationPriority.URGENT]: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
[NotificationPriority.NORMAL]:
|
||||
"bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
[NotificationPriority.HIGH]:
|
||||
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
||||
[NotificationPriority.URGENT]:
|
||||
"bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
};
|
||||
|
||||
interface NotificationCardProps {
|
||||
@@ -57,23 +74,37 @@ interface NotificationCardProps {
|
||||
onAcknowledge: () => void;
|
||||
}
|
||||
|
||||
function NotificationCard({ notification, onMarkRead, onAcknowledge }: NotificationCardProps) {
|
||||
function NotificationCard({
|
||||
notification,
|
||||
onMarkRead,
|
||||
onAcknowledge,
|
||||
}: NotificationCardProps) {
|
||||
return (
|
||||
<Card className={notification.is_read ? "opacity-70" : "border-l-4 border-l-primary"}>
|
||||
<Card
|
||||
className={
|
||||
notification.is_read ? "opacity-70" : "border-l-4 border-l-primary"
|
||||
}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">{typeIcons[notification.type]}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{notification.subject}</span>
|
||||
<Badge className={priorityColors[notification.priority] + " text-xs"}>
|
||||
<Badge
|
||||
className={priorityColors[notification.priority] + " text-xs"}
|
||||
>
|
||||
{notification.priority}
|
||||
</Badge>
|
||||
{!notification.is_read && (
|
||||
<Badge variant="secondary" className="text-xs">New</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
{notification.requires_ack && !notification.is_acknowledged && (
|
||||
<Badge variant="destructive" className="text-xs">Needs Ack</Badge>
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Needs Ack
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
@@ -129,19 +160,21 @@ function NotificationsPageContent() {
|
||||
}
|
||||
|
||||
const { data, isLoading, error, refetch } = useNotifications(
|
||||
activeTab === "unread" ? { unread_only: true } :
|
||||
activeTab === "pending" ? { pending_ack_only: true } :
|
||||
undefined
|
||||
activeTab === "unread"
|
||||
? { unread_only: true }
|
||||
: activeTab === "pending"
|
||||
? { pending_ack_only: true }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const markRead = useMarkNotificationRead();
|
||||
const acknowledge = useAcknowledgeNotification();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
|
||||
const isOffline = error && (
|
||||
error.message?.includes("Network Error") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK"
|
||||
);
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
try {
|
||||
@@ -213,7 +246,9 @@ function NotificationsPageContent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{data.unread_count}</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{data.unread_count}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -224,7 +259,9 @@ function NotificationsPageContent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{data.pending_ack_count}</div>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{data.pending_ack_count}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -244,7 +281,10 @@ function NotificationsPageContent() {
|
||||
Unread {data && data.unread_count > 0 && `(${data.unread_count})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pending">
|
||||
Pending {data && data.pending_ack_count > 0 && `(${data.pending_ack_count})`}
|
||||
Pending{" "}
|
||||
{data &&
|
||||
data.pending_ack_count > 0 &&
|
||||
`(${data.pending_ack_count})`}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -284,7 +324,8 @@ function NotificationsPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function NotificationsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -318,7 +359,8 @@ export default function NotificationsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<NotificationsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,11 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { Team } from "@/types";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { CreateProjectDialog, ProjectFilters, ProjectTable } from "@/components/projects";
|
||||
import {
|
||||
CreateProjectDialog,
|
||||
ProjectFilters,
|
||||
ProjectTable,
|
||||
} from "@/components/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
@@ -19,7 +23,7 @@ function ProjectsPageContent() {
|
||||
const cellFilterParam = searchParams.get("cell");
|
||||
const cellFilter = useMemo(
|
||||
() => (cellFilterParam?.split(",").filter(Boolean) as Team[]) || [],
|
||||
[cellFilterParam]
|
||||
[cellFilterParam],
|
||||
);
|
||||
const showInactive = searchParams.get("inactive") === "true";
|
||||
|
||||
@@ -37,32 +41,37 @@ function ProjectsPageContent() {
|
||||
const query = params.toString();
|
||||
router.push(query ? `/projects?${query}` : "/projects");
|
||||
},
|
||||
[router, searchParams]
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({ q: value || null });
|
||||
},
|
||||
[updateParams]
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleCellChange = useCallback(
|
||||
(value: Team[]) => {
|
||||
updateParams({ cell: value.length > 0 ? value.join(",") : null });
|
||||
},
|
||||
[updateParams]
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleShowInactiveChange = useCallback(
|
||||
(value: boolean) => {
|
||||
updateParams({ inactive: value ? "true" : null });
|
||||
},
|
||||
[updateParams]
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// Fetch projects
|
||||
const { data: projects, isLoading, error, refetch } = useProjects({
|
||||
const {
|
||||
data: projects,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useProjects({
|
||||
active_only: !showInactive,
|
||||
});
|
||||
|
||||
@@ -72,12 +81,18 @@ function ProjectsPageContent() {
|
||||
|
||||
return projects.filter((project) => {
|
||||
// Search filter
|
||||
if (searchQuery && !project.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
if (
|
||||
searchQuery &&
|
||||
!project.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cell filter (if any selected, project must match one of them)
|
||||
if (cellFilter.length > 0 && !cellFilter.includes(project.assigned_cell)) {
|
||||
if (
|
||||
cellFilter.length > 0 &&
|
||||
!cellFilter.includes(project.assigned_cell)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useEffect, useRef } from "react";
|
||||
import { Loader2, Sparkles, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePrompter } from "@/hooks/use-prompter";
|
||||
import { Team } from "@/types";
|
||||
import {
|
||||
ChatMessages,
|
||||
ChatComposer,
|
||||
SuccessCard,
|
||||
BoardReviewSentCard,
|
||||
IntakeForm,
|
||||
BatchReviewCard,
|
||||
} from "@/components/prompter";
|
||||
@@ -42,7 +44,7 @@ export default function PrompterPage() {
|
||||
batch,
|
||||
batchWaves,
|
||||
batchResult,
|
||||
updateBatchDraftProject,
|
||||
setBatchDraftProjects,
|
||||
confirmBatch,
|
||||
} = usePrompter();
|
||||
|
||||
@@ -113,6 +115,22 @@ export default function PrompterPage() {
|
||||
createdTaskTeam ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
|
||||
<div className="w-full max-w-md space-y-3">
|
||||
{/* Board-routed MegaTask: created HELD for PO+HoM review, not
|
||||
dispatched — the CEO releases it with Approve & Start on the
|
||||
umbrella task. Every other path is a real "created/launched"
|
||||
success. ``createdTaskTeam === BOARD`` is set only by the
|
||||
batch board route (the single-draft board route parks and
|
||||
never reaches success). */}
|
||||
{createdTaskTeam === Team.BOARD && batchResult ? (
|
||||
<BoardReviewSentCard
|
||||
taskId={createdTaskId}
|
||||
taskTitle={createdTaskTitle}
|
||||
rootSubtaskCount={batchResult.root_subtask_ids.length}
|
||||
waveCount={batchResult.waves.length}
|
||||
onStartAnother={startAnother}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SuccessCard
|
||||
taskId={createdTaskId}
|
||||
taskTitle={createdTaskTitle}
|
||||
@@ -121,8 +139,8 @@ export default function PrompterPage() {
|
||||
/>
|
||||
{batchResult && (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{batchResult.root_subtask_ids.length} tasks sequenced into{" "}
|
||||
{batchResult.waves.length} wave
|
||||
{batchResult.root_subtask_ids.length} tasks sequenced
|
||||
into {batchResult.waves.length} wave
|
||||
{batchResult.waves.length === 1 ? "" : "s"}.
|
||||
{batchResult.warnings.length > 0 &&
|
||||
` ${batchResult.warnings.length} advisory note${
|
||||
@@ -130,6 +148,8 @@ export default function PrompterPage() {
|
||||
}.`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -149,7 +169,7 @@ export default function PrompterPage() {
|
||||
waves={batchWaves}
|
||||
projectIds={projectIds}
|
||||
onKeepChatting={keepChatting}
|
||||
onProjectChange={updateBatchDraftProject}
|
||||
onSetProjects={setBatchDraftProjects}
|
||||
onConfirm={confirmBatch}
|
||||
isLaunching={isLaunching}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useUIStore } from "@/store";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,14 +22,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Settings,
|
||||
Palette,
|
||||
Bell,
|
||||
Server,
|
||||
User,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { Settings, Palette, Bell, Server, User, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { API_URL, WS_URL } from "@/lib/constants";
|
||||
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
||||
@@ -69,11 +68,15 @@ export default function SettingsPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-2xl">CEO</span>
|
||||
<span className="text-primary-foreground font-bold text-2xl">
|
||||
CEO
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg">Renzo</p>
|
||||
<p className="text-sm text-muted-foreground">Chief Executive Officer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chief Executive Officer
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Agent ID: 00000000-0000-0000-0000-000000000001
|
||||
</p>
|
||||
@@ -89,7 +92,9 @@ export default function SettingsPage() {
|
||||
<Palette className="h-5 w-5" />
|
||||
Appearance
|
||||
</CardTitle>
|
||||
<CardDescription>Customize the look and feel of the panel</CardDescription>
|
||||
<CardDescription>
|
||||
Customize the look and feel of the panel
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -143,10 +148,7 @@ export default function SettingsPage() {
|
||||
Automatically refresh data periodically
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -224,7 +226,9 @@ export default function SettingsPage() {
|
||||
<Settings className="h-5 w-5" />
|
||||
Connection Info
|
||||
</CardTitle>
|
||||
<CardDescription>Backend API configuration (read-only)</CardDescription>
|
||||
<CardDescription>
|
||||
Backend API configuration (read-only)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -236,7 +240,8 @@ export default function SettingsPage() {
|
||||
<Input value={WS_URL} readOnly className="bg-muted" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These values are configured via environment variables (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL)
|
||||
These values are configured via environment variables
|
||||
(NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -6,7 +6,11 @@ import { useTask, useTaskLifecycle, useUpdateTask } from "@/hooks/use-tasks";
|
||||
import { useProject } from "@/hooks/use-projects";
|
||||
import { useCreateBranch, useCreatePR, useMergePR } from "@/hooks/use-git";
|
||||
import { Team, TaskStatus } from "@/types";
|
||||
import { TaskHeader, TaskMetadata, TaskTabs } from "@/components/tasks/task-detail";
|
||||
import {
|
||||
TaskHeader,
|
||||
TaskMetadata,
|
||||
TaskTabs,
|
||||
} from "@/components/tasks/task-detail";
|
||||
import { ApproveAndStartButton } from "@/components/tasks/approve-and-start-button";
|
||||
import {
|
||||
EscalateToCeoDialog,
|
||||
@@ -42,7 +46,8 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
|
||||
// Dialog states
|
||||
const [escalateDialogOpen, setEscalateDialogOpen] = useState(false);
|
||||
const [approveAndMergeDialogOpen, setApproveAndMergeDialogOpen] = useState(false);
|
||||
const [approveAndMergeDialogOpen, setApproveAndMergeDialogOpen] =
|
||||
useState(false);
|
||||
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
||||
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||
const [branchDialogOpen, setBranchDialogOpen] = useState(false);
|
||||
@@ -51,7 +56,8 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
const [passQaDialogOpen, setPassQaDialogOpen] = useState(false);
|
||||
const [failQaDialogOpen, setFailQaDialogOpen] = useState(false);
|
||||
const [docsCompleteDialogOpen, setDocsCompleteDialogOpen] = useState(false);
|
||||
const [submitPmReviewDialogOpen, setSubmitPmReviewDialogOpen] = useState(false);
|
||||
const [submitPmReviewDialogOpen, setSubmitPmReviewDialogOpen] =
|
||||
useState(false);
|
||||
const [completeDialogOpen, setCompleteDialogOpen] = useState(false);
|
||||
|
||||
const handleAction = async (action: string) => {
|
||||
@@ -141,7 +147,10 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
setEscalateDialogOpen(true);
|
||||
return; // Don't refetch yet, dialog will handle it
|
||||
case "request-changes":
|
||||
await lifecycle.failQa.mutateAsync({ taskId: task.id, qaNotes: "Changes requested by PM" });
|
||||
await lifecycle.failQa.mutateAsync({
|
||||
taskId: task.id,
|
||||
qaNotes: "Changes requested by PM",
|
||||
});
|
||||
toast.success("Changes requested");
|
||||
break;
|
||||
case "create-branch":
|
||||
@@ -221,11 +230,21 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
refetch();
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const detail = (err.response?.data as { detail?: string } | undefined)?.detail ?? "";
|
||||
const detail =
|
||||
(err.response?.data as { detail?: string } | undefined)?.detail ?? "";
|
||||
if (typeof detail === "string" && detail.startsWith("NO_PR")) {
|
||||
toast.error("No PR found for this task. Create a pull request before merging.");
|
||||
} else if (typeof detail === "string" && detail.startsWith("Merge failed")) {
|
||||
toast.error("Merge failed: " + (detail.slice("Merge failed".length).replace(/^[: ]+/, "") || "the merge could not be completed"));
|
||||
toast.error(
|
||||
"No PR found for this task. Create a pull request before merging.",
|
||||
);
|
||||
} else if (
|
||||
typeof detail === "string" &&
|
||||
detail.startsWith("Merge failed")
|
||||
) {
|
||||
toast.error(
|
||||
"Merge failed: " +
|
||||
(detail.slice("Merge failed".length).replace(/^[: ]+/, "") ||
|
||||
"the merge could not be completed"),
|
||||
);
|
||||
} else {
|
||||
toast.error("Failed to approve and merge task");
|
||||
}
|
||||
@@ -333,7 +352,12 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
await createBranch.mutateAsync({
|
||||
project_slug: project.slug,
|
||||
task_id: task.id,
|
||||
branch_type: branchType as "feature" | "bug" | "chore" | "docs" | "hotfix",
|
||||
branch_type: branchType as
|
||||
| "feature"
|
||||
| "bug"
|
||||
| "chore"
|
||||
| "docs"
|
||||
| "hotfix",
|
||||
agent_id: "ceo", // CEO is creating the branch from the panel
|
||||
});
|
||||
toast.success("Branch created successfully");
|
||||
@@ -403,7 +427,8 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
<AlertTriangle className="h-16 w-16 mx-auto mb-4 text-destructive" />
|
||||
<h2 className="text-xl font-semibold mb-2">Task Not Found</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{error?.message ?? "The task you're looking for doesn't exist or has been deleted."}
|
||||
{error?.message ??
|
||||
"The task you're looking for doesn't exist or has been deleted."}
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
|
||||
@@ -5,7 +5,10 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useWorkSessions } from "@/hooks/use-work-sessions";
|
||||
import { WorkSessionStatus } from "@/types";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { WorkSessionTable, WorkSessionFilters } from "@/components/work-sessions";
|
||||
import {
|
||||
WorkSessionTable,
|
||||
WorkSessionFilters,
|
||||
} from "@/components/work-sessions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
@@ -18,8 +21,9 @@ function WorkSessionsPageContent() {
|
||||
const searchQuery = searchParams.get("q") || "";
|
||||
const statusParam = searchParams.get("status");
|
||||
const statusFilter = useMemo(
|
||||
() => (statusParam?.split(",").filter(Boolean) as WorkSessionStatus[]) || [],
|
||||
[statusParam]
|
||||
() =>
|
||||
(statusParam?.split(",").filter(Boolean) as WorkSessionStatus[]) || [],
|
||||
[statusParam],
|
||||
);
|
||||
|
||||
// Update URL params
|
||||
@@ -36,21 +40,21 @@ function WorkSessionsPageContent() {
|
||||
const query = params.toString();
|
||||
router.push(query ? `/work-sessions?${query}` : "/work-sessions");
|
||||
},
|
||||
[router, searchParams]
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({ q: value || null });
|
||||
},
|
||||
[updateParams]
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(value: WorkSessionStatus[]) => {
|
||||
updateParams({ status: value.length > 0 ? value.join(",") : null });
|
||||
},
|
||||
[updateParams]
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// Fetch work sessions
|
||||
|
||||
@@ -5,7 +5,13 @@ import { useStopAgent } from "@/hooks/use-agents";
|
||||
import { AgentStatusResponse } from "@/types";
|
||||
import { AgentDefinition } from "@/lib/agent-definitions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -34,7 +40,9 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
|
||||
// so a new "up" state the backend adds defaults to non-spawnable — the safe
|
||||
// side, since spawning an already-running agent is exactly the bug to avoid.
|
||||
// (The badge renders "active" as a first-class state, so it MUST count as up.)
|
||||
const isActive = !["stopped", "offline", "terminated", "error"].includes(state);
|
||||
const isActive = !["stopped", "offline", "terminated", "error"].includes(
|
||||
state,
|
||||
);
|
||||
|
||||
const handleStop = async (graceful: boolean) => {
|
||||
try {
|
||||
@@ -50,7 +58,9 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
|
||||
<Card className={isActive ? "border-green-500/50" : ""}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{agent.name || "Unknown Agent"}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{agent.name || "Unknown Agent"}
|
||||
</CardTitle>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
|
||||
@@ -21,18 +21,20 @@ export function AgentGrid({
|
||||
isLoading,
|
||||
columns = 4,
|
||||
}: AgentGridProps) {
|
||||
const gridCols = {
|
||||
const gridCols =
|
||||
{
|
||||
3: "md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4",
|
||||
4: "md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-5",
|
||||
5: "md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5",
|
||||
}[columns] || "md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-5";
|
||||
}[columns] ||
|
||||
"md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-5";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">{title}</h2>
|
||||
<div className={"grid gap-4 " + gridCols}>
|
||||
{isLoading ? (
|
||||
Array.from({ length: agents.length || 3 }).map((_, i) => (
|
||||
{isLoading
|
||||
? Array.from({ length: agents.length || 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
@@ -40,16 +42,14 @@ export function AgentGrid({
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
: agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
agentStatus={agentStatuses[agent.id] || null}
|
||||
usageRow={agentUsage?.[agent.id] ?? null}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,14 +65,17 @@ export function AgentSelector({
|
||||
if (a.team === filterByTeam) return true;
|
||||
|
||||
// For Board team, also include board-level roles
|
||||
if (filterByTeam === Team.BOARD && (
|
||||
a.role === AgentRole.PRODUCT_OWNER ||
|
||||
if (
|
||||
filterByTeam === Team.BOARD &&
|
||||
(a.role === AgentRole.PRODUCT_OWNER ||
|
||||
a.role === AgentRole.HEAD_MARKETING ||
|
||||
a.role === AgentRole.AUDITOR
|
||||
)) return true;
|
||||
a.role === AgentRole.AUDITOR)
|
||||
)
|
||||
return true;
|
||||
|
||||
// For Main PM team, also include Main PM role
|
||||
if (filterByTeam === Team.MAIN_PM && a.role === AgentRole.MAIN_PM) return true;
|
||||
if (filterByTeam === Team.MAIN_PM && a.role === AgentRole.MAIN_PM)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
@@ -80,7 +83,9 @@ export function AgentSelector({
|
||||
|
||||
// Apply role filter
|
||||
if (filterByRoles && filterByRoles.length > 0) {
|
||||
filtered = filtered.filter((a) => a.role && filterByRoles.includes(a.role));
|
||||
filtered = filtered.filter(
|
||||
(a) => a.role && filterByRoles.includes(a.role),
|
||||
);
|
||||
}
|
||||
|
||||
// Group by team following org hierarchy
|
||||
@@ -94,12 +99,17 @@ export function AgentSelector({
|
||||
};
|
||||
|
||||
for (const agent of filtered) {
|
||||
if (agent.team === Team.BOARD ||
|
||||
if (
|
||||
agent.team === Team.BOARD ||
|
||||
agent.role === AgentRole.PRODUCT_OWNER ||
|
||||
agent.role === AgentRole.HEAD_MARKETING ||
|
||||
agent.role === AgentRole.AUDITOR) {
|
||||
agent.role === AgentRole.AUDITOR
|
||||
) {
|
||||
groups.board.push(agent);
|
||||
} else if (agent.team === Team.MAIN_PM || agent.role === AgentRole.MAIN_PM) {
|
||||
} else if (
|
||||
agent.team === Team.MAIN_PM ||
|
||||
agent.role === AgentRole.MAIN_PM
|
||||
) {
|
||||
groups.main_pm.push(agent);
|
||||
} else if (agent.team === Team.BACKEND) {
|
||||
groups.backend.push(agent);
|
||||
|
||||
@@ -59,7 +59,11 @@ interface AgentStateBadgeProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function AgentStateBadge({ state, showIcon = true, size = "md" }: AgentStateBadgeProps) {
|
||||
export function AgentStateBadge({
|
||||
state,
|
||||
showIcon = true,
|
||||
size = "md",
|
||||
}: AgentStateBadgeProps) {
|
||||
const sizeClasses = {
|
||||
sm: "text-xs px-2 py-0.5",
|
||||
md: "text-sm px-2.5 py-0.5",
|
||||
|
||||
@@ -29,7 +29,10 @@ export function AgentStatusCards({ agent }: AgentStatusCardsProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{agent.task_id ? (
|
||||
<Link href={"/tasks/" + agent.task_id} className="text-blue-500 hover:underline">
|
||||
<Link
|
||||
href={"/tasks/" + agent.task_id}
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{agent.task_id.slice(0, 8)}...
|
||||
</Link>
|
||||
) : (
|
||||
@@ -58,7 +61,11 @@ export function AgentStatusCards({ agent }: AgentStatusCardsProps) {
|
||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<span className={agent.error_count > 0 ? "text-red-600 font-semibold" : ""}>
|
||||
<span
|
||||
className={
|
||||
agent.error_count > 0 ? "text-red-600 font-semibold" : ""
|
||||
}
|
||||
>
|
||||
{agent.error_count}
|
||||
</span>
|
||||
{agent.waiting_for && (
|
||||
|
||||
@@ -9,7 +9,10 @@ interface OrchestratorStatusCardsProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function OrchestratorStatusCards({ status, isLoading }: OrchestratorStatusCardsProps) {
|
||||
export function OrchestratorStatusCards({
|
||||
status,
|
||||
isLoading,
|
||||
}: OrchestratorStatusCardsProps) {
|
||||
// Calculate running agents from by_state
|
||||
const runningCount = status?.by_state?.running || 0;
|
||||
const readyCount = status?.by_state?.ready || 0;
|
||||
@@ -45,7 +48,9 @@ export function OrchestratorStatusCards({ status, isLoading }: OrchestratorStatu
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-8 w-12" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{status?.total_agents || 0}</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{status?.total_agents || 0}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -71,7 +76,9 @@ export function OrchestratorStatusCards({ status, isLoading }: OrchestratorStatu
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-8 w-12" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{status?.waiting_count || 0}</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{status?.waiting_count || 0}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -23,7 +23,11 @@ interface SpawnAgentDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SpawnAgentDialog({ agentId, agentName, trigger }: SpawnAgentDialogProps) {
|
||||
export function SpawnAgentDialog({
|
||||
agentId,
|
||||
agentName,
|
||||
trigger,
|
||||
}: SpawnAgentDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const [initialPrompt, setInitialPrompt] = useState("");
|
||||
@@ -60,9 +64,7 @@ export function SpawnAgentDialog({ agentId, agentName, trigger }: SpawnAgentDial
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || defaultTrigger}
|
||||
</DialogTrigger>
|
||||
<DialogTrigger asChild>{trigger || defaultTrigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Spawn {agentName}</DialogTitle>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAgentStream, ConnectionState } from "@/hooks/use-websocket";
|
||||
@@ -26,14 +32,17 @@ const stateLabels: Record<ConnectionState, string> = {
|
||||
disconnected: "Disconnected",
|
||||
};
|
||||
|
||||
export function AgentStreamViewer({ agentId, agentName }: AgentStreamViewerProps) {
|
||||
export function AgentStreamViewer({
|
||||
agentId,
|
||||
agentName,
|
||||
}: AgentStreamViewerProps) {
|
||||
const {
|
||||
state,
|
||||
streamOutput,
|
||||
streamChunks,
|
||||
clearMessages,
|
||||
isConnected,
|
||||
isConnecting
|
||||
isConnecting,
|
||||
} = useAgentStream(agentId);
|
||||
|
||||
const outputRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
@@ -23,10 +23,17 @@ export function WaitingAgentsAlert({ waitingAgents }: WaitingAgentsAlertProps) {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{waitingAgents.map((agent) => (
|
||||
<div key={agent.agent_id} className="flex items-center justify-between p-2 bg-muted rounded">
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className="flex items-center justify-between p-2 bg-muted rounded"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{getAgentDisplayName(agent.agent_id)}</span>
|
||||
<span className="text-muted-foreground ml-2">waiting for: {agent.waiting_for}</span>
|
||||
<span className="font-medium">
|
||||
{getAgentDisplayName(agent.agent_id)}
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
waiting for: {agent.waiting_for}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={"/agents/" + agent.agent_id}>Resolve</Link>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function AuditorDashboard() {
|
||||
{
|
||||
onSuccess: () => toast.success("Audit report generated successfully"),
|
||||
onError: () => toast.error("Failed to generate audit report"),
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,7 +48,9 @@ export function AuditorDashboard() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Auditor Dashboard</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Auditor Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Quality oversight, flagging, and reporting
|
||||
</p>
|
||||
@@ -58,7 +60,10 @@ export function AuditorDashboard() {
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={handleGenerateReport} disabled={createReport.isPending}>
|
||||
<Button
|
||||
onClick={handleGenerateReport}
|
||||
disabled={createReport.isPending}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Generate Report
|
||||
</Button>
|
||||
|
||||
@@ -44,7 +44,10 @@ const CATEGORY_OPTIONS = [
|
||||
"other",
|
||||
];
|
||||
|
||||
export function CreateFlagDialog({ open, onOpenChange }: CreateFlagDialogProps) {
|
||||
export function CreateFlagDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateFlagDialogProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [severity, setSeverity] = useState<FlagSeverity>(FlagSeverity.INFO);
|
||||
@@ -124,7 +127,10 @@ export function CreateFlagDialog({ open, onOpenChange }: CreateFlagDialogProps)
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Severity</Label>
|
||||
<Select value={severity} onValueChange={(v) => setSeverity(v as FlagSeverity)}>
|
||||
<Select
|
||||
value={severity}
|
||||
onValueChange={(v) => setSeverity(v as FlagSeverity)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -36,7 +36,11 @@ function formatTime(timestamp: string): string {
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function FlaggedItem({ flag, onResolve, onReportToCeo }: FlaggedItemProps) {
|
||||
export function FlaggedItem({
|
||||
flag,
|
||||
onResolve,
|
||||
onReportToCeo,
|
||||
}: FlaggedItemProps) {
|
||||
const isResolved = !!flag.resolved_at;
|
||||
|
||||
return (
|
||||
@@ -63,7 +67,9 @@ export function FlaggedItem({ flag, onResolve, onReportToCeo }: FlaggedItemProps
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">{flag.description}</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{flag.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
|
||||
@@ -25,8 +25,13 @@ interface FlaggedItemsPanelProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function FlaggedItemsPanel({ flags, isLoading }: FlaggedItemsPanelProps) {
|
||||
const [filter, setFilter] = useState<"all" | "unresolved" | "resolved">("unresolved");
|
||||
export function FlaggedItemsPanel({
|
||||
flags,
|
||||
isLoading,
|
||||
}: FlaggedItemsPanelProps) {
|
||||
const [filter, setFilter] = useState<"all" | "unresolved" | "resolved">(
|
||||
"unresolved",
|
||||
);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const resolveFlag = useResolveAuditorFlag();
|
||||
|
||||
@@ -74,7 +79,12 @@ export function FlaggedItemsPanel({ flags, isLoading }: FlaggedItemsPanelProps)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={filter} onValueChange={(v) => setFilter(v as "all" | "unresolved" | "resolved")}>
|
||||
<Select
|
||||
value={filter}
|
||||
onValueChange={(v) =>
|
||||
setFilter(v as "all" | "unresolved" | "resolved")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-auto min-w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface LiveFeedsPanelProps {
|
||||
|
||||
export function LiveFeedsPanel({ feeds, isLoading }: LiveFeedsPanelProps) {
|
||||
const activeCount = (feeds ?? []).filter(
|
||||
(f) => f.status === "active" || f.message_count_24h > 0
|
||||
(f) => f.status === "active" || f.message_count_24h > 0,
|
||||
).length;
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { BarChart3, CheckCircle, Clock, FileText, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface QualityMetricsPanelProps {
|
||||
metrics: Record<string, number> | undefined;
|
||||
@@ -36,7 +42,8 @@ const METRICS: MetricDisplay[] = [
|
||||
key: "avg_completion_time",
|
||||
label: "Avg Completion Time",
|
||||
icon: <Clock className="h-4 w-4 text-purple-500" />,
|
||||
format: (v) => `${(typeof v === "number" ? v : parseFloat(v) || 0).toFixed(1)}h`,
|
||||
format: (v) =>
|
||||
`${(typeof v === "number" ? v : parseFloat(v) || 0).toFixed(1)}h`,
|
||||
},
|
||||
{
|
||||
key: "documentation_rate",
|
||||
@@ -59,7 +66,10 @@ const METRICS: MetricDisplay[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function QualityMetricsPanel({ metrics, isLoading }: QualityMetricsPanelProps) {
|
||||
export function QualityMetricsPanel({
|
||||
metrics,
|
||||
isLoading,
|
||||
}: QualityMetricsPanelProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { AuditorReport } from "@/types";
|
||||
import { useSendAuditorReport, useCreateAuditorReport } from "@/hooks/use-dashboard";
|
||||
import {
|
||||
useSendAuditorReport,
|
||||
useCreateAuditorReport,
|
||||
} from "@/hooks/use-dashboard";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -24,7 +27,11 @@ function formatDate(timestamp: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function ReportsPanel({ reports, isLoading, onCreateReport }: ReportsPanelProps) {
|
||||
export function ReportsPanel({
|
||||
reports,
|
||||
isLoading,
|
||||
onCreateReport,
|
||||
}: ReportsPanelProps) {
|
||||
const sendReport = useSendAuditorReport();
|
||||
const createReport = useCreateAuditorReport();
|
||||
|
||||
@@ -44,7 +51,7 @@ export function ReportsPanel({ reports, isLoading, onCreateReport }: ReportsPane
|
||||
{
|
||||
onSuccess: () => toast.success("Draft report created"),
|
||||
onError: () => toast.error("Failed to create report"),
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -65,7 +72,11 @@ export function ReportsPanel({ reports, isLoading, onCreateReport }: ReportsPane
|
||||
<FileText className="h-5 w-5" />
|
||||
Reports
|
||||
</CardTitle>
|
||||
<Button size="sm" onClick={handleNewReport} disabled={createReport.isPending}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleNewReport}
|
||||
disabled={createReport.isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Report
|
||||
</Button>
|
||||
|
||||
@@ -97,7 +97,7 @@ describe("CompanyScorecardCard", () => {
|
||||
|
||||
// Should NOT show error or data content
|
||||
expect(
|
||||
screen.queryByText("Could not load scorecard data")
|
||||
screen.queryByText("Could not load scorecard data"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Company Scorecard")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -111,7 +111,7 @@ describe("CompanyScorecardCard", () => {
|
||||
render(<CompanyScorecardCard />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Could not load scorecard data")
|
||||
screen.getByText("Could not load scorecard data"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Skeleton and scorecard body should not appear
|
||||
@@ -125,7 +125,7 @@ describe("CompanyScorecardCard", () => {
|
||||
|
||||
// When data is falsy the component falls through to the OfflineState branch
|
||||
expect(
|
||||
screen.getByText("Could not load scorecard data")
|
||||
screen.getByText("Could not load scorecard data"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -112,8 +112,12 @@ interface SpendSectionProps {
|
||||
}
|
||||
|
||||
function SpendSection({ spend }: SpendSectionProps) {
|
||||
const { monthly_budget_cap_usd, spend_30d_usd, projected_monthly_usd, over_budget } =
|
||||
spend;
|
||||
const {
|
||||
monthly_budget_cap_usd,
|
||||
spend_30d_usd,
|
||||
projected_monthly_usd,
|
||||
over_budget,
|
||||
} = spend;
|
||||
|
||||
// Red/destructive only when cap is a non-null number AND over_budget is true
|
||||
const isOverBudget = monthly_budget_cap_usd !== null && over_budget;
|
||||
@@ -139,7 +143,9 @@ function SpendSection({ spend }: SpendSectionProps) {
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Monthly cap</span>
|
||||
{monthly_budget_cap_usd === null ? (
|
||||
<span className="text-muted-foreground italic">No budget cap set</span>
|
||||
<span className="text-muted-foreground italic">
|
||||
No budget cap set
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
@@ -180,7 +186,8 @@ function SpeedSection({ medianLeadTimeHours }: SpeedSectionProps) {
|
||||
<span className="text-muted-foreground">Median lead time</span>
|
||||
{hasData ? (
|
||||
<span className="font-medium tabular-nums">
|
||||
{medianLeadTimeHours.toFixed(1)}h median — target: < 24h
|
||||
{medianLeadTimeHours.toFixed(1)}h median — target:
|
||||
< 24h
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">No data yet</span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user