mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Fix: agent idle deadlock and lifecycle hardening (#96)
* fix(panel): cap dialog height and pin footer so actions stay reachable
Shared DialogContent now caps at max-h-[85vh] with overflow-y-auto, and the
footer is sticky to the bottom. Long content (e.g. a pasted change-request
note) no longer pushes the submit/cancel buttons past the viewport — the body
scrolls while the actions stay visible. No-op on dialogs that already fit.
* feat(notifications): suppress duplicate same-purpose notifications at send
A notification is not created when an unacknowledged one with the same purpose
— same sender, same type, same task, overlapping recipients — already exists.
Body text is not compared, so rewording cannot defeat it; a different type,
task, sender, or an already-acked recipient all still send through. Stops
agents that loop re-issuing the same signal from piling up unread that
soft-blocks the recipient's idle path.
* fix(gateway): stop board/PM lifecycle verbs from 500-crashing
Two unguarded crashes that wedged the org in respawn/escalate loops:
- escalate_to_ceo dereferenced None.status when the verb runner declined the
escalation (task not in awaiting_pm_review — e.g. a board agent escalating a
blocked task). It now returns a clean invalid_state. The message/remediate
build moved to a helper so the function stays within the complexity gate.
- The coordination-root git ops (pr_target, pr_merge, PR update, branch-token
resolve) called UUID(str(task.project_id)) directly, which raised on a
coordination/integration task (project_id is None — 'badly formed hexadecimal
UUID string'). They now resolve through _project_for_task, which falls back to
the product's repo for project-less roots.
* refactor(intake): split out _block_to_chunk per-block classifier
Extract the per-block classification from _blocks_to_chunks so each function
stays within the xenon cyclomatic-complexity gate (was rank C). Behaviour is
unchanged — verified by the existing intake_driver tests.
* feat(gateway): make the i_am_idle unread soft-block satisfiable
The soft-block on unread A2A / @mentions had no clearing path, so once those
briefing fields populated an agent could never idle — a whole-org deadlock.
Keep the guard (it is correct) and add the missing clear paths:
- New read_messages content verb (schema -> route -> handler ->
a2a.mark_all_read -> MCP tool -> role do_tools): bulk-zeroes the caller's
unread A2A and stamps read_at. The idle hint now points to it.
- list_unread_mentions returns UNACKED MENTION-type notifications (each @mention
already raises one via messaging._notify_mentions) instead of raw,
unconditional mentions, so they clear via the existing notify_ack. No schema
migration needed.
The soft-block is now satisfiable: A2A via read_messages, mentions and
notifications via notify_ack.
* fix(tests): repair notification-dedup db.scalar mocks + prompter agent seeding
The notification send-dedup added a db.scalar() purpose-lookup to
_create_notification; the two hand-rolled _FakeDb test stubs (test_notification,
test_a2a_priority_tristate) had no scalar() method → AttributeError. Add
scalar() returning None (no duplicate) so creation proceeds.
Separately, the prompter '& Start' route tests assign the draft to a fixed
product-owner / main-pm AGENT_UUID but only seeded system + CEO, so the
assigned_to FK failed in isolation (and main-pm flaked in the full suite). Seed
both via idempotent merge() in _seed_project_and_ceo.
* fix(git): gitignore .pnpm-store + flag GH001 push rejection as permanent
A dev once committed the ~115 MB pnpm store → GitHub GH001 (>100 MB) pre-receive
reject → open_pr retry-loop. Two root fixes:
- Add .pnpm-store/ to .gitignore — an ignored dir can't be staged by any git add.
- push() restates a GH001 / file-size rejection as an unmistakable PERMANENT
error pointing at i_am_blocked, so the agent stops blind-retrying a push that
can never succeed (it otherwise mis-reads the raw output as a transient timeout).
The per-verb retry cap (open_pr: 5) already bounded the burn; this ends it.
* fix(gateway): accept a PM decision note as satisfying the complete/submit_up reflect gate
A cell/main PM that wrote a fresh decision but no separate reflect note bounced
on the reflect tracing-gate indefinitely (re-confirmed live: cell PMs looped on
cell_pm_complete -> journal:reflect until reaped, burning tokens — worse because
each respawn resets the per-verb retry cap). For a PM closing/submitting a task
the decision note already documents the close; the separate reflect is the
redundant artifact weak-model PMs forget. Accept a fresh decision as satisfying
reflect for complete + submit_up — the gate still requires a decision +
substantive notes, so the close stays documented.
NOTE (enforcement tradeoff, flagged for CEO review): this intentionally relaxes
the PM complete/submit_up gate. It does NOT touch the developer i_am_done gate.
* feat(gateway): refuse i_am_idle when a PM still owns a task awaiting its review
A cell/main PM once tried to 'send work back' by DMing the developer and going
idle — but a DM changes no task state, so the task stayed awaiting_pm_review and
the orchestrator just re-dispatched the PM in a loop. i_am_idle now refuses (like
the pending-assignment guard) when a PM owns an awaiting_pm_review task, with a
clear remediation: complete() to finish, or reassign()/delegate() to route it
back. PM-only; devs/QA/doc unaffected. Pairs with the reflect-gate relaxation so
the PM can actually complete instead of looping.
* feat(gateway): push a prior-work handoff digest into task-scoped briefings
A freshly spawned or respawned agent previously started cold on every
lifecycle hand-off: the prior worker's PR, commits, acceptance status and
journal highlights lived in task evidence but were pull-on-demand, so each
new role agent re-explored the codebase from scratch — wasted tokens and
fragile context loss across respawns.
build_task_handoff() composes a compact, DB-only digest (no git diff) and
_briefing_for() now attaches it to context_briefing whenever the caller
already holds the task row. The digest is built only from a passed-in task,
so there are zero extra fetches: every resumption entry point (give_me_work
and pm_give_me_work, i_will_work_on, i_will_plan, triage/triage_all,
i_am_done, submit_up, escalate_up, complete) threads the loaded task, while
id-only correction/rejection paths cleanly omit it.
Every field is type-guarded so a partial row never leaks a non-serialisable
value into the envelope.
* docs(prompts): tell agents to resume from the briefing handoff before re-exploring
The base prompt described the success envelope but never told agents to act
on context_briefing, so a respawned or hand-off agent would re-scan the
whole repo and re-derive the plan even when the briefing already carried the
prior worker's PR, commits, acceptance status and journal highlights.
Adds a 'Resume from your briefing' section that walks each task_handoff
field and instructs the agent to continue from it — and to read the unread
A2A / mention / notification lists, which are messages addressed to them.
Pairs with the gateway change that now pushes task_handoff into every
task-scoped briefing.
* feat(tasks): remember cleared dependencies so the unblock briefing can surface them
When an upstream dependency completed, _unblock_dependents removed its id from
the dependent's dependency_ids to let it be claimed — destroying the only
record of which upstream task had just landed. The revived dependent then
re-discovered that work from cold.
Adds tasks.completed_dependency_ids (Alembic 026, uuid[] default '{}'):
_unblock_dependents now appends the cleared id there instead of only dropping
it, and the briefing handoff digest surfaces it so the agent picking the task
back up knows its blocker cleared because that upstream work shipped. The base
prompt documents the field.
Migration round-trip verified against postgres (upgrade adds the column,
downgrade drops it).
* docs(prompts): instruct PMs to split oversized tasks into per-concern subtasks
A subtask carrying a long acceptance list or spanning multiple layers/files
drove repeated QA failures and a PM revision loop — QA can't pass a partial,
and the dev keeps re-touching unrelated parts. Nothing in the PM prompts told
them to decompose by size/concern.
cell_pm gets a 'Sizing' rule: one subtask = one focused concern with ~2-4
criteria and its own dev->QA pass; decompose anything larger before
delegating, sequencing with dependencies. main_pm gets a matching reminder to
scope each cell's slice to that cell's layer rather than handing a cell a
cross-layer monolith that just pushes the problem down a level.
* fix(gateway): mirror the task= kwarg on ChoreographerHelpers helper signatures
The handoff-digest change added a keyword-only task= parameter to
_briefing_for and _build_tracing_gap in _impl, but the ChoreographerHelpers
base that the role mixins inherit still declared the old signatures, so the
composed Choreographer had two incompatible base definitions (mypy [misc]).
Sync the base declarations to match.
* fix(tasks): keep the owner on a substitute-out so the task isn't orphaned
build_substitute_update unconditionally nulled assigned_to, so any
substitute that routes to PENDING (max_retries, low_context, out_of_scope_*)
— the path a verb hitting repeated 500s or its retry limit takes — left the
task pending AND unassigned. The dispatcher only respawns a pending task when
it has an owner, so the task went dormant: no agent ever picked it back up.
Keep the task with its current owner instead. A substitute-out is almost
always a transient stall, so the task re-dispatches to the SAME agent, which
resumes from the briefing handoff. Only the task_complete -> PM-review handoff
changes owner (unchanged).
* feat(a2a): suppress duplicate unread A2A messages at send
A respawned or retrying agent could re-emit the same DM, stacking identical
copies on the recipient's inbox and re-bumping the unread count — noise that
the recipient then has to clear. The notification path already dedups; A2A did
not.
send_chat_message now suppresses a send when an identical message from the
same sender is still unread in the conversation, keyed on (conversation,
sender, message_kind, content). Genuinely different messages are never
collapsed (verified: distinct content still produces distinct rows), so this
avoids the earlier per-pair over-suppression. No migration.
* fix(panel): default the notifications view to Unread, not All
Landing on the All tab buried new notifications under everything already
seen — the most-reported annoyance. The Unread tab is the actionable view, so
make it the default; the All/Pending tabs are one click away.
* fix(panel): show clone progress during intake prep instead of a frozen pill
The first clone of a repo can take a few minutes, during which the intake
form showed only a static 'Preparing the agent…' button — indistinguishable
from a hang. Add a progress region while preparing: an elapsed timer, a
saturating progress bar (approaches but never reaches 100% until the agent
actually answers), and staged copy (spinning up → cloning → first-clone-takes-
a-while → reading the codebase) so the wait reads as work, not a freeze.
* feat(docs): index workspace-authored docs that never reached the RAG store
Docs written through roboco_docs_write land at /app/docs on the orchestrator
and index fine. But a documenter can also write docs with Edit/Write directly
in its own clone (README, CHANGELOG, workspace markdown); those resolve to a
/app/docs path that doesn't exist on the orchestrator, so the indexer reads
nothing and the docs never become searchable — a cross-container miss with no
shared mount to bridge it.
On docs completion, capture each listed doc's committed content out of the
branch (new GitService.read_file_at_branch, via git show) and write it
server-side under /app/docs before indexing, so workspace-authored docs reach
RAG too. Docs already present server-side are skipped; absolute paths and
unreadable/uncommitted files are passed over best-effort.
* feat(prompter): survive a browser reload by reconnecting to the live intake chat
The intake chat lived entirely in React state, so a page reload wiped it and
dropped the human back to the scope form — even though the agent container
outlives the page. Now the chat persists a small TTL'd slice (session id,
messages, scope, draft) to localStorage and, on mount, reconnects: it asks the
new GET /live/{id}/status whether the session is still running and, if so,
restores the history and reopens the SSE stream; if dead or expired it clears
and shows the form. A full reload doesn't run React effect cleanup, so the
navigate-away reap never fires on refresh and the session stays up.
Backend adds the status endpoint + PrompterLiveRegistry.is_alive; localStorage
is cleared on confirm, start-another, and SPA navigate-away.
* chore: remove internal session-bookkeeping refs from code comments (part 1)
Strip leaked task/finding numbers, Wave/Phase/cluster/audit labels from
docstrings and comments across services, foundation policy, runtime, mcp,
api schemas, and agent_sdk — they mean nothing to a repo reader and expose
process internals. Wording preserved; only the labels dropped. Done by hand,
one comment at a time (no scripted rewrite). _impl.py follows separately.
* chore: remove internal session-bookkeeping refs from code comments (part 2)
Finishes the manual scrub: the choreographer _impl.py docstrings/comments plus
the remaining dogfood-run ('smoke-N') labels across runtime, mcp, foundation,
api schemas, services, and agent factories. Reworded to describe the bug or
behaviour in plain words; every label dropped. The repo source is now free of
task/finding numbers, Wave/Phase/cluster/audit/smoke labels. By hand, one
comment at a time.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -93,6 +93,10 @@ panel/node_modules/
|
||||
panel/.next/
|
||||
panel/out/
|
||||
panel/coverage/
|
||||
# pnpm content-addressable store — a dev once committed the ~115MB store and hit
|
||||
# GitHub's 100MB pre-receive limit (GH001), spiralling open_pr into a retry loop.
|
||||
# An ignored dir can't be staged by any `git add`, so this is the root fix.
|
||||
.pnpm-store/
|
||||
panel/tsconfig.tsbuildinfo
|
||||
panel/.env.local
|
||||
panel/.env.*.local
|
||||
|
||||
@@ -45,6 +45,19 @@ Read the `missing` array literally. Each entry below names what to do; the `reme
|
||||
| `subtasks not all terminal` | Wait — the closure dispatcher will respawn you when descendants finish. The `remediate` lists which subtasks aren't terminal. | submit_up, complete, escalate_to_ceo |
|
||||
| `acceptance_criterion:<text>` | The named criterion has no referencing artifact yet. Add a commit/file/progress entry that addresses it. | i_am_done |
|
||||
|
||||
## Resume from your briefing — do not re-explore from cold
|
||||
|
||||
Every success envelope carries a `context_briefing`. **Read it before you touch the codebase.** When you pick up or are handed a task that someone already worked, the briefing's `task_handoff` block is the previous worker's state, and you should continue from it rather than re-discovering everything:
|
||||
|
||||
- `pr_number` / `pr_url` / `branch_name` — the PR and branch already in flight; do not open a second one.
|
||||
- `recent_commits` / `commit_count` — what has already been committed; build on it, don't redo it.
|
||||
- `dev_summary` — the implementer's own note on what they did.
|
||||
- `acceptance_criteria_status` — which criteria are already satisfied.
|
||||
- `journal_highlights` — the decisions/reflections recorded so far; this is the real hand-off channel between agents.
|
||||
- `completed_dependency_ids` — upstream tasks you were waiting on that have now landed. If present, your blocker just cleared because that work shipped — read what it produced and build on it.
|
||||
|
||||
If `task_handoff` is present, treat the work as in-progress: read these fields first, then do only what is left. Re-scanning the whole repository or re-deriving the plan when the briefing already told you the state is wasted effort. Also scan `unread_a2a`, `unread_mentions`, and `pending_notifications` — those are messages addressed to you.
|
||||
|
||||
## Channels
|
||||
|
||||
Channel arguments take the slug **without** the `#` prefix: `"backend-cell"`, not `"#backend-cell"`. Channel names with `#` may be tolerated but are not correct.
|
||||
|
||||
@@ -98,6 +98,12 @@ If you are the **UX cell PM**, `task_type='design'` is your designer's normal wo
|
||||
- The correct move is `i_am_idle()` — the closure dispatcher respawns you when the in-flight child needs review or completes.
|
||||
- Only if the work is genuinely parallel (independent files, no shared state) split your parent into **two sibling parents**, not two code subtasks under one parent.
|
||||
|
||||
### Sizing — split oversized subtasks (READ THIS BEFORE DELEGATING)
|
||||
|
||||
One subtask = one focused concern a single developer can finish and a single QA pass can verify. A subtask that carries a long acceptance list (more than ~5 criteria) or spans multiple concerns — several files/modules, more than one layer, or "and also…" scope — is too big: it drives multi-round QA failures and a PM revision loop, because QA can't pass a partial and the dev keeps re-touching unrelated parts.
|
||||
|
||||
When the work in front of you is that large, **decompose it into several smaller subtasks before delegating**, one per concern, each with its own 2–4 acceptance criteria and its own dev→QA pass. Sequence them with dependencies when one must land before the next (see the cross-cell sequencing rules). Prefer three small subtasks that each pass QA once over one big subtask that fails QA four times. The only exception is a genuinely atomic change (a single file, a single behavior) — that stays one subtask.
|
||||
|
||||
### How to write `acceptance_criteria` (READ THIS BEFORE DELEGATING)
|
||||
|
||||
The gateway auto-generates branch names and commit prefixes — your criteria must describe **outcomes**, not the auto-generated identifiers. Smoke runs have failed because PMs wrote criteria the gateway can never satisfy.
|
||||
|
||||
@@ -32,6 +32,8 @@ This is the single most common mental-model mistake at your seat. Get it right:
|
||||
- **Do NOT** treat the one repository you happen to be able to see as "the" codebase, and do **NOT** describe another cell's area as "a separate repo" unless you have actually confirmed the Projects resolve to different repositories. In a monorepo the frontend is **not** "a separate repo" — it is a subtree of the same repo that the frontend cell owns. Each cell's `project_slug` is what tells you which Project/repo it works in; read it from the subtask, never guess.
|
||||
- Your coordination spans whatever shape the Product takes. You delegate per cell, each Cell PM works in their own Project (same repo subtree or different repo), and `complete` merges each cell's PR back along the chain. The fan-out shape (mono vs multi) is a property of the Product's per-cell Project config — inspect it, don't assume it.
|
||||
|
||||
**Scope each cell's slice to that cell's layer — never a cross-layer monolith.** A backend slice is backend work, a frontend slice is frontend work; if a slice reads as "build the whole feature end-to-end", you've under-decomposed it across cells. Keep each slice to one cell's concern and let that Cell PM break it into focused dev subtasks. A slice that bundles many concerns into one cell just pushes the oversized-task / repeated-QA-failure problem down a level.
|
||||
|
||||
## Inputs you start with
|
||||
|
||||
- Your `task_id` (your root coordination task) and `agent_id` are pre-baked into the gateway session.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Add tasks.completed_dependency_ids — remember which dependency cleared.
|
||||
|
||||
When an upstream dependency completes, ``_unblock_dependents`` removes its id
|
||||
from the dependent's ``dependency_ids`` so the dependent can be claimed. That
|
||||
strips the only record of *which* upstream task just landed, so the revived
|
||||
dependent agent has no way to know what it can now build on — it re-discovers
|
||||
the upstream work from cold. This column keeps the cleared dependency ids so the
|
||||
briefing can surface "dependency X just completed" to the agent picking the
|
||||
task back up. Defaults to an empty array; existing rows backfill empty (no
|
||||
historical unblock is reconstructed).
|
||||
|
||||
Revision ID: 026_completed_dependency_ids
|
||||
Revises: 025_agentrole_prompter
|
||||
Create Date: 2026-06-10
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, UUID
|
||||
|
||||
revision = "026_completed_dependency_ids"
|
||||
down_revision = "025_agentrole_prompter"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tasks",
|
||||
sa.Column(
|
||||
"completed_dependency_ids",
|
||||
ARRAY(UUID(as_uuid=True)),
|
||||
nullable=False,
|
||||
server_default="{}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tasks", "completed_dependency_ids")
|
||||
@@ -106,7 +106,9 @@ function NotificationCard({ notification, onMarkRead, onAcknowledge }: Notificat
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [activeTab, setActiveTab] = useState<"all" | "unread" | "pending">("all");
|
||||
// Default to Unread: the actionable view. Landing on "All" buries new
|
||||
// notifications under everything already seen.
|
||||
const [activeTab, setActiveTab] = useState<"all" | "unread" | "pending">("unread");
|
||||
|
||||
const { data, isLoading, error, refetch } = useNotifications(
|
||||
activeTab === "unread" ? { unread_only: true } :
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
@@ -51,6 +53,35 @@ export function IntakeForm({
|
||||
const { data: projects = [] } = useProjects();
|
||||
const { data: products = [] } = useProducts();
|
||||
|
||||
// The first clone of a repo can take a few minutes; without feedback the
|
||||
// "Preparing…" button looks frozen. Tick an elapsed timer while preparing
|
||||
// and drive a saturating progress bar + staged copy so the wait reads as
|
||||
// work-in-progress, not a hang. The bar approaches but never reaches 100%
|
||||
// until the agent actually answers (which flips isPreparing off).
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!isPreparing) return;
|
||||
const id = setInterval(() => setElapsed((s) => s + 1), 1000);
|
||||
// Reset on cleanup (when preparing ends or the component unmounts) rather
|
||||
// than synchronously in the effect body, which would trigger a cascading
|
||||
// render.
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
setElapsed(0);
|
||||
};
|
||||
}, [isPreparing]);
|
||||
|
||||
const prepPct = Math.min(95, Math.round(100 * (1 - Math.exp(-elapsed / 75))));
|
||||
const prepStage =
|
||||
elapsed < 15
|
||||
? "Spinning up the agent…"
|
||||
: elapsed < 45
|
||||
? "Cloning your repository…"
|
||||
: elapsed < 120
|
||||
? "First clone can take a couple of minutes — hang tight…"
|
||||
: "Reading the codebase…";
|
||||
const prepElapsed = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, "0")}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-8">
|
||||
<div className="w-full max-w-lg space-y-6 rounded-xl border bg-card p-6 shadow-sm">
|
||||
@@ -160,6 +191,16 @@ export function IntakeForm({
|
||||
"Start chatting"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isPreparing && (
|
||||
<div className="space-y-1.5" aria-live="polite">
|
||||
<Progress value={prepPct} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{prepStage}</span>
|
||||
<span className="tabular-nums">{prepElapsed}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[85vh] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -95,7 +95,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
"bg-background sticky bottom-0 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -142,6 +142,63 @@ function draftFromEvent(data: Record<string, unknown> | undefined): {
|
||||
return { draft: d as unknown as DraftProposal, scale };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh durability
|
||||
//
|
||||
// The chat lives entirely in React state, so a browser reload wiped it and
|
||||
// dropped the human back to the scope form — even though the intake agent
|
||||
// container outlives the page. Persist a small slice to localStorage (TTL'd)
|
||||
// and, on mount, only restore it once the backend confirms the session is
|
||||
// still alive. A full reload doesn't run React effect cleanup, so the
|
||||
// navigate-away reap below never fires on refresh and the session survives.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PERSIST_KEY = "roboco:prompter:live";
|
||||
const PERSIST_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
interface PersistedChat {
|
||||
sessionId: string;
|
||||
messages: ChatMessage[];
|
||||
state: PrompterState;
|
||||
scope: { targetKind: TargetKind; projectId: string; productId: string };
|
||||
editableDraft: EditableDraft;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
function loadPersisted(): PersistedChat | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PERSIST_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as PersistedChat;
|
||||
if (!parsed.sessionId || Date.now() - parsed.savedAt > PERSIST_TTL_MS) {
|
||||
window.localStorage.removeItem(PERSIST_KEY);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function savePersisted(slice: PersistedChat): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(PERSIST_KEY, JSON.stringify(slice));
|
||||
} catch {
|
||||
// localStorage full / unavailable — durability is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
function clearPersisted(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(PERSIST_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -304,15 +361,83 @@ export function usePrompter() {
|
||||
[closeStream, handleEvent]
|
||||
);
|
||||
|
||||
// Best-effort reap if the user navigates away mid-chat.
|
||||
// Best-effort reap if the user navigates away mid-chat. This cleanup runs on
|
||||
// SPA navigation (component unmount), NOT on a full page reload — so a reload
|
||||
// leaves the session running for the reconnect path below to pick up.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closeStream();
|
||||
const sid = sessionIdRef.current;
|
||||
if (sid) void prompterLiveApi.stop(sid).catch(() => undefined);
|
||||
if (sid) {
|
||||
void prompterLiveApi.stop(sid).catch(() => undefined);
|
||||
clearPersisted();
|
||||
}
|
||||
};
|
||||
}, [closeStream]);
|
||||
|
||||
// Persist the live chat whenever it changes, so a reload can resume it.
|
||||
useEffect(() => {
|
||||
const persistable =
|
||||
sessionId !== null &&
|
||||
(state === "chatting" ||
|
||||
state === "streaming" ||
|
||||
state === "draft_preview" ||
|
||||
state === "review_modal");
|
||||
if (persistable && sessionId) {
|
||||
savePersisted({
|
||||
sessionId,
|
||||
messages,
|
||||
state,
|
||||
scope: scopeRef.current,
|
||||
editableDraft,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}, [sessionId, messages, state, editableDraft]);
|
||||
|
||||
// On mount, reconnect to a still-running session left behind by a reload.
|
||||
const didRestoreRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didRestoreRef.current) return;
|
||||
didRestoreRef.current = true;
|
||||
const persisted = loadPersisted();
|
||||
if (!persisted) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const { alive } = await prompterLiveApi.status(persisted.sessionId);
|
||||
if (cancelled) return;
|
||||
if (!alive) {
|
||||
clearPersisted();
|
||||
return;
|
||||
}
|
||||
// Restore the history + scope + draft, then reopen the stream for new
|
||||
// events. Any tokens from a turn that was mid-flight at reload are gone,
|
||||
// so land in a stable state rather than "streaming".
|
||||
sessionIdRef.current = persisted.sessionId;
|
||||
setSessionId(persisted.sessionId);
|
||||
setMessages(persisted.messages);
|
||||
setEditableDraft(persisted.editableDraft);
|
||||
setTargetKind(persisted.scope.targetKind);
|
||||
setProjectId(persisted.scope.projectId);
|
||||
setProductId(persisted.scope.productId);
|
||||
setState(
|
||||
persisted.state === "draft_preview" ||
|
||||
persisted.state === "review_modal"
|
||||
? "draft_preview"
|
||||
: "chatting"
|
||||
);
|
||||
openStream(persisted.sessionId);
|
||||
} catch {
|
||||
// Status check failed (server unreachable) — stay on the form and keep
|
||||
// the persisted slice for a later retry within its TTL.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [openStream]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Start the live session (from the scope form)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -466,6 +591,7 @@ export function usePrompter() {
|
||||
// The draft became a task — reap the agent and close the stream.
|
||||
closeStream();
|
||||
void prompterLiveApi.stop(sid).catch(() => undefined);
|
||||
clearPersisted();
|
||||
sessionIdRef.current = null;
|
||||
setCreatedTaskId(task_id);
|
||||
setCreatedTaskTitle(draft.title);
|
||||
@@ -489,6 +615,7 @@ export function usePrompter() {
|
||||
closeStream();
|
||||
const sid = sessionIdRef.current;
|
||||
if (sid) void prompterLiveApi.stop(sid).catch(() => undefined);
|
||||
clearPersisted();
|
||||
sessionIdRef.current = null;
|
||||
streamingIdRef.current = null;
|
||||
setMessages([]);
|
||||
|
||||
@@ -72,6 +72,15 @@ export const prompterLiveApi = {
|
||||
streamUrl: (sessionId: string): string =>
|
||||
`${API_URL}/prompter/live/${sessionId}/stream`,
|
||||
|
||||
/** Is this session still running? The panel calls this after a reload to
|
||||
* decide whether to reconnect the chat or fall back to the scope form. */
|
||||
status: async (sessionId: string): Promise<{ alive: boolean }> => {
|
||||
const { data } = await api.get<{ alive: boolean }>(
|
||||
`/prompter/live/${sessionId}/status`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Deliver the human's message to the running agent; the reply streams back. */
|
||||
sendMessage: async (sessionId: string, text: string): Promise<void> => {
|
||||
await api.post(`/prompter/live/${sessionId}/messages`, { text });
|
||||
|
||||
@@ -97,6 +97,32 @@ def _is_propose_draft(name: str) -> bool:
|
||||
return name == "propose_draft" or name.endswith("__propose_draft")
|
||||
|
||||
|
||||
def _block_to_chunk(
|
||||
block: Any,
|
||||
) -> tuple[StreamChunk | None, str | None, dict[str, Any] | None]:
|
||||
"""Classify one assistant content block → (chunk, text_part, draft).
|
||||
|
||||
A ``propose_draft`` tool call yields a draft; thinking / other tool_use
|
||||
yield a chunk; a TextBlock yields a text_part (already streamed live, mined
|
||||
for a fenced draft by the caller). Unknown blocks yield nothing.
|
||||
"""
|
||||
if hasattr(block, "thinking"): # ThinkingBlock
|
||||
return StreamChunk(kind="thinking", text=str(block.thinking)), None, None
|
||||
if hasattr(block, "name") and hasattr(block, "input"): # ToolUseBlock
|
||||
name = str(block.name)
|
||||
tool_input = getattr(block, "input", {})
|
||||
if _is_propose_draft(name):
|
||||
return None, None, _draft_from_tool_input(tool_input)
|
||||
return (
|
||||
StreamChunk(kind="tool_use", tool=name, data={"input": tool_input}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
if hasattr(block, "text"): # TextBlock — already streamed; mine for a draft
|
||||
return None, str(block.text), None
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _blocks_to_chunks(content: list[Any]) -> list[StreamChunk]:
|
||||
"""Map an assistant message's content blocks to chunks (duck-typed).
|
||||
|
||||
@@ -114,19 +140,12 @@ def _blocks_to_chunks(content: list[Any]) -> list[StreamChunk]:
|
||||
text_parts: list[str] = []
|
||||
draft: dict[str, Any] | None = None
|
||||
for block in content or []:
|
||||
if hasattr(block, "thinking"): # ThinkingBlock
|
||||
chunks.append(StreamChunk(kind="thinking", text=str(block.thinking)))
|
||||
elif hasattr(block, "name") and hasattr(block, "input"): # ToolUseBlock
|
||||
name = str(block.name)
|
||||
tool_input = getattr(block, "input", {})
|
||||
if _is_propose_draft(name):
|
||||
draft = draft or _draft_from_tool_input(tool_input)
|
||||
else:
|
||||
chunks.append(
|
||||
StreamChunk(kind="tool_use", tool=name, data={"input": tool_input})
|
||||
)
|
||||
elif hasattr(block, "text"): # TextBlock — already streamed; mine for a draft
|
||||
text_parts.append(str(block.text))
|
||||
chunk, text_part, block_draft = _block_to_chunk(block)
|
||||
if chunk is not None:
|
||||
chunks.append(chunk)
|
||||
if text_part is not None:
|
||||
text_parts.append(text_part)
|
||||
draft = draft or block_draft
|
||||
draft = draft or _extract_draft("".join(text_parts))
|
||||
if draft is not None:
|
||||
chunks.append(StreamChunk(kind="draft", data=draft))
|
||||
@@ -280,15 +299,15 @@ def build_intake_options(
|
||||
) -> Any: # pragma: no cover - thin SDK construction
|
||||
"""Build locked-down ``ClaudeAgentOptions`` for the intake session.
|
||||
|
||||
Isolation/security (smoke 2026-06-09 #11): the intake agent must NOT inherit
|
||||
the host's personal Claude Code env (Gmail/Notion MCP, Write/Edit/Bash). So:
|
||||
Isolation/security: the intake agent must NOT inherit the host's personal
|
||||
Claude Code env (Gmail/Notion MCP, Write/Edit/Bash). So:
|
||||
|
||||
- ``strict_mcp_config=True`` + ``setting_sources=[]`` → ignore the host's
|
||||
``~/.claude.json`` / ``settings.json``; use ONLY the MCP server below.
|
||||
- ``permission_mode="dontAsk"`` (NOT ``bypassPermissions``) + a ``can_use_tool``
|
||||
gate → a hard allowlist (Read/Grep/Glob/Task + ``propose_draft``), no prompts.
|
||||
|
||||
Draft emission (#10): the agent calls the ``propose_draft`` MCP tool, which the
|
||||
Draft emission: the agent calls the ``propose_draft`` MCP tool, which the
|
||||
driver turns into a ``draft`` event — deterministic, not a fragile text fence.
|
||||
|
||||
NOTE: ``setting_sources=[]`` must be validated against the mounted-``~/.claude``
|
||||
|
||||
@@ -146,7 +146,7 @@ class PostMortemRequest(BaseModel):
|
||||
|
||||
|
||||
class VerbAttemptRequest(BaseModel):
|
||||
"""Per-verb circuit-breaker attempt record (Phase 3 Task 14).
|
||||
"""Per-verb circuit-breaker attempt record.
|
||||
|
||||
Posted by the agent's response-handling layer when a gateway verb
|
||||
call returns a rejection envelope (`tracing_gap`, `invalid_state`,
|
||||
|
||||
@@ -361,7 +361,7 @@ _LOOP_ACTION_RAW = os.environ.get("ROBOCO_AGENT_LOOP_ACTION", _BUDGET.loop_actio
|
||||
_LOOP_ACTION: Literal["warn", "halt"] = "halt" if _LOOP_ACTION_RAW == "halt" else "warn"
|
||||
_STOP_ALLOWANCE = int(os.environ.get("ROBOCO_AGENT_STOP_ATTEMPT_ALLOWANCE", "1"))
|
||||
_RECENT_TOOL_WINDOW = 5 # not in foundation — keep local
|
||||
# Sliding-window for the per-verb retry circuit breaker (Phase 3 Task 14).
|
||||
# Sliding-window for the per-verb retry circuit breaker.
|
||||
# 60s matches the docstring on foundation.VERB_RETRY_LIMITS — cap is "N
|
||||
# rejections in 60s", not "N rejections since session start".
|
||||
_VERB_ATTEMPT_WINDOW_S: int = 60
|
||||
@@ -375,7 +375,7 @@ _CIRCUIT_REJECTION_KINDS: frozenset[str] = frozenset(
|
||||
# stores after stripping the `mcp__roboco-flow__` / `mcp__roboco-do__`
|
||||
# prefix (see line ~798). The deleted pre-gateway tool names never
|
||||
# matched the suffix-stripped values, so the stop hook used to nag
|
||||
# every agent even after a successful i_am_idle (smoke-7 evidence).
|
||||
# every agent even after a successful i_am_idle.
|
||||
_TERMINAL_TOOLS: frozenset[str] = frozenset(
|
||||
{
|
||||
# Every role's clean exit.
|
||||
@@ -447,7 +447,7 @@ _state = _SessionState()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PER-VERB CIRCUIT BREAKER (Phase 3 Task 14)
|
||||
# PER-VERB CIRCUIT BREAKER
|
||||
# =============================================================================
|
||||
# Pre-Phase-3 the gateway had no per-verb retry cap. The 2026-05-10 smoke
|
||||
# showed i_am_done retried 5+ times in 2 minutes within the global 150-tool
|
||||
|
||||
@@ -98,15 +98,15 @@ def _autogen_verbs_layer(prompts_path: Path, role: "AgentRole") -> str | None:
|
||||
``roboco/api/schemas/v1/`` plus the role-config. Including it as a
|
||||
composition layer means the prompt always shows the literal accepted
|
||||
body shape for every verb the agent has — eliminating the prompt
|
||||
drift class identified in audit P2-9 (D-04, D-10, D-11, D-29-D-31).
|
||||
drift class where the prompt and the accepted body shape diverged.
|
||||
"""
|
||||
role_value = role.value if hasattr(role, "value") else str(role)
|
||||
return _load_layer(prompts_path / "_generated" / f"{role_value}.md")
|
||||
|
||||
|
||||
# Built-in Claude Code tools each role's session needs at spawn time.
|
||||
# Smoke-7 surfaced: be-dev-1 hit "Edit exists but is not enabled in this
|
||||
# context" because Claude Code v2.1.69+ defers built-in tools behind a
|
||||
# Dogfooding surfaced: a developer hit "Edit exists but is not enabled in
|
||||
# this context" because Claude Code v2.1.69+ defers built-in tools behind a
|
||||
# ToolSearch activation. Weak models skip the soft directive in the
|
||||
# briefing, so we hoist the exact call into a top-of-system-prompt
|
||||
# layer (highest-priority instruction the model sees).
|
||||
|
||||
@@ -150,6 +150,17 @@ async def stream(session_id: str, request: Request) -> EventSourceResponse:
|
||||
return EventSourceResponse(events(), ping=15)
|
||||
|
||||
|
||||
@router.get("/live/{session_id}/status")
|
||||
async def session_status(session_id: str) -> dict[str, bool]:
|
||||
"""Report whether a live intake session is still running.
|
||||
|
||||
The panel calls this after a reload: if the session survived (the agent
|
||||
container outlives a browser refresh), it reopens the SSE stream and
|
||||
resumes the chat instead of dropping back to the scope form.
|
||||
"""
|
||||
return {"alive": get_live_registry().is_alive(session_id)}
|
||||
|
||||
|
||||
@router.post("/live/{session_id}/messages")
|
||||
async def send_message(session_id: str, body: LiveMessageRequest) -> dict[str, bool]:
|
||||
"""Deliver the human's message to the running intake agent."""
|
||||
|
||||
@@ -21,6 +21,7 @@ from roboco.api.schemas.v1.do import (
|
||||
OpenSessionRequest,
|
||||
ProgressRequest,
|
||||
PRUpdateRequest,
|
||||
ReadMessagesRequest,
|
||||
SayRequest,
|
||||
)
|
||||
from roboco.services.gateway.content_actions import ContentActions
|
||||
@@ -236,6 +237,17 @@ async def do_notify_ack(
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/read_messages")
|
||||
async def do_read_messages(
|
||||
request: Request,
|
||||
_body: ReadMessagesRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
actions: _ContentActionsDep,
|
||||
) -> dict:
|
||||
env = await actions.read_messages(agent_id=x_agent_id)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/channels")
|
||||
async def do_channels(
|
||||
request: Request,
|
||||
|
||||
@@ -51,7 +51,7 @@ class NoteRequest(BaseModel):
|
||||
title: str | None = None
|
||||
# decision scope (all required at gateway when scope='decision').
|
||||
# Typed as non-nullable str (default "") so the MCP tool schema declares
|
||||
# the field as `string` not `anyOf[string, null]` — smoke-6 showed
|
||||
# the field as `string` not `anyOf[string, null]` — dogfooding showed
|
||||
# minimax-m3 passing literal `null` for these and the server-side
|
||||
# gate looping forever on `incomplete_input`. Empty string still counts
|
||||
# as missing at the gate.
|
||||
@@ -70,7 +70,7 @@ class NoteRequest(BaseModel):
|
||||
# ``options``, a single dict) is wrapped into a one-element list before
|
||||
# field validation. Without this a well-intentioned ``consequences="x"``
|
||||
# 422'd at the route and the agent's retry loop tripped the circuit
|
||||
# breaker (issue #15). ``mode="before"`` runs ahead of type coercion so
|
||||
# breaker. ``mode="before"`` runs ahead of type coercion so
|
||||
# the wrapped value satisfies the declared ``list[...]`` type.
|
||||
@field_validator("options", "consequences", "next_steps", mode="before")
|
||||
@classmethod
|
||||
@@ -131,7 +131,7 @@ class LinkSessionRequest(BaseModel):
|
||||
|
||||
|
||||
class ProgressRequest(BaseModel):
|
||||
"""Progress update; % is DERIVED from the plan checklist (#173).
|
||||
"""Progress update; % is DERIVED from the plan checklist.
|
||||
|
||||
Pass ``plan_step`` (a sub_task id or its 1-based order) as you finish
|
||||
each plan step — it is marked complete and the percentage is computed
|
||||
@@ -170,10 +170,14 @@ class ChannelsRequest(BaseModel):
|
||||
"""No params — caller's identity comes from X-Agent-ID header."""
|
||||
|
||||
|
||||
class ReadMessagesRequest(BaseModel):
|
||||
"""No params — clears the caller's unread A2A inbox (X-Agent-ID header)."""
|
||||
|
||||
|
||||
class PRUpdateRequest(BaseModel):
|
||||
"""Update an open PR's title/body and/or request reviewers.
|
||||
|
||||
Smoke-5 surfaced the gap: agents who needed to fix the PR title or
|
||||
Dogfooding surfaced the gap: agents who needed to fix the PR title or
|
||||
request a reviewer after `open_pr` had no verb for it and got blocked
|
||||
by the bash-guard on `gh pr edit`. This is the gateway-native fix.
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ class GiveMeWorkRequest(BaseModel):
|
||||
class IWillWorkOnRequest(BaseModel):
|
||||
task_id: UUID
|
||||
plan: str | None = None
|
||||
# #172: the executing developer's plan is a step checklist (same
|
||||
# The executing developer's plan is a step checklist (same
|
||||
# SubTask shape as IWillPlanRequest.sub_tasks). It is both the
|
||||
# execution plan AND the progress checklist (#173): completing a
|
||||
# execution plan AND the progress checklist: completing a
|
||||
# step advances progress. Depth is enforced server-side in
|
||||
# choreographer._dev_steps_gate (a title with no real description is
|
||||
# not a step). Server assigns id + order.
|
||||
@@ -154,12 +154,12 @@ class EscalateToCeoRequest(BaseModel):
|
||||
class IWillPlanRequest(BaseModel):
|
||||
task_id: UUID
|
||||
plan: str = Field(..., min_length=1)
|
||||
# Pre-gateway parity (Wave A1, 2026-05-12). Approach is REQUIRED — agents
|
||||
# Pre-gateway parity: Approach is REQUIRED — agents
|
||||
# could not transition claimed → in_progress without filling this in the
|
||||
# pre-gateway flow. The Plan tab depends on it; smoke run 3 confirmed
|
||||
# the empty default lets agents through with thin plans.
|
||||
# min_length must match choreographer._impl._PM_APPROACH_MIN_LEN. Raised
|
||||
# 20→150 (smoke-15): a 20-char approach was a one-liner; the approach +
|
||||
# 20→150: a 20-char approach was a one-liner; the approach +
|
||||
# sub_tasks are also the progress checklist, so they must be substantive.
|
||||
approach: str = Field(..., min_length=150)
|
||||
sub_tasks: list[dict[str, str]] = Field(
|
||||
|
||||
+4
-4
@@ -365,8 +365,8 @@ class Settings(BaseSettings):
|
||||
ge=60,
|
||||
description="Claim heartbeat staleness threshold (seconds)",
|
||||
)
|
||||
# Wave C3 (2026-05-12). Reaper window for stale-claim detection.
|
||||
# Smoke run 3 reaped agents at ~180s while they were actively
|
||||
# Reaper window for stale-claim detection. Dogfooding reaped agents at
|
||||
# ~180s while they were actively
|
||||
# retrying — LLM inference + retry loops routinely exceed 3 min
|
||||
# between verb successes. 600s is large enough to accommodate that
|
||||
# without letting a genuinely-stuck container linger.
|
||||
@@ -385,7 +385,7 @@ class Settings(BaseSettings):
|
||||
# (e.g. a reassignment that didn't spawn) is invisibly stuck — the heartbeat
|
||||
# reaper can't see it because its heartbeat was seeded fresh at claim time.
|
||||
# After this short grace window the dispatcher (re)spawns the assignee, or
|
||||
# releases the task to pending for re-dispatch (#19). Shorter than the
|
||||
# releases the task to pending for re-dispatch. Shorter than the
|
||||
# heartbeat reaper window: this is the "no agent at all" case, not the
|
||||
# "agent went silent mid-run" case.
|
||||
claimed_no_agent_grace_seconds: int = Field(
|
||||
@@ -397,7 +397,7 @@ class Settings(BaseSettings):
|
||||
"override via ROBOCO_CLAIMED_NO_AGENT_GRACE_SECONDS"
|
||||
),
|
||||
)
|
||||
# Wave C8 (2026-05-12). Pre-gateway parity: PMs wrote a fresh
|
||||
# Pre-gateway parity: PMs wrote a fresh
|
||||
# journal:decision around each decision point, not once at task
|
||||
# creation. The PM-decision tracing gate (delegate, unblock,
|
||||
# escalate_up, escalate_to_ceo) treats decisions older than this
|
||||
|
||||
@@ -259,6 +259,12 @@ class TaskTable(Base):
|
||||
blocker_ids: Mapped[list[PyUUID]] = mapped_column(
|
||||
ARRAY(UUID(as_uuid=True)), default=list
|
||||
)
|
||||
# Dependencies that have since completed and been cleared from
|
||||
# ``dependency_ids`` — kept so the unblock briefing can tell the revived
|
||||
# dependent which upstream task just landed.
|
||||
completed_dependency_ids: Mapped[list[PyUUID]] = mapped_column(
|
||||
ARRAY(UUID(as_uuid=True)), default=list, server_default="{}"
|
||||
)
|
||||
|
||||
# Ordering (for sibling tasks under the same parent)
|
||||
sequence: Mapped[int] = mapped_column(
|
||||
|
||||
@@ -330,7 +330,7 @@ async def handle_question_answered(event: Event) -> None:
|
||||
|
||||
|
||||
async def handle_auditor_spawn(event: Event) -> None:
|
||||
"""Wave C6 (2026-05-12) — spawn auditor on exceptional lifecycle events.
|
||||
"""Spawn the auditor on exceptional lifecycle events.
|
||||
|
||||
Spawned on task.blocked / task.cancelled / task.awaiting_ceo_approval
|
||||
so the auditor can read the journal aggregate and emit a reflect note
|
||||
@@ -401,7 +401,7 @@ def register_default_handlers(bus: Any = None) -> None:
|
||||
# Question handlers
|
||||
bus.subscribe(EventType.QUESTION_ANSWERED, handle_question_answered)
|
||||
|
||||
# Auditor spawn handlers — Wave C6 (2026-05-12)
|
||||
# Auditor spawn handlers.
|
||||
# Auditor observes exceptional task lifecycle events only; routine
|
||||
# progress events (claimed, started, in_progress) do not trigger it.
|
||||
auditor_events = [
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
Owns budget thresholds, loop-detection action, and per-verb circuit breakers.
|
||||
|
||||
Replaces (subsequent tasks migrate consumers):
|
||||
Replaces:
|
||||
- agent_sdk/server.py: 527-534 (hand-coded constants for warn/halt/loop thresholds)
|
||||
- runtime/orchestrator.py: 3807 (_PM_RESPAWN_MAX_UNPRODUCTIVE)
|
||||
- docker/scripts/post-tool-budget-hook.sh exit-0-on-loop (Task 13 changes to exit 1)
|
||||
- docker/scripts/post-tool-budget-hook.sh exit-0-on-loop (now exit 1)
|
||||
|
||||
The verb-level circuit breaker (VERB_RETRY_LIMITS) is NEW. Pre-Phase-3 the
|
||||
gateway had no per-verb retry cap — the 2026-05-10 smoke run showed
|
||||
i_am_done retried 5+ times in 2 minutes within the global budget. After
|
||||
Task 14 lands the runtime tracker, exceeding VERB_RETRY_LIMITS[verb]
|
||||
attempts in 60s returns Envelope.circuit_open.
|
||||
The verb-level circuit breaker (VERB_RETRY_LIMITS) is NEW. The gateway had
|
||||
no per-verb retry cap — dogfooding showed i_am_done retried 5+ times in 2
|
||||
minutes within the global budget. With the runtime tracker in place,
|
||||
exceeding VERB_RETRY_LIMITS[verb] attempts in 60s returns
|
||||
Envelope.circuit_open.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -53,7 +53,7 @@ VERB_RETRY_LIMITS: dict[str, int] = {
|
||||
# QA / Doc handoffs. Keys are the public MCP verb names (what the SDK
|
||||
# receives via /verb/attempted, derived from the flow URL path).
|
||||
# IntentSpec uses `pass_review`/`fail_review` internally; the MCP layer
|
||||
# exposes them as `pass`/`fail`. Smoke-7 surfaced the mismatch — the
|
||||
# exposes them as `pass`/`fail`. Dogfooding surfaced the mismatch — the
|
||||
# old keys here never matched any actual rejection.
|
||||
"pass": 3,
|
||||
"fail": 3,
|
||||
|
||||
@@ -26,7 +26,7 @@ def parse_priority(
|
||||
) -> NotificationPriority:
|
||||
"""Resolve a Priority value from mixed-source A2A inputs.
|
||||
|
||||
Precedence (P3 Task 9 — A2A urgency tristate):
|
||||
Precedence (A2A urgency tristate):
|
||||
1. ``raw_priority`` — string matching the Priority enum
|
||||
("normal" | "high" | "urgent"). Unknown values fall back to NORMAL.
|
||||
2. ``legacy_urgent_flag`` — legacy bool from
|
||||
@@ -221,7 +221,7 @@ CHANNELS: dict[str, ChannelSpec] = {
|
||||
# -- Management channels --------------------------------------------------
|
||||
# Legacy CHANNEL_ACCESS lists auditor as both read AND write here. We
|
||||
# preserve that behaviour for parity; the runtime guard that downgrades
|
||||
# auditor to silent lives in services (Phase 3 Task 8).
|
||||
# auditor to silent lives in services.
|
||||
"main-pm-board": ChannelSpec(
|
||||
slug="main-pm-board",
|
||||
description="Main PM and Board communication",
|
||||
|
||||
@@ -225,7 +225,7 @@ _STATUS_TRANSITIONS: tuple[StatusTransition, ...] = (
|
||||
# PM setup
|
||||
StatusTransition(Status.BACKLOG, Status.PENDING, "activate", None),
|
||||
# Claim path. role_constraint=None on rows below means "any role —
|
||||
# the per-role-vs-status filtering is in CLAIM_RULES (Task 5)".
|
||||
# the per-role-vs-status filtering is in CLAIM_RULES".
|
||||
# A None here is NOT an oversight; it is the explicit handoff
|
||||
# point between the StatusTransition table (state machine) and
|
||||
# CLAIM_RULES (per-role claim authority).
|
||||
@@ -359,8 +359,8 @@ _ATOMIC_ACTIONS: dict[str, ActionSpec] = {
|
||||
needs_team_match=False,
|
||||
),
|
||||
# claim's source_statuses is the UNION across all roles — see CLAIM_RULES
|
||||
# for per-role authority. Both tables are authoritative; Task 8 validates
|
||||
# consistency between them.
|
||||
# for per-role authority. Both tables are authoritative; a validator
|
||||
# checks consistency between them.
|
||||
"claim": ActionSpec(
|
||||
name="claim",
|
||||
allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES),
|
||||
@@ -1023,7 +1023,7 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
|
||||
# sees pr_created=True. Mirrors the dev's open_pr→i_am_done split.
|
||||
pre_side_effects=("create_pr",),
|
||||
side_effects=(),
|
||||
# #182: the Cell PM owns cell completion — it merges the cell→root PR
|
||||
# The Cell PM owns cell completion — it merges the cell→root PR
|
||||
# via complete(). Main PM only completes the ROOT task.
|
||||
next_hint=lambda _t: "complete(task_id) to merge the cell→root PR",
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tracing-gate policy — verb→required-set table + check_requirements.
|
||||
|
||||
Replaces (in Task 13):
|
||||
Replaces:
|
||||
- services/gateway/tracing_gate.py (the entire module)
|
||||
- 6 inline `journal:decision` checks scattered in choreographer/_impl.py
|
||||
- inline gates in choreographer/qa.py (QA pass/fail)
|
||||
|
||||
+14
-3
@@ -40,7 +40,7 @@ _SDK_TIMEOUT = 2.0
|
||||
# Mirrors flow_server._CIRCUIT_REJECTION_KINDS — agent_sdk.server is the
|
||||
# authoritative side; the same set must be applied here so the do-server
|
||||
# (content tools) gets the same protection as flow-server (intent verbs).
|
||||
# Smoke-6 surfaced the gap: `note(scope='decision')` looped 8 times
|
||||
# Dogfooding surfaced the gap: `note(scope='decision')` looped 8 times
|
||||
# returning `incomplete_input` with no breaker.
|
||||
_CIRCUIT_REJECTION_KINDS: frozenset[str] = frozenset(
|
||||
{"tracing_gap", "invalid_state", "not_authorized", "incomplete_input"}
|
||||
@@ -74,7 +74,7 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
Rejection envelopes (error in _CIRCUIT_REJECTION_KINDS) are forwarded
|
||||
to the local SDK's /verb/attempted so the per-verb circuit breaker
|
||||
can track them. If the SDK reports open, the original rejection is
|
||||
REPLACED with circuit_open. Smoke-6 surfaced the gap: do-server had
|
||||
REPLACED with circuit_open. Dogfooding surfaced the gap: do-server had
|
||||
no breaker and `note(scope='decision')` looped 8 times returning
|
||||
incomplete_input.
|
||||
"""
|
||||
@@ -132,7 +132,7 @@ def _record_and_check_circuit(
|
||||
"""
|
||||
# Gateway envelopes use a string `error` (kind); RobocoError-derived
|
||||
# exceptions surface a dict-shaped error via FastAPI's middleware
|
||||
# (smoke-7: TypeError on `dict in frozenset`). Defend against the
|
||||
# (a TypeError on `dict in frozenset`). Defend against the
|
||||
# dict shape — only string kinds count toward the breaker, dicts pass
|
||||
# straight through.
|
||||
rejection_kind = payload.get("error")
|
||||
@@ -489,6 +489,16 @@ def pr_update(
|
||||
)
|
||||
|
||||
|
||||
def read_messages() -> dict[str, Any]:
|
||||
"""Mark all your unread A2A direct messages as read.
|
||||
|
||||
Call this when ``i_am_idle()`` soft-blocks you on unread A2A — it clears
|
||||
your direct-message inbox so you can idle. Notifications are separate: use
|
||||
``notify_list`` / ``notify_get`` / ``notify_ack`` for those.
|
||||
"""
|
||||
return _post("/api/v1/do/read_messages", {})
|
||||
|
||||
|
||||
# ---------- Tool registry ----------
|
||||
#
|
||||
# Maps the tool name an agent calls (matches manifest entries and the
|
||||
@@ -509,6 +519,7 @@ _TOOLS: dict[str, Any] = {
|
||||
"notify_ack": notify_ack,
|
||||
"channels": channels,
|
||||
"pr_update": pr_update,
|
||||
"read_messages": read_messages,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ def i_will_work_on(
|
||||
steps: Ordered execution checklist — list of
|
||||
``{"title": "...", "description": "..."}`` with every description
|
||||
substantive. Becomes the plan's sub-tasks AND the progress
|
||||
checklist (#173 — completing a step advances %).
|
||||
checklist (completing a step advances %).
|
||||
technical_considerations: Bullet list (strings) of architectural /
|
||||
library / approach notes.
|
||||
risks: List of ``{"risk": "...", "mitigation": "..."}`` entries.
|
||||
@@ -533,7 +533,7 @@ _TOOLS: dict[str, Any] = {
|
||||
|
||||
# IntentSpec verb names → MCP public tool names. The IntentSpec layer uses
|
||||
# Python-friendly identifiers (no reserved keywords); the MCP layer exposes
|
||||
# the user-facing verb name. Smoke-7 surfaced this gap: the manifest carried
|
||||
# the user-facing verb name. Dogfooding surfaced this gap: the manifest carried
|
||||
# `pass_review`/`fail_review` (IntentSpec names) but flow_server only had
|
||||
# `pass`/`fail` keys, so QA's tools were silently dropped at registration.
|
||||
_INTENT_TO_PUBLIC: dict[str, str] = {
|
||||
@@ -577,8 +577,8 @@ def _register_tools() -> list[str]:
|
||||
|
||||
The manifest is the role-authoritative tool list. Falling back to
|
||||
all-verbs registration (the previous behaviour) caused PMs to see
|
||||
developer/QA verbs and call them at wrong URLs (404s) — see audit
|
||||
2026-05-04 D-12. We now refuse to start without the manifest.
|
||||
developer/QA verbs and call them at wrong URLs (404s). We now refuse
|
||||
to start without the manifest.
|
||||
|
||||
Returns the list of verb names actually registered.
|
||||
"""
|
||||
|
||||
@@ -13,12 +13,12 @@ from roboco.models.base import RobocoBase, TimestampMixin
|
||||
class ProductCellMapping(RobocoBase):
|
||||
"""One cell -> Project assignment within a Product."""
|
||||
|
||||
# Plan-mandated deviation from a bare ``project.py`` mirror: ``RobocoBase``
|
||||
# Deliberate deviation from a bare ``project.py`` mirror: ``RobocoBase``
|
||||
# sets ``use_enum_values=True``, which coerces ``team`` to the plain string
|
||||
# ``"backend"``. The plan's Task 3.1 code requires ``team`` to stay a real
|
||||
# ``Team`` enum so its Step 1 test (``m.team is Team.BACKEND``) and the
|
||||
# validator's ``v.value`` error message hold. Pydantic merges model_config
|
||||
# across inheritance, so this single key inherits the rest of the base.
|
||||
# ``"backend"``. We need ``team`` to stay a real ``Team`` enum so the
|
||||
# ``m.team is Team.BACKEND`` check and the validator's ``v.value`` error
|
||||
# message hold. Pydantic merges model_config across inheritance, so this
|
||||
# single key inherits the rest of the base.
|
||||
model_config = ConfigDict(use_enum_values=False)
|
||||
|
||||
team: Team
|
||||
|
||||
@@ -191,6 +191,10 @@ class Task(TimestampMixin):
|
||||
blocker_ids: list[UUID] = Field(
|
||||
default_factory=list, description="Task IDs this is blocking"
|
||||
)
|
||||
completed_dependency_ids: list[UUID] = Field(
|
||||
default_factory=list,
|
||||
description="Dependency task IDs that have since completed and cleared",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
claimed_at: datetime | None = None
|
||||
|
||||
@@ -192,7 +192,7 @@ def _branch_is_expected(task: dict[str, Any]) -> bool:
|
||||
gets one (it does no git of its own). Gating the "missing branch_name"
|
||||
readiness/stuck condition on this predicate stops the orchestrator from
|
||||
auto-blocking a never-claimed PENDING code task that simply hasn't reached
|
||||
the claim transition yet (issue #18: a pending task sat 13min, auto-blocked
|
||||
the claim transition yet (a pending task sat 13min, auto-blocked
|
||||
every 30s, never dispatched).
|
||||
"""
|
||||
if _is_coordination_task(task):
|
||||
@@ -553,7 +553,7 @@ class AgentOrchestrator:
|
||||
# formal CEO notification per task. Tracks task_ids already notified so
|
||||
# the signal fires exactly once.
|
||||
self._board_review_ceo_notified: set[str] = set()
|
||||
# Stale-claim reaper config. Wave C3 (2026-05-12): sourced from
|
||||
# Stale-claim reaper config, sourced from
|
||||
# stale_claim_reap_seconds (default 600) rather than
|
||||
# claim_stale_seconds (default 180). The two settings are now
|
||||
# distinct: claim_stale_seconds drives trigger_filter (spawn
|
||||
@@ -579,8 +579,8 @@ class AgentOrchestrator:
|
||||
# agents that were WAITING_LONG at shutdown can still be resolved.
|
||||
await self.restore_waiting_records()
|
||||
|
||||
# Self-heal: roll back orphan claims left over from a prior crash
|
||||
# (audit P2-8). Tasks that show CLAIMED/IN_PROGRESS but have NO
|
||||
# Self-heal: roll back orphan claims left over from a prior crash.
|
||||
# Tasks that show CLAIMED/IN_PROGRESS but have NO
|
||||
# branch_name set indicate _finalize_claim flushed the status before
|
||||
# branch creation failed in a pre-P0-7 run. Without this, the next
|
||||
# claim attempt fails non-idempotent on `git checkout -b`.
|
||||
@@ -1534,7 +1534,7 @@ class AgentOrchestrator:
|
||||
|
||||
@staticmethod
|
||||
def _append_claude_json_mount(cmd: list[str], hosts: dict[str, str | None]) -> None:
|
||||
"""Mount host's ~/.claude.json sibling FILE if present (audit D-48)."""
|
||||
"""Mount host's ~/.claude.json sibling FILE if present."""
|
||||
claude_dir = hosts["claude"]
|
||||
if not claude_dir:
|
||||
return
|
||||
@@ -1635,7 +1635,7 @@ class AgentOrchestrator:
|
||||
@staticmethod
|
||||
def _append_workspace_cwd(cmd: list[str], config: AgentConfig) -> None:
|
||||
"""Set the container -w to the agent or cell workspace by role."""
|
||||
# Pre-gateway parity (Wave A2+A3, 2026-05-12). Set the container's cwd
|
||||
# Pre-gateway parity: set the container's cwd
|
||||
# to the agent's task workspace so Edit/Write resolve to paths that
|
||||
# match _get_role_permissions allowlist, and `git add` operates inside
|
||||
# the workspace clone. Without this, container WORKDIR (/app from the
|
||||
@@ -1883,7 +1883,7 @@ class AgentOrchestrator:
|
||||
"ROBOCO_ORCHESTRATOR_URL": api_url,
|
||||
"ROBOCO_AGENT_ID": agent_uuid,
|
||||
"ROBOCO_AGENT_ROLE": agent_role,
|
||||
# #179: every MCP server is launched as `uv run python -m
|
||||
# Every MCP server is launched as `uv run python -m
|
||||
# roboco.mcp.<server>` by Claude Code, with cwd = the agent's
|
||||
# WORKSPACE (not /app). Without this, `uv run` resolves a
|
||||
# cwd-relative `.venv` (≠ the baked /app/.venv), ignores the
|
||||
@@ -2108,7 +2108,7 @@ class AgentOrchestrator:
|
||||
|
||||
Handoff states are role-specific. Dev-owned states (in_progress,
|
||||
verifying, needs_revision, paused, blocked) are restricted to
|
||||
developer/documenter to defang the smoke-8 bug where QA got
|
||||
developer/documenter to defang the bug where QA got
|
||||
respawned on a `needs_revision` task via the crash-restart path
|
||||
and immediately hit ``role 'qa' may not claim from status
|
||||
'needs_revision'`` at the gateway.
|
||||
@@ -2148,7 +2148,7 @@ class AgentOrchestrator:
|
||||
return "task has no project"
|
||||
# Branch is auto-created at claim, so only states at/after claim are
|
||||
# expected to own one. _branch_is_expected centralizes this gate so
|
||||
# the readiness and stuck-detection paths agree (#18).
|
||||
# the readiness and stuck-detection paths agree.
|
||||
if _branch_is_expected(task) and not task.get("branch_name"):
|
||||
return f"state={status} but branch_name is unset"
|
||||
return self._readiness_check_role_for_status(agent_id, role, status)
|
||||
@@ -3289,7 +3289,7 @@ Start by:
|
||||
"""Return (is_running, exit_code) from `docker inspect`.
|
||||
|
||||
exit_code is None when the output is missing or unparseable; the
|
||||
caller treats None as a crash for safety (smoke-8 fix).
|
||||
caller treats None as a crash for safety.
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
@@ -3314,7 +3314,7 @@ Start by:
|
||||
) -> None:
|
||||
"""Update state + auto-restart only when the exit was non-zero.
|
||||
|
||||
Smoke-8 evidence: graceful exits (exit 0 — agent called i_am_idle)
|
||||
Graceful exits (exit 0 — agent called i_am_idle)
|
||||
were treated as crashes by the old logic. The health check bumped
|
||||
error_count and respawned the agent with the prior task_id even if
|
||||
the task had since moved into a state the role can't claim from
|
||||
@@ -3547,7 +3547,7 @@ Start by:
|
||||
# A coordination/fan-out parent (product, no repo of its own) never gets
|
||||
# a branch: the child resolves its own real project and cuts from that
|
||||
# project's default branch, not from the parent. Blocking the child on a
|
||||
# branch the parent will never have wedges the cell↔Main-PM loop (#17).
|
||||
# branch the parent will never have wedges the cell↔Main-PM loop.
|
||||
if _is_coordination_task(parent):
|
||||
return None
|
||||
|
||||
@@ -3709,12 +3709,12 @@ Start by:
|
||||
) -> None:
|
||||
"""Resume a paused parent right before its PM is respawned for closure.
|
||||
|
||||
#170: a PM auto-pauses its owned parent on i_am_idle (by design,
|
||||
A PM auto-pauses its owned parent on i_am_idle (by design,
|
||||
so the closure dispatcher knows to respawn it). Pre-gateway the
|
||||
parent was resumed at respawn so the PM landed actionable; the
|
||||
gateway refactor dropped that, so the respawned PM had to issue
|
||||
``resume()`` itself — which weak models (minimax) reliably fail,
|
||||
wedging the whole chain (smoke-15). Restore the auto-resume:
|
||||
``resume()`` itself — which weak models reliably fail,
|
||||
wedging the whole chain. Restore the auto-resume:
|
||||
paused -> in_progress before spawn so the PM can directly
|
||||
submit_up / complete / escalate. Best-effort; a resume failure
|
||||
must not block the spawn (the PM can still resume manually).
|
||||
@@ -3740,16 +3740,16 @@ Start by:
|
||||
) -> None:
|
||||
"""Recover a blocked parent right before its PM is respawned for closure.
|
||||
|
||||
#177: symmetric to ``_auto_resume_paused_parent`` (#170). The
|
||||
Symmetric to ``_auto_resume_paused_parent``. The
|
||||
closure dispatcher only reaches this point once every descendant
|
||||
is terminal, so a parent still ``blocked`` here is an errant /
|
||||
stale block (e.g. a child's i_am_blocked propagated, or a PM
|
||||
blocked it and never unblocked) — the real dependency is already
|
||||
done. #170 auto-resumed only ``paused`` parents, so a ``blocked``
|
||||
done. That resume path handled only ``paused`` parents, so a ``blocked``
|
||||
one wedged the whole chain forever: the respawned PM cannot
|
||||
submit_up / complete a blocked parent and must first ``unblock``
|
||||
it (needs journal:decision), which weak models never reliably do
|
||||
(smoke-19/this run wedged exactly here). ``blocked -> in_progress``
|
||||
(a dogfood run wedged exactly here). ``blocked -> in_progress``
|
||||
is lifecycle-valid — it is precisely what ``unblock(restore=True)``
|
||||
performs. Best-effort; a failure must not block the spawn (the PM
|
||||
can still ``unblock`` manually).
|
||||
@@ -4940,7 +4940,7 @@ Start now: evidence(task_id="{task_id}")
|
||||
def _is_recently_paused(self, task: dict[str, Any]) -> bool:
|
||||
"""A paused task whose heartbeat is fresher than the stale cutoff.
|
||||
|
||||
Closes the ``i_am_idle`` vs closure-respawn race (audit C12):
|
||||
Closes the ``i_am_idle`` vs closure-respawn race:
|
||||
``i_am_idle`` auto-pauses in-flight tasks and then sets the agent
|
||||
IDLE. If the dispatcher ticks between those two writes it sees a
|
||||
paused parent and would spawn the closure PM against a session
|
||||
@@ -4999,12 +4999,12 @@ Start now: evidence(task_id="{task_id}")
|
||||
pm_id=pm_id,
|
||||
)
|
||||
|
||||
# #170: the parent auto-paused when its PM idled (by design). Resume
|
||||
# The parent auto-paused when its PM idled (by design). Resume
|
||||
# it before respawn so the PM lands actionable (in_progress) and can
|
||||
# directly submit_up / complete / escalate — pre-gateway behaviour the
|
||||
# gateway refactor dropped, which wedged smoke-15 (minimax never
|
||||
# issued resume() itself).
|
||||
# #177: a parent that is `blocked` at closure (all descendants
|
||||
# gateway refactor dropped, which wedged a dogfood run (the model
|
||||
# never issued resume() itself).
|
||||
# A parent that is `blocked` at closure (all descendants
|
||||
# terminal) is an errant/stale block — recover it symmetrically so
|
||||
# the chain can't wedge forever waiting for a PM to manually unblock.
|
||||
parent_status = task.get("status")
|
||||
@@ -5160,7 +5160,7 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
"""
|
||||
|
||||
def _get_prompt_for_agent(self, agent_slug: str, task: dict[str, Any]) -> str:
|
||||
"""Get the prompt appropriate to the agent's ACTUAL role (#19).
|
||||
"""Get the prompt appropriate to the agent's ACTUAL role.
|
||||
|
||||
A respawn must hand each role the prompt it can act on — a PM or board
|
||||
agent handed the developer prompt is told to write code and call verbs
|
||||
@@ -5319,7 +5319,7 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
owner_uuid = self._resolve_dev_owner_uuid(task)
|
||||
agent_slug = self._resolve_agent_slug(owner_uuid) if owner_uuid else None
|
||||
|
||||
# Role/task_type mismatch guard (audit D-49). The dispatcher
|
||||
# Role/task_type mismatch guard. The dispatcher
|
||||
# previously trusted whatever ``assigned_to`` named, so a
|
||||
# documentation task accidentally assigned to a developer agent
|
||||
# would silently spawn the dev. Reject the dispatch if the
|
||||
@@ -5659,7 +5659,7 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
The unblock content gate (note/unblock) is assignee-only: the
|
||||
dispatched agent must be the task's CURRENT ``assigned_to``, or its
|
||||
required pre-unblock decision note returns not_authorized and the
|
||||
orchestrator respawns it forever (#17 livelock — a task escalated to
|
||||
orchestrator respawns it forever (a livelock — a task escalated to
|
||||
Main PM kept respawning the ex-assignee cell PM, which could not author
|
||||
the note). So whenever the blocked task carries an assignee that is a
|
||||
PM or board role, dispatch THAT assignee. Only a task with no PM/board
|
||||
@@ -5708,7 +5708,7 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
def _claimed_task_needs_agent(self, task: dict[str, Any]) -> str | None:
|
||||
"""Return the assignee slug to (re)spawn for an agentless claimed task.
|
||||
|
||||
#19: a task left CLAIMED/IN_PROGRESS with an assignee but no running
|
||||
A task left CLAIMED/IN_PROGRESS with an assignee but no running
|
||||
container (e.g. a reassignment that didn't spawn) is invisibly stuck —
|
||||
only PENDING tasks get fresh dispatch, and the heartbeat reaper can't
|
||||
see it because the claim seeded a fresh heartbeat. Returns the assignee
|
||||
@@ -5735,7 +5735,7 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
return agent_slug
|
||||
|
||||
async def _dispatch_claimed_without_agent(self, client: httpx.AsyncClient) -> None:
|
||||
"""(Re)spawn or release claimed/in_progress tasks that have no agent (#19).
|
||||
"""(Re)spawn or release claimed/in_progress tasks that have no agent.
|
||||
|
||||
Net for the invisible-stuck case the other dispatchers miss: a task
|
||||
held CLAIMED/IN_PROGRESS by an assignee with no running container. If
|
||||
@@ -6077,7 +6077,7 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
# A branch only exists once a task is claimed; a coordination task does
|
||||
# no git at all. A pending, never-claimed code task therefore has no
|
||||
# branch by design — flagging that here auto-blocked tasks before their
|
||||
# first dispatch (#18). Only flag a missing branch when the task is in a
|
||||
# first dispatch. Only flag a missing branch when the task is in a
|
||||
# state where it should already own one.
|
||||
if not task.get("branch_name") and _branch_is_expected(task):
|
||||
issues.append("Task missing branch_name")
|
||||
|
||||
+68
-1
@@ -607,7 +607,7 @@ class A2AService:
|
||||
hint = get_a2a_route_hint(from_agent, target_agent)
|
||||
raise ValueError(f"{error_msg} Hint: {hint}")
|
||||
# Priority parsing: full tristate (NORMAL/HIGH/URGENT) survives
|
||||
# end-to-end after P3 Task 9. Resolution rules live in
|
||||
# end-to-end. Resolution rules live in
|
||||
# foundation.policy.communications.parse_priority.
|
||||
from roboco.foundation.policy.communications import parse_priority
|
||||
|
||||
@@ -1031,6 +1031,32 @@ class A2AService:
|
||||
if from_agent not in (conv.agent_a, conv.agent_b):
|
||||
raise ValueError("Not a participant in this conversation")
|
||||
|
||||
# Purpose-dedup: if an identical message from this sender is still
|
||||
# unread in this conversation, the sender is re-saying the same thing
|
||||
# (a respawn re-emitting, or a retry) — don't stack another copy on the
|
||||
# recipient's inbox or re-bump the unread count. Keyed on
|
||||
# (conversation, sender, kind, content) while unread, so genuinely
|
||||
# different messages are never collapsed.
|
||||
dup = await self.session.scalar(
|
||||
select(A2AMessageTable)
|
||||
.where(
|
||||
A2AMessageTable.conversation_id == conversation_id,
|
||||
A2AMessageTable.from_agent == from_agent,
|
||||
A2AMessageTable.message_kind == message_kind,
|
||||
A2AMessageTable.content == content,
|
||||
A2AMessageTable.read_at.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if dup is not None:
|
||||
logger.info(
|
||||
"Suppressed duplicate unread A2A message",
|
||||
conversation_id=str(conversation_id),
|
||||
from_agent=from_agent,
|
||||
existing_message_id=str(dup.id),
|
||||
)
|
||||
return self._msg_to_model(dup)
|
||||
|
||||
# Create message
|
||||
msg = A2AMessageTable(
|
||||
conversation_id=conversation_id,
|
||||
@@ -1143,6 +1169,47 @@ class A2AService:
|
||||
|
||||
await self.session.flush()
|
||||
|
||||
async def mark_all_read(self, agent_id: UUID) -> int:
|
||||
"""Mark every conversation with unread-for-this-agent as read.
|
||||
|
||||
Agent-keyed bulk form of ``mark_read``: zeroes the agent's per-side
|
||||
unread counter and stamps ``read_at`` on the inbound messages across all
|
||||
its conversations. Returns the number cleared. Lets an agent satisfy
|
||||
``i_am_idle``'s unread-A2A soft-block in one call.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import or_, update
|
||||
|
||||
slug = await self._resolve_slug_from_id(agent_id)
|
||||
result = await self.session.execute(
|
||||
select(A2AConversationTable).where(
|
||||
or_(
|
||||
(A2AConversationTable.agent_a == slug)
|
||||
& (A2AConversationTable.unread_by_a > 0),
|
||||
(A2AConversationTable.agent_b == slug)
|
||||
& (A2AConversationTable.unread_by_b > 0),
|
||||
)
|
||||
)
|
||||
)
|
||||
convs = list(result.scalars().all())
|
||||
if not convs:
|
||||
return 0
|
||||
for conv in convs:
|
||||
if conv.agent_a == slug:
|
||||
conv.unread_by_a = 0
|
||||
else:
|
||||
conv.unread_by_b = 0
|
||||
await self.session.execute(
|
||||
update(A2AMessageTable)
|
||||
.where(A2AMessageTable.conversation_id.in_([c.id for c in convs]))
|
||||
.where(A2AMessageTable.from_agent != slug)
|
||||
.where(A2AMessageTable.read_at.is_(None))
|
||||
.values(read_at=datetime.now(UTC))
|
||||
)
|
||||
await self.session.flush()
|
||||
return len(convs)
|
||||
|
||||
async def get_inbox_summary(self, agent_slug: str) -> A2AInboxSummary:
|
||||
"""Get summary of pending A2A for agent."""
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
@@ -108,7 +108,7 @@ def _coerce_doc_ref(d: object) -> DocRef:
|
||||
"""Build a DocRef from a stored ``Task.documents`` element.
|
||||
|
||||
Canonical rows are dicts (``DocRef.model_dump()``). Defensive
|
||||
against legacy/corrupted rows (#169): a bare path string is wrapped
|
||||
against legacy/corrupted rows: a bare path string is wrapped
|
||||
instead of exploding ``DocRef(**str)`` and 500-ing the endpoint.
|
||||
"""
|
||||
if isinstance(d, DocRef):
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Gateway choreographer package.
|
||||
|
||||
Audit P2-2: the single-file 2,540-line ``choreographer.py`` is being
|
||||
split into per-role mixins composed onto a single ``Choreographer``
|
||||
class. ``board.py`` is the first extraction (Board + Auditor verbs);
|
||||
the rest still live in ``_impl.py`` and will move incrementally per
|
||||
the plan in ``docs/internal/audit_2026_05_04/p2_2_decompose_plan.md``.
|
||||
The single-file 2,540-line ``choreographer.py`` is being split into
|
||||
per-role mixins composed onto a single ``Choreographer`` class.
|
||||
``board.py`` is the first extraction (Board + Auditor verbs); the rest
|
||||
still live in ``_impl.py`` and will move incrementally.
|
||||
|
||||
The public surface (``Choreographer``, ``ChoreographerDeps``,
|
||||
``DelegateInputs``) is re-exported here so every caller's import path
|
||||
|
||||
@@ -31,6 +31,7 @@ from roboco.services.gateway.evidence_builder import (
|
||||
BriefingInputs,
|
||||
build_context_briefing,
|
||||
build_evidence_for_task,
|
||||
build_task_handoff,
|
||||
)
|
||||
from roboco.services.gateway.merge_chain import resolve_parent_branch
|
||||
from roboco.services.gateway.remediation import (
|
||||
@@ -49,7 +50,7 @@ logger = structlog.get_logger()
|
||||
|
||||
# Minimum character length enforced on rich_plan["approach"] by the PM
|
||||
# sub-tasks gate. Must match the Pydantic min_length on
|
||||
# IWillPlanRequest.approach. Raised 20→150 (smoke-15): plans were vague
|
||||
# IWillPlanRequest.approach. Raised 20→150: plans were vague
|
||||
# because 20 chars is a one-liner; the approach + sub_tasks are also the
|
||||
# progress checklist, so they must be substantive.
|
||||
_PM_APPROACH_MIN_LEN = 150
|
||||
@@ -61,7 +62,7 @@ _PM_SUBTASK_DESC_MIN_LEN = 60
|
||||
|
||||
|
||||
def _thin_subtask_hint(sub_tasks: list[Any]) -> str | None:
|
||||
"""Return a hint if any PM sub_task is title-only / thin (#171).
|
||||
"""Return a hint if any PM sub_task is title-only / thin.
|
||||
|
||||
Each sub_task is a delegate target AND a progress-checklist item, so
|
||||
a title with no real description is not a plan. Returns None when
|
||||
@@ -210,7 +211,7 @@ class ChoreographerDeps:
|
||||
journal: Any
|
||||
audit: Any
|
||||
evidence_repo: Any
|
||||
# Task #156: messaging is optional so existing callsites + tests that
|
||||
# messaging is optional so existing callsites + tests that
|
||||
# don't exercise session propagation don't have to plumb it in. The
|
||||
# delegate() path uses it to thread parent sessions onto new subtasks.
|
||||
messaging: Any = None
|
||||
@@ -261,8 +262,8 @@ class _IAmDoneContext:
|
||||
class _ReassignedCtx:
|
||||
"""Bundle of fields the ``_reassigned_rejection`` helper inspects.
|
||||
|
||||
Shared between ``unclaim`` and ``resume`` (Task 6 fix in commit
|
||||
a5d358d). Frozen so the helper site can't mutate caller state and
|
||||
Shared between ``unclaim`` and ``resume``. Frozen so the helper site
|
||||
can't mutate caller state and
|
||||
to keep PLR0913 (too many positional args) at bay.
|
||||
"""
|
||||
|
||||
@@ -280,14 +281,14 @@ class DelegateInputs:
|
||||
|
||||
Mirrors :data:`roboco.foundation.policy.task_completeness.TASK_AT_CREATE`:
|
||||
`task_type` and `nature` have no defaults — the v1 schema enforces both at
|
||||
the HTTP boundary (Task 15), and defaulting here too would let direct
|
||||
the HTTP boundary, and defaulting here too would let direct
|
||||
callers (tests, internal code) silently pick `'code'`/`'technical'` and
|
||||
recreate the 2026-05-08 deadlock.
|
||||
recreate the no-acceptance-criteria deadlock.
|
||||
|
||||
Optional fields (`acceptance_criteria=None`, `nature=None`) survive the
|
||||
construction step and are then rejected by the gateway-side
|
||||
`task_completeness.check` before reaching `_create_subtask_from_inputs`
|
||||
(Task 19): the rejection takes the form of `Envelope.incomplete_input`
|
||||
`task_completeness.check` before reaching `_create_subtask_from_inputs`:
|
||||
the rejection takes the form of `Envelope.incomplete_input`
|
||||
so the agent receives a structured field-by-field guide.
|
||||
"""
|
||||
|
||||
@@ -373,7 +374,7 @@ class Choreographer:
|
||||
) -> None:
|
||||
"""Append a server-emitted progress entry on a lifecycle milestone.
|
||||
|
||||
Task #155: agents call ``progress()`` inconsistently. Server-side
|
||||
Agents call ``progress()`` inconsistently. Server-side
|
||||
auto-emit on natural milestones (open_pr, i_am_done) guarantees
|
||||
the panel + audit view always have entries at the major
|
||||
transitions, regardless of how chatty the agent is. Best-effort:
|
||||
@@ -394,8 +395,8 @@ class Choreographer:
|
||||
) -> Envelope | None:
|
||||
"""Build the "task reassigned by upstream verb" rejection envelope.
|
||||
|
||||
Shared between ``unclaim`` and ``resume`` (Task 6 fix in commit
|
||||
a5d358d). The spec doesn't model "task got reassigned out from
|
||||
Shared between ``unclaim`` and ``resume``. The spec doesn't model
|
||||
"task got reassigned out from
|
||||
under you by an upstream verb" — when the spec gate accepts but
|
||||
``task.assigned_to != agent_id``, this helper produces the
|
||||
envelope with the load-bearing "current owner" hint and
|
||||
@@ -469,13 +470,13 @@ class Choreographer:
|
||||
task_id: UUID,
|
||||
briefing: dict[str, Any],
|
||||
) -> Envelope | None:
|
||||
"""Wave A1 gate: PMs must supply a substantive approach + sub_tasks.
|
||||
"""Plan-depth gate: PMs must supply a substantive approach + sub_tasks.
|
||||
|
||||
Enforces both fields at the choreographer layer so direct service-layer
|
||||
callers (MCP server, test fixtures, orchestrator-internal Python) cannot
|
||||
persist a plan that bypassed the HTTP Pydantic boundary.
|
||||
|
||||
Smoke-15: a 20-char approach and title-only sub_tasks were "no
|
||||
A 20-char approach and title-only sub_tasks were "no
|
||||
effort" plans. approach must be >= _PM_APPROACH_MIN_LEN and every
|
||||
sub_task needs a real title + a description that says what the
|
||||
step does (it is both a delegate target and a progress-checklist
|
||||
@@ -549,12 +550,11 @@ class Choreographer:
|
||||
per-attempt id into the audit row's ``details`` JSONB. The
|
||||
attempt_id is unique per rejection event so post-mortem queries
|
||||
can group "all attempts on task X within a window" without
|
||||
confusing two distinct calls that share a correlation_id (audit
|
||||
P2-7/D-N).
|
||||
confusing two distinct calls that share a correlation_id.
|
||||
"""
|
||||
if env.error is None:
|
||||
return env
|
||||
# Wave C3 (2026-05-12): refresh heartbeat on every rejection so an
|
||||
# Refresh heartbeat on every rejection so an
|
||||
# agent stuck in a verb-rejection loop (e.g., tracing_gap while
|
||||
# retrying) does not look idle to the reaper. Best-effort: a
|
||||
# heartbeat failure must never alter the envelope returned to the
|
||||
@@ -596,7 +596,7 @@ class Choreographer:
|
||||
def _claim_verb_hint(role: str, task: Any) -> str:
|
||||
"""Role + status aware 'how to start this task' hint.
|
||||
|
||||
Task #162 facet (d): give_me_work hard-coded
|
||||
give_me_work used to hard-code
|
||||
``i_will_work_on(...)`` for every role/status. A documenter
|
||||
handed an awaiting_documentation task (or QA an awaiting_qa
|
||||
task) was told to call a dev verb it doesn't have — it looped.
|
||||
@@ -652,7 +652,7 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=self._claim_verb_hint(role, t),
|
||||
context_briefing=await self._briefing_for(agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(agent_id, t.id, task=t),
|
||||
).with_introspection(task=t, role=role)
|
||||
assigned = await self._drop_dependency_held(
|
||||
await self._deps.task.list_assigned_for_agent(agent_id)
|
||||
@@ -663,7 +663,7 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=self._claim_verb_hint(role, t),
|
||||
context_briefing=await self._briefing_for(agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(agent_id, t.id, task=t),
|
||||
).with_introspection(task=t, role=role)
|
||||
paused = await self._deps.task.list_paused_for_agent(agent_id)
|
||||
if paused:
|
||||
@@ -672,7 +672,7 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=f"call resume(task_id='{t.id}') to continue paused work",
|
||||
context_briefing=await self._briefing_for(agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(agent_id, t.id, task=t),
|
||||
).with_introspection(task=t, role=role)
|
||||
return Envelope.ok(
|
||||
status="idle",
|
||||
@@ -682,10 +682,23 @@ class Choreographer:
|
||||
)
|
||||
|
||||
async def _briefing_for(
|
||||
self, agent_id: UUID, task_id: UUID | None
|
||||
self, agent_id: UUID, task_id: UUID | None, *, task: Any | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble context_briefing for agent_id, optionally scoped to task_id."""
|
||||
"""Assemble context_briefing for agent_id, optionally scoped to task_id.
|
||||
|
||||
``task`` is the already-loaded row (every claim / give_me_work / done
|
||||
path holds it). The prior-work handoff is built only when it is passed —
|
||||
no extra fetch — so task-scoped error paths that carry only an id simply
|
||||
omit the digest rather than pay a redundant read for it.
|
||||
"""
|
||||
repo = self._deps.evidence_repo
|
||||
task_handoff: dict[str, Any] | None = None
|
||||
if task_id is not None and task is not None:
|
||||
# Push the prior-work digest so a freshly spawned / respawned agent
|
||||
# resumes from the previous worker's PR + commits + journal rather
|
||||
# than re-exploring the codebase cold on every lifecycle hand-off.
|
||||
handoff_highlights = await repo.journal_highlights_for_task(task_id)
|
||||
task_handoff = build_task_handoff(task, handoff_highlights)
|
||||
inputs = BriefingInputs(
|
||||
unread_a2a=await repo.list_unread_a2a(agent_id),
|
||||
unread_mentions=await repo.list_unread_mentions(agent_id),
|
||||
@@ -695,6 +708,7 @@ class Choreographer:
|
||||
),
|
||||
recent_team_activity=await repo.recent_team_activity(agent_id),
|
||||
blockers_in_my_lane=await repo.blockers_in_lane(agent_id),
|
||||
task_handoff=task_handoff,
|
||||
)
|
||||
return build_context_briefing(inputs)
|
||||
|
||||
@@ -710,7 +724,7 @@ class Choreographer:
|
||||
does NOT model. Role/state/task_type checks now route through
|
||||
``spec.can_invoke_action`` (CLAIM_RULES + ActionSpec.allowed_task_types)
|
||||
in the verb's spec gate; the former role-typed and
|
||||
pm_cannot_execute_code guards have been deleted (Task 27, 2026-05-10).
|
||||
pm_cannot_execute_code guards have been deleted.
|
||||
|
||||
Pre-gateway location: _helpers.py:124-204.
|
||||
"""
|
||||
@@ -757,7 +771,7 @@ class Choreographer:
|
||||
"""Return a tracing_gap rejection if any subtask of ``task_id`` is non-terminal.
|
||||
|
||||
Centralizes the closure-time "all subtasks terminal" gate that fires
|
||||
in submit_up, cell_pm_complete, main_pm_complete (audit P2-3/D-15).
|
||||
in submit_up, cell_pm_complete, main_pm_complete.
|
||||
``context_phrase`` lets each caller name the action being blocked
|
||||
(e.g., "bubbling up", "completing parent").
|
||||
"""
|
||||
@@ -859,7 +873,7 @@ class Choreographer:
|
||||
task_id=task_id,
|
||||
verb=verb_name,
|
||||
)
|
||||
# Wave C4 (2026-05-12) — pre-gateway parity. Ensure WorkSession row
|
||||
# Pre-gateway parity: ensure the WorkSession row
|
||||
# exists on the stuck-claimed recovery path too (same guarantee as
|
||||
# _claim_plan_start_run). Re-entry guard inside ensure_work_session.
|
||||
await self.task.ensure_work_session(task_id, agent_id)
|
||||
@@ -965,7 +979,7 @@ class Choreographer:
|
||||
task_id=ctx.task_id,
|
||||
verb=verb_name,
|
||||
)
|
||||
# Wave C4 (2026-05-12) — pre-gateway parity. Create the WorkSession
|
||||
# Pre-gateway parity: create the WorkSession
|
||||
# row so downstream subsystems (panel, PR, merge chain) can track
|
||||
# this agent's per-task git activity. work_session_id stored on the
|
||||
# task; one WorkSession per (agent, task) claim cycle; re-entry
|
||||
@@ -987,7 +1001,7 @@ class Choreographer:
|
||||
risks: list[dict[str, Any]] | None,
|
||||
open_questions: list[dict[str, Any]] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble the panel-shaped rich plan (#172) from a dev's inputs."""
|
||||
"""Assemble the panel-shaped rich plan from a dev's inputs."""
|
||||
return {
|
||||
"approach": plan or "",
|
||||
"sub_tasks": steps or [],
|
||||
@@ -1014,10 +1028,10 @@ class Choreographer:
|
||||
the DB. Idempotent re-entry: a respawned dev re-calling on a
|
||||
task they already own in_progress just refreshes the heartbeat.
|
||||
|
||||
#172: ``steps`` is the developer's execution checklist (same
|
||||
``steps`` is the developer's execution checklist (same
|
||||
SubTask shape as a PM's sub_tasks). Persisted into
|
||||
``task.plan.sub_tasks`` via the panel-shaped path so it renders
|
||||
identically AND feeds plan-driven progress (#173). A developer
|
||||
identically AND feeds plan-driven progress. A developer
|
||||
on a fresh claim must supply substantive steps —
|
||||
``_dev_steps_gate`` enforces depth; the re-entry / recovery
|
||||
paths short-circuit before the gate so a respawned dev is never
|
||||
@@ -1033,7 +1047,7 @@ class Choreographer:
|
||||
)
|
||||
agent = await self.task.agent_for(agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "developer"
|
||||
briefing = await self._briefing_for(agent_id, task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
try:
|
||||
role = spec_module.Role(role_str)
|
||||
except ValueError:
|
||||
@@ -1047,10 +1061,10 @@ class Choreographer:
|
||||
task_id=task_id,
|
||||
verb="i_will_work_on",
|
||||
)
|
||||
# #172/full-parity: a dev authors the same rich plan a PM does. The
|
||||
# Full parity: a dev authors the same rich plan a PM does. The
|
||||
# dev's `plan` doubles as the Approach; `steps` become sub_tasks. Built
|
||||
# via the panel-shaped path so the Plan tab renders identically and
|
||||
# feeds #173 progress. With no rich fields (re-entry/recovery) this
|
||||
# feeds progress. With no rich fields (re-entry/recovery) this
|
||||
# falls through to unchanged string behaviour.
|
||||
rich_plan = self._build_rich_plan(
|
||||
plan, steps, technical_considerations, risks, open_questions
|
||||
@@ -1118,7 +1132,7 @@ class Choreographer:
|
||||
# Recovery re-entry: task stuck in `claimed` (orchestrator restart
|
||||
# or partial-claim race) and the agent already owns it. The spec
|
||||
# `claim` source-statuses exclude CLAIMED, so run only set_plan +
|
||||
# start (Bug A from the 2026-05-09 smoke test).
|
||||
# start.
|
||||
if str(t.status) == "claimed" and t.assigned_to == agent_id:
|
||||
envelope = await self._resume_from_claimed(ctx)
|
||||
return await self._post_claim_journal_gate(
|
||||
@@ -1253,8 +1267,7 @@ class Choreographer:
|
||||
Renamed from ``submit_for_qa`` (2026-05-08): the old name
|
||||
suggested this verb advanced the lifecycle, but it only opens
|
||||
the PR. Agents misread the name, called it expecting a QA
|
||||
handoff, then never called i_am_done — orphaning PRs (e.g.
|
||||
PR #12 in the smoke-test trace).
|
||||
handoff, then never called i_am_done — orphaning open PRs.
|
||||
|
||||
Idempotent on re-call: if the caller already owns the task and
|
||||
a PR is already open, return OK pointing at ``i_am_done`` rather
|
||||
@@ -1269,7 +1282,7 @@ class Choreographer:
|
||||
task_id=task_id,
|
||||
verb="open_pr",
|
||||
)
|
||||
briefing = await self._briefing_for(agent_id, task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
agent = await self.task.agent_for(agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "developer"
|
||||
# Idempotent re-entry: caller owns the task and a PR is already
|
||||
@@ -1344,8 +1357,8 @@ class Choreographer:
|
||||
"""Refresh the task, auto-emit milestone progress, build the OK envelope.
|
||||
|
||||
git_service.create_pr writes pr_number / pr_url onto the task row;
|
||||
the runner doesn't bubble that update back so we re-fetch. Task
|
||||
#155 milestone progress fires server-side so the panel + audit
|
||||
the runner doesn't bubble that update back so we re-fetch.
|
||||
Milestone progress fires server-side so the panel + audit
|
||||
log always show "opened PR #N" regardless of agent chattiness.
|
||||
"""
|
||||
refreshed = await self.task.get(task_id)
|
||||
@@ -1389,8 +1402,8 @@ class Choreographer:
|
||||
in the verb body.
|
||||
|
||||
The previous strict path required a separate ``submit_for_verification``
|
||||
verb that wasn't on any manifest, making i_am_done unreachable
|
||||
(audit D-08). Removed that requirement; the act of calling i_am_done
|
||||
verb that wasn't on any manifest, making i_am_done unreachable.
|
||||
Removed that requirement; the act of calling i_am_done
|
||||
IS the self-verification.
|
||||
"""
|
||||
t = await self.task.get(task_id)
|
||||
@@ -1403,7 +1416,7 @@ class Choreographer:
|
||||
)
|
||||
agent = await self.task.agent_for(agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "developer"
|
||||
briefing = await self._briefing_for(agent_id, task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
ctx = _IAmDoneContext(
|
||||
agent_id=agent_id,
|
||||
task_id=task_id,
|
||||
@@ -1471,7 +1484,7 @@ class Choreographer:
|
||||
return await self._reject_i_am_done(ctx, rejection)
|
||||
if rejection := await self._ensure_branch_pushed(ctx):
|
||||
return await self._reject_i_am_done(ctx, rejection)
|
||||
# Wave C5 (2026-05-12) — pre-gateway parity. Persist per-criterion
|
||||
# Pre-gateway parity: persist per-criterion
|
||||
# status now that all gates have passed. The write runs AFTER the
|
||||
# verdict so it cannot change i_am_done's rejection behavior.
|
||||
await self._write_criteria_status(ctx.agent_id, ctx.task_id, ctx.task)
|
||||
@@ -1553,7 +1566,7 @@ class Choreographer:
|
||||
) -> None:
|
||||
"""Persist per-criterion addressing status to task.acceptance_criteria_status.
|
||||
|
||||
Wave C5 (2026-05-12) — pre-gateway parity. The i_am_done gate uses
|
||||
Pre-gateway parity: the i_am_done gate uses
|
||||
journal:reflect as a blanket addressing artifact when it is present
|
||||
(one reflect note covers all criteria — spec §9 item 1). We surface
|
||||
that decision as a structured per-criterion list so the panel and
|
||||
@@ -1612,7 +1625,7 @@ class Choreographer:
|
||||
)
|
||||
await self._notify_qa(ctx.agent_id, ctx.task_id, t)
|
||||
await self._touch(ctx.task_id)
|
||||
# Task #155: server-side milestone progress so the panel always
|
||||
# Server-side milestone progress so the panel always
|
||||
# records the QA handoff regardless of agent's progress() habits.
|
||||
await self._record_milestone_progress(
|
||||
ctx.task_id,
|
||||
@@ -1688,7 +1701,7 @@ class Choreographer:
|
||||
)
|
||||
if result.passed:
|
||||
return None
|
||||
return await self._build_tracing_gap(agent_id, task_id, result.missing)
|
||||
return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)
|
||||
|
||||
async def _post_claim_journal_gate(
|
||||
self,
|
||||
@@ -1756,7 +1769,7 @@ class Choreographer:
|
||||
)
|
||||
if result.passed:
|
||||
return None
|
||||
return await self._build_tracing_gap(agent_id, task_id, result.missing)
|
||||
return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)
|
||||
|
||||
async def _check_pm_decision_required(
|
||||
self, verb: str, agent_id: UUID, task_id: UUID, t: Any
|
||||
@@ -1769,7 +1782,7 @@ class Choreographer:
|
||||
``submit_up``) use the verb-specific helpers below which thread
|
||||
the additional state (reflect, notes, subtasks) into GateContext.
|
||||
|
||||
Wave C8 (2026-05-12) — pre-gateway parity: the gate requires the
|
||||
Pre-gateway parity: the gate requires the
|
||||
*most recent* journal:decision for (agent, task) to be no older
|
||||
than ``settings.pm_decision_window_seconds``. Older decisions are
|
||||
treated as missing so PMs write a fresh decision around each
|
||||
@@ -1797,7 +1810,7 @@ class Choreographer:
|
||||
)
|
||||
if result.passed:
|
||||
return None
|
||||
return await self._build_tracing_gap(agent_id, task_id, result.missing)
|
||||
return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)
|
||||
|
||||
async def _check_complete_gates(
|
||||
self, agent_id: UUID, task_id: UUID, notes: str
|
||||
@@ -1821,7 +1834,14 @@ class Choreographer:
|
||||
task_view = SimpleNamespace(notes=notes)
|
||||
ctx = _tr.GateContext(
|
||||
journal_decision_present=has_decision,
|
||||
journal_reflect_present=has_reflect,
|
||||
# A PM closing/submitting a task documents it in its *decision* note;
|
||||
# a separate *reflect* adds little for a coordination/review close and
|
||||
# is exactly the artifact weak-model PMs forget — looping on the
|
||||
# reflect gate until reaped (re-confirmed live 2026-06-10). Accept a
|
||||
# decision as satisfying reflect for the PM complete/submit_up close;
|
||||
# the gate still requires a decision + substantive notes, so the close
|
||||
# stays documented — only the redundant second-artifact demand drops.
|
||||
journal_reflect_present=has_reflect or has_decision,
|
||||
notes_min_chars=getattr(_settings, "notes_min_chars", 20),
|
||||
)
|
||||
result = _tr.check_requirements(
|
||||
@@ -1856,7 +1876,14 @@ class Choreographer:
|
||||
task_view = SimpleNamespace(notes=notes)
|
||||
ctx = _tr.GateContext(
|
||||
journal_decision_present=has_decision,
|
||||
journal_reflect_present=has_reflect,
|
||||
# A PM closing/submitting a task documents it in its *decision* note;
|
||||
# a separate *reflect* adds little for a coordination/review close and
|
||||
# is exactly the artifact weak-model PMs forget — looping on the
|
||||
# reflect gate until reaped (re-confirmed live 2026-06-10). Accept a
|
||||
# decision as satisfying reflect for the PM complete/submit_up close;
|
||||
# the gate still requires a decision + substantive notes, so the close
|
||||
# stays documented — only the redundant second-artifact demand drops.
|
||||
journal_reflect_present=has_reflect or has_decision,
|
||||
notes_min_chars=getattr(_settings, "notes_min_chars", 20),
|
||||
)
|
||||
requirements: list[_tr.Requirement] = [
|
||||
@@ -1886,7 +1913,7 @@ class Choreographer:
|
||||
# NOTE: self_verified is no longer a precondition — i_am_done auto-runs
|
||||
# the in_progress → verifying transition which sets it. The previous
|
||||
# NOT_SELF_VERIFIED gate required a separate submit_for_verification
|
||||
# verb that wasn't on any manifest (audit D-08).
|
||||
# verb that wasn't on any manifest.
|
||||
if not t.commits:
|
||||
missing.append("NO_COMMITS")
|
||||
hints.append(
|
||||
@@ -1904,7 +1931,7 @@ class Choreographer:
|
||||
return Envelope.tracing_gap(
|
||||
missing=missing,
|
||||
remediate=" ; ".join(hints),
|
||||
context_briefing=await self._briefing_for(agent_id, task_id),
|
||||
context_briefing=await self._briefing_for(agent_id, task_id, task=t),
|
||||
)
|
||||
|
||||
async def _build_i_am_done_ok(
|
||||
@@ -1912,7 +1939,7 @@ class Choreographer:
|
||||
) -> Envelope:
|
||||
"""Assemble the success envelope for i_am_done / _with_catchup.
|
||||
|
||||
Task #154: files_changed sourced from git (authoritative) so the
|
||||
files_changed sourced from git (authoritative) so the
|
||||
i_am_done envelope shows the same file list QA / docs / PMs will
|
||||
see — independent of legacy ``add_files_modified`` plumbing.
|
||||
"""
|
||||
@@ -1934,7 +1961,7 @@ class Choreographer:
|
||||
task_id=str(task_id),
|
||||
next="idle until QA responds",
|
||||
evidence=evidence.as_dict(),
|
||||
context_briefing=await self._briefing_for(agent_id, task_id),
|
||||
context_briefing=await self._briefing_for(agent_id, task_id, task=t),
|
||||
).with_introspection(task=t, role=role)
|
||||
|
||||
@staticmethod
|
||||
@@ -2015,11 +2042,16 @@ class Choreographer:
|
||||
return simple_hints.get(missing_key)
|
||||
|
||||
async def _build_tracing_gap(
|
||||
self, agent_id: UUID, task_id: UUID, missing: list[str]
|
||||
self,
|
||||
agent_id: UUID,
|
||||
task_id: UUID,
|
||||
missing: list[str],
|
||||
*,
|
||||
task: Any | None = None,
|
||||
) -> Envelope:
|
||||
"""Translate missing requirement keys into agent-facing hints.
|
||||
|
||||
Task #159: multi-missing remediate uses a numbered list so the
|
||||
Multi-missing remediate uses a numbered list so the
|
||||
agent sees each requirement as a distinct step instead of a
|
||||
single semicolon-joined sentence the model parses as one
|
||||
instruction. Each missing key with no hint in
|
||||
@@ -2065,7 +2097,7 @@ class Choreographer:
|
||||
return Envelope.tracing_gap(
|
||||
missing=missing,
|
||||
remediate=remediate,
|
||||
context_briefing=await self._briefing_for(agent_id, task_id),
|
||||
context_briefing=await self._briefing_for(agent_id, task_id, task=task),
|
||||
)
|
||||
|
||||
async def _notify_qa(self, agent_id: UUID, task_id: UUID, t: Any) -> None:
|
||||
@@ -2094,7 +2126,7 @@ class Choreographer:
|
||||
``id`` keys) or ``capabilities`` (SQLAlchemy AgentTable, list of
|
||||
strings). The DB-side AgentTable has no ``skills`` attribute,
|
||||
so a naive ``target_agent.skills`` raises AttributeError on
|
||||
production agents (audit D-06). Falls back to the first entry
|
||||
production agents. Falls back to the first entry
|
||||
in ``preference`` when no match is found.
|
||||
"""
|
||||
skills_attr = getattr(target_agent, "skills", None)
|
||||
@@ -2183,7 +2215,7 @@ class Choreographer:
|
||||
)
|
||||
agent = await self.task.agent_for(agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "developer"
|
||||
briefing = await self._briefing_for(agent_id, task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
try:
|
||||
role = spec_module.Role(role_str)
|
||||
except ValueError:
|
||||
@@ -2254,7 +2286,7 @@ class Choreographer:
|
||||
invalid_state when the status drifted between get and write.
|
||||
"""
|
||||
t = await self.task.get(task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
if t is None:
|
||||
return await self._emit_rejection(
|
||||
Envelope.not_found(message=f"task {task_id} not found"),
|
||||
@@ -2405,7 +2437,7 @@ class Choreographer:
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
|
||||
t = await self.task.get(task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
if t is None:
|
||||
return await self._emit_rejection(
|
||||
Envelope.not_found(message=f"task {task_id} not found"),
|
||||
@@ -2492,7 +2524,7 @@ class Choreographer:
|
||||
atomic chain wrapped in a savepoint.
|
||||
"""
|
||||
t = await self.task.get(task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
if t is None:
|
||||
return await self._emit_rejection(
|
||||
Envelope.not_found(message=f"task {task_id} not found"),
|
||||
@@ -2602,8 +2634,8 @@ class Choreographer:
|
||||
status="idle_with_unread",
|
||||
task_id=None,
|
||||
next=(
|
||||
"address unread A2A and @mentions in context_briefing"
|
||||
" before going idle"
|
||||
"clear your inbox, then retry i_am_idle(): read_messages()"
|
||||
" for unread A2A, notify_ack() per @mention notification"
|
||||
),
|
||||
context_briefing=briefing,
|
||||
)
|
||||
@@ -2611,6 +2643,10 @@ class Choreographer:
|
||||
return await self._emit_rejection(
|
||||
guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
|
||||
)
|
||||
if guard := await self._pm_unfinished_review_guard(agent_id, briefing):
|
||||
return await self._emit_rejection(
|
||||
guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
|
||||
)
|
||||
paused_ids = await self._auto_pause_in_progress_tasks(agent_id)
|
||||
await self.task.mark_agent_idle(agent_id)
|
||||
if paused_ids:
|
||||
@@ -2672,6 +2708,38 @@ class Choreographer:
|
||||
context_briefing=briefing,
|
||||
)
|
||||
|
||||
async def _pm_unfinished_review_guard(
|
||||
self, agent_id: UUID, briefing: dict[str, Any]
|
||||
) -> Envelope | None:
|
||||
"""Refuse i_am_idle when a PM still owns a task awaiting its own review.
|
||||
|
||||
A cell/main PM once tried to "send work back" by DMing the developer and
|
||||
going idle — but a DM changes no task state, so the task stayed
|
||||
awaiting_pm_review and the PM was just re-dispatched in a loop. A PM that
|
||||
owns an awaiting_pm_review task must act on it (complete to finish, or
|
||||
reassign/delegate to route it back) before it can idle.
|
||||
"""
|
||||
agent = await self.task.agent_for(agent_id)
|
||||
if not agent or agent.role not in ("cell_pm", "main_pm"):
|
||||
return None
|
||||
assigned = await self.task.list_assigned_for_agent(agent_id)
|
||||
review = next(
|
||||
(t for t in assigned if str(t.status) == "awaiting_pm_review"), None
|
||||
)
|
||||
if review is None:
|
||||
return None
|
||||
return Envelope.invalid_state(
|
||||
message=(
|
||||
f"you still own task {review.id} awaiting your review; a DM does"
|
||||
" not route work, so idling just re-dispatches you."
|
||||
),
|
||||
remediate=(
|
||||
"complete(task_id) to finish it, or reassign()/delegate() to"
|
||||
" send it back — then retry i_am_idle()."
|
||||
),
|
||||
context_briefing=briefing,
|
||||
)
|
||||
|
||||
async def _auto_pause_in_progress_tasks(self, agent_id: UUID) -> list[str]:
|
||||
"""Pause every in_progress task assigned to this agent.
|
||||
|
||||
@@ -2683,7 +2751,7 @@ class Choreographer:
|
||||
``i_am_idle`` can tell the agent which ``resume(task_id)`` calls
|
||||
await it on the next respawn. Empty list when nothing was active.
|
||||
|
||||
Wave C7 (2026-05-12) — pre-gateway parity: a synthetic checkpoint is
|
||||
Pre-gateway parity: a synthetic checkpoint is
|
||||
written for each paused task so the panel's Checkpoints column reflects
|
||||
reality. Checkpoint failure is swallowed; it must never block the pause.
|
||||
"""
|
||||
@@ -2698,7 +2766,7 @@ class Choreographer:
|
||||
async def _write_auto_pause_checkpoint(self, agent_id: UUID, task: Any) -> None:
|
||||
"""Write a synthetic checkpoint for a task that was auto-paused on i_am_idle.
|
||||
|
||||
Wave C7 (2026-05-12) — captures state-at-pause so the panel's
|
||||
Captures state-at-pause so the panel's
|
||||
Checkpoints column is never empty after an auto-pause. Agents that
|
||||
want an explicit checkpoint before idling can call note(scope='note',
|
||||
text='checkpoint: ...') first; this synthetic write covers the bare
|
||||
@@ -2736,11 +2804,11 @@ class Choreographer:
|
||||
agent_id=str(agent_id),
|
||||
)
|
||||
|
||||
# --- Phase 2 (QA) verbs moved to ``qa.py`` (audit P2-2). ---
|
||||
# --- QA verbs moved to ``qa.py``. ---
|
||||
|
||||
# --- Phase 3 (documenter + PM) verbs ---
|
||||
|
||||
# claim_doc_task + i_documented moved to ``doc.py`` (audit P2-2).
|
||||
# claim_doc_task + i_documented moved to ``doc.py``.
|
||||
|
||||
_RICH_PLAN_FIELDS: ClassVar[tuple[str, ...]] = (
|
||||
"approach",
|
||||
@@ -2802,7 +2870,7 @@ class Choreographer:
|
||||
)
|
||||
agent = await self.task.agent_for(pm_agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "cell_pm"
|
||||
briefing = await self._briefing_for(pm_agent_id, task_id)
|
||||
briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
|
||||
try:
|
||||
role = spec_module.Role(role_str)
|
||||
except ValueError:
|
||||
@@ -2817,7 +2885,7 @@ class Choreographer:
|
||||
verb="i_will_plan",
|
||||
)
|
||||
effective_plan = self._resolve_effective_plan(plan, rich_plan)
|
||||
# Task #153: spec_ctx carries the resolved (possibly-dict) plan so the
|
||||
# spec_ctx carries the resolved (possibly-dict) plan so the
|
||||
# verb runner's set_plan handler persists the panel-shaped rich shape.
|
||||
# Passing the raw `plan` string here was the bug that left the Plan tab
|
||||
# empty: the runner uses spec_ctx, not _ClaimPlanStartContext.
|
||||
@@ -2894,7 +2962,7 @@ class Choreographer:
|
||||
)
|
||||
agent = await self.task.agent_for(pm_agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "cell_pm"
|
||||
briefing = await self._briefing_for(pm_agent_id, parent_task_id)
|
||||
briefing = await self._briefing_for(pm_agent_id, parent_task_id, task=parent)
|
||||
try:
|
||||
role = spec_module.Role(role_str)
|
||||
except ValueError:
|
||||
@@ -2923,12 +2991,12 @@ class Choreographer:
|
||||
task_id=parent_task_id,
|
||||
verb="delegate",
|
||||
)
|
||||
# Task 19: foundation/policy/task_completeness gate runs BEFORE the
|
||||
# The foundation/policy/task_completeness gate runs BEFORE the
|
||||
# static/lifecycle guards. Auto-fill helpers patch unambiguous fields
|
||||
# (team-from-slug, priority-from-parent), then `check(TASK_AT_CREATE,
|
||||
# ...)` rejects under-filled payloads with `Envelope.incomplete_input`
|
||||
# — the spec §5.2.1 interrogation pattern. Defense-in-depth: the
|
||||
# service-layer raise from Task 18 still catches non-gateway callers.
|
||||
# service-layer raise still catches non-gateway callers.
|
||||
completeness_env = self._delegate_completeness_check(
|
||||
inputs, parent, briefing, role_str
|
||||
)
|
||||
@@ -3066,7 +3134,7 @@ class Choreographer:
|
||||
|
||||
@staticmethod
|
||||
def _is_cross_team_planning(new_type: str, new_team: str, sib_team: str) -> bool:
|
||||
"""Task #157: planning subtasks on different teams are NOT
|
||||
"""Planning subtasks on different teams are NOT
|
||||
over-decomposition — main_pm fans planning out to per-cell PMs.
|
||||
Both teams must be non-empty so an empty-team escape hatch can't
|
||||
bypass the cap defensively.
|
||||
@@ -3113,7 +3181,7 @@ class Choreographer:
|
||||
1. **Same-type concurrency cap**: a parent may have AT MOST one
|
||||
non-terminal subtask of types ``code`` / ``planning`` /
|
||||
``documentation`` at any given time, regardless of assignee.
|
||||
Task #157 exception: ``planning`` subtasks on different
|
||||
Exception: ``planning`` subtasks on different
|
||||
teams are allowed in parallel — that's main_pm's legitimate
|
||||
cross-cell fanout.
|
||||
2. **Same-assignee same-type** (fallback): a PM never delegates two
|
||||
@@ -3178,7 +3246,7 @@ class Choreographer:
|
||||
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
|
||||
)
|
||||
if str(inputs.task_type) == "documentation":
|
||||
# Task #163: the lifecycle auto-handles documentation. After a
|
||||
# The lifecycle auto-handles documentation. After a
|
||||
# `code` subtask passes QA it transitions to
|
||||
# awaiting_documentation and a *documenter* is spawned
|
||||
# automatically. A PM-created `documentation` subtask assigned
|
||||
@@ -3212,7 +3280,7 @@ class Choreographer:
|
||||
"""Reject role-vs-type misclassifications.
|
||||
|
||||
Rules:
|
||||
- (2026-05-09 smoke Bug B): delegating to a Cell PM requires
|
||||
- delegating to a Cell PM requires
|
||||
``task_type='planning'``. Cell PMs decompose; they don't execute.
|
||||
- (2026-05-11 smoke): delegating to a Developer requires
|
||||
``task_type in {'code', 'documentation', 'research'}``. Devs
|
||||
@@ -3394,7 +3462,7 @@ class Choreographer:
|
||||
briefing: dict[str, Any],
|
||||
role_str: str,
|
||||
) -> Envelope | None:
|
||||
"""Task 19: foundation/policy/task_completeness gate for delegate.
|
||||
"""The foundation/policy/task_completeness gate for delegate.
|
||||
|
||||
Auto-fills unambiguous fields (team-from-slug, priority-from-parent)
|
||||
without overwriting explicit values, then runs `check(TASK_AT_CREATE,
|
||||
@@ -3408,7 +3476,7 @@ class Choreographer:
|
||||
|
||||
The `acceptance_criteria=inputs.acceptance_criteria or []` collapse
|
||||
at `_create_subtask_from_inputs` was removed alongside this gate,
|
||||
so under-filled payloads now hit the service-layer raise (Task 18)
|
||||
so under-filled payloads now hit the service-layer raise
|
||||
instead of being silently substituted. This method is the
|
||||
gateway-side defense; the service raise is defense-in-depth for
|
||||
non-gateway callers.
|
||||
@@ -3460,7 +3528,7 @@ class Choreographer:
|
||||
) -> Envelope:
|
||||
"""Run subtask creation and translate completeness raises into envelopes.
|
||||
|
||||
The defensive raises inside `_create_subtask_from_inputs` (Task 18)
|
||||
The defensive raises inside `_create_subtask_from_inputs`
|
||||
catch under-filled payloads that slipped past the gateway gate. Without
|
||||
this translator they surface as Starlette 500s — which means the agent
|
||||
never sees `field_hints`, retries indefinitely, and looks like a
|
||||
@@ -3666,7 +3734,7 @@ class Choreographer:
|
||||
and `inputs.nature` are guaranteed non-None / non-empty here. The
|
||||
defensive `TaskCompletenessError` raises preserve correctness if
|
||||
a future caller bypasses the gateway path — defense-in-depth in
|
||||
line with Task 18's service-layer raise.
|
||||
line with the service-layer raise.
|
||||
"""
|
||||
from roboco.foundation.policy.task_completeness import TaskCompletenessError
|
||||
from roboco.models.base import TaskNature
|
||||
@@ -3675,10 +3743,10 @@ class Choreographer:
|
||||
|
||||
team_enum, type_enum, complexity_enum = self._resolve_delegate_enums(inputs)
|
||||
assignee_id = UUID(AGENT_UUIDS[inputs.assigned_to])
|
||||
# Task 19: the `or []` collapse was removed. The gateway runs
|
||||
# The `or []` collapse was removed. The gateway runs
|
||||
# `_delegate_completeness_check` BEFORE this helper, so empty/None
|
||||
# acceptance_criteria here means a non-gateway caller bypassed the
|
||||
# check. Raise so the service-layer raise (Task 18) can attach the
|
||||
# check. Raise so the service-layer raise can attach the
|
||||
# field hints — never silently substitute.
|
||||
if not inputs.acceptance_criteria:
|
||||
raise TaskCompletenessError(
|
||||
@@ -3728,7 +3796,7 @@ class Choreographer:
|
||||
estimated_complexity=complexity_enum,
|
||||
)
|
||||
new_task = await self.task.create_subtask(req)
|
||||
# Task #156: thread the parent's existing session links onto the
|
||||
# Thread the parent's existing session links onto the
|
||||
# new subtask so the assigned agent (dev/qa/doc) lands in the
|
||||
# group chat the PM has already been talking in. Pre-gateway
|
||||
# parity — sessions were wired to the whole tree at creation
|
||||
@@ -3788,7 +3856,7 @@ class Choreographer:
|
||||
returns, the task is handed off to the Main PM (reassign + a2a).
|
||||
"""
|
||||
t = await self.task.get(task_id)
|
||||
briefing = await self._briefing_for(pm_agent_id, task_id)
|
||||
briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
|
||||
if t is None:
|
||||
return await self._emit_rejection(
|
||||
Envelope.not_found(message=f"task {task_id} not found"),
|
||||
@@ -3852,7 +3920,7 @@ class Choreographer:
|
||||
verb="submit_up",
|
||||
)
|
||||
t = outcome
|
||||
# #182: do NOT hand the cell task to Main PM. The cell PM owns cell
|
||||
# Do NOT hand the cell task to Main PM. The cell PM owns cell
|
||||
# completion — it stays assigned to the cell PM, which is respawned to
|
||||
# `complete` the task (merging the cell→root PR). Main PM only
|
||||
# completes the ROOT (root→master + escalate-to-CEO).
|
||||
@@ -3969,7 +4037,7 @@ class Choreographer:
|
||||
statuses — PMs care about all assigned tasks (planning, paused, in
|
||||
progress, awaiting_pm_review).
|
||||
|
||||
Pre-assigned pending tasks are checked first (Wave B6, 2026-05-12).
|
||||
Pre-assigned pending tasks are checked first.
|
||||
Smoke run 3 showed Main PM getting idle even though c7935d2c was
|
||||
pending and assigned_to=main-pm because list_assigned_for_agent
|
||||
ordered by priority/updated_at and could rank a pre-assigned pending
|
||||
@@ -3985,7 +4053,7 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=self._pm_next_hint(str(t.status), t.id),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
)
|
||||
assigned = await self.task.list_assigned_for_agent(pm_agent_id)
|
||||
if assigned:
|
||||
@@ -3995,7 +4063,7 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=self._pm_next_hint(str(t.status), t.id),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="idle",
|
||||
@@ -4030,7 +4098,7 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=f"investigate the block, then unblock(task_id='{t.id}')",
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
)
|
||||
awaiting = await self.task.list_awaiting_pm_review_for_team(pm.team)
|
||||
if awaiting:
|
||||
@@ -4039,7 +4107,7 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=f"review and complete(task_id='{t.id}')",
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="idle",
|
||||
@@ -4060,7 +4128,7 @@ class Choreographer:
|
||||
f"escalation/cross-cell help required: investigate, then "
|
||||
f"unblock(task_id='{t.id}') or escalate_up()"
|
||||
),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
)
|
||||
awaiting = await self.task.list_awaiting_main_pm_all()
|
||||
if awaiting:
|
||||
@@ -4069,7 +4137,7 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=f"complete(task_id='{t.id}') opens master PR + escalates to CEO",
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="idle",
|
||||
@@ -4158,7 +4226,7 @@ class Choreographer:
|
||||
async def _own_review_hint(self, pm_agent_id: UUID, exclude_task_id: UUID) -> str:
|
||||
"""Remediate suffix naming the PM's OWN task ready to complete.
|
||||
|
||||
Smoke-15 wedge (#170): a PM looped firing complete/unblock at the
|
||||
A PM looped firing complete/unblock at the
|
||||
wrong (parent) task_id while its own leaf sat at
|
||||
``awaiting_pm_review``, never named in any rejection — minimax
|
||||
never found the one correct call. Surface it explicitly.
|
||||
@@ -4252,7 +4320,7 @@ class Choreographer:
|
||||
task_id=task_id,
|
||||
verb="cell_pm_complete",
|
||||
)
|
||||
# #181/#182: resolve the merge target from the PARENT task's real
|
||||
# Resolve the merge target from the PARENT task's real
|
||||
# branch_name. For a leaf this is the cell branch (same team — no
|
||||
# change); for a cell task it is the root branch (feature/main_pm/…),
|
||||
# which parent_branch_for would have mis-derived as feature/<cellteam>/…
|
||||
@@ -4329,7 +4397,7 @@ class Choreographer:
|
||||
main_pm_agent_id, root_task_id
|
||||
),
|
||||
)
|
||||
# #183: accept in_progress too. A root resumed from paused (its
|
||||
# Accept in_progress too. A root resumed from paused (its
|
||||
# subtasks all done) sits in in_progress — there is no submit_up for
|
||||
# roots to move it to awaiting_pm_review. main_pm_complete itself
|
||||
# opens the root→master PR and walks it through awaiting_pm_review
|
||||
@@ -4403,7 +4471,7 @@ class Choreographer:
|
||||
if needs_pr:
|
||||
await self.git.create_pr(t.branch_name, parent="master", is_root_pr=True)
|
||||
|
||||
# #183: escalate_to_ceo requires source=awaiting_pm_review, but a root
|
||||
# escalate_to_ceo requires source=awaiting_pm_review, but a root
|
||||
# resumed from paused is in_progress and nothing else moves it there
|
||||
# (submit_up is cell-PM-only). The root→master PR now exists, so walk
|
||||
# the root through awaiting_pm_review here. Uses the TaskService
|
||||
@@ -4437,7 +4505,7 @@ class Choreographer:
|
||||
|
||||
# Use kwargs — service signature is (task_id, agent_role="cell_pm",
|
||||
# notes=None). Positional was passing agent_id as task_id and the
|
||||
# actual task_id as agent_role (audit D-07).
|
||||
# actual task_id as agent_role.
|
||||
t = await self.task.escalate_to_ceo(
|
||||
task_id=root_task_id, agent_role="main_pm", notes=notes
|
||||
)
|
||||
@@ -4470,7 +4538,7 @@ class Choreographer:
|
||||
spec doesn't model yet.
|
||||
"""
|
||||
t = await self.task.get(task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
if t is None:
|
||||
return await self._emit_rejection(
|
||||
Envelope.not_found(message=f"task {task_id} not found"),
|
||||
@@ -4531,7 +4599,7 @@ class Choreographer:
|
||||
``composes=()`` (no atomic action for the runner to run).
|
||||
"""
|
||||
t = await self.task.get(task_id)
|
||||
briefing = await self._briefing_for(pm_agent_id, task_id)
|
||||
briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
|
||||
if t is None:
|
||||
return await self._emit_rejection(
|
||||
Envelope.not_found(message=f"task {task_id} not found"),
|
||||
@@ -4642,6 +4710,45 @@ class Choreographer:
|
||||
|
||||
# --- Phase 4 (board) verbs ---
|
||||
|
||||
async def _escalate_did_not_apply(
|
||||
self,
|
||||
*,
|
||||
runner_error: str | None,
|
||||
task: Any,
|
||||
role_str: str,
|
||||
briefing: dict[str, Any],
|
||||
agent_id: UUID,
|
||||
task_id: UUID,
|
||||
) -> Envelope:
|
||||
"""Rejection for an escalate_to_ceo the runner did not apply.
|
||||
|
||||
``runner_error`` set → the run_intent savepoint raised; otherwise the
|
||||
service declined (task not in awaiting_pm_review). Either way a clean
|
||||
invalid_state, never the unhandled ``None.status`` 500.
|
||||
"""
|
||||
if runner_error is not None:
|
||||
message = f"verb runner failed: {runner_error}"
|
||||
remediate = "check workspace + retry; if persistent, escalate"
|
||||
else:
|
||||
message = (
|
||||
"escalate_to_ceo did not apply — the task is not in a state"
|
||||
" that escalates to the CEO (needs awaiting_pm_review)"
|
||||
)
|
||||
remediate = (
|
||||
"resolve or re-route the task; only awaiting_pm_review tasks"
|
||||
" escalate to the CEO"
|
||||
)
|
||||
return await self._emit_rejection(
|
||||
Envelope.invalid_state(
|
||||
message=message,
|
||||
remediate=remediate,
|
||||
context_briefing=briefing,
|
||||
).with_introspection(task=task, role=role_str),
|
||||
agent_id=agent_id,
|
||||
task_id=task_id,
|
||||
verb="escalate_to_ceo",
|
||||
)
|
||||
|
||||
async def escalate_to_ceo(
|
||||
self, agent_id: UUID, task_id: UUID, reason: str
|
||||
) -> Envelope:
|
||||
@@ -4660,7 +4767,7 @@ class Choreographer:
|
||||
main_pm_complete).
|
||||
"""
|
||||
t = await self.task.get(task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
if t is None:
|
||||
return await self._emit_rejection(
|
||||
Envelope.not_found(message=f"task {task_id} not found"),
|
||||
@@ -4714,19 +4821,25 @@ class Choreographer:
|
||||
)
|
||||
|
||||
runner = self._verb_runner()
|
||||
runner_error: str | None = None
|
||||
try:
|
||||
t = await runner.run_intent("escalate_to_ceo", t, me, spec_ctx)
|
||||
updated = await runner.run_intent("escalate_to_ceo", t, me, spec_ctx)
|
||||
except Exception as exc:
|
||||
return await self._emit_rejection(
|
||||
Envelope.invalid_state(
|
||||
message=f"verb runner failed: {exc}",
|
||||
remediate="check workspace + retry; if persistent, escalate",
|
||||
context_briefing=briefing,
|
||||
).with_introspection(task=t, role=role_str),
|
||||
updated, runner_error = None, str(exc)
|
||||
# run_intent returns None when the service declines the escalation (e.g. a
|
||||
# board agent escalating a task not in awaiting_pm_review). Without this
|
||||
# guard the OK path below dereferenced ``None.status`` — an unhandled 500
|
||||
# (the board's escalate-from-blocked crash loop).
|
||||
if updated is None:
|
||||
return await self._escalate_did_not_apply(
|
||||
runner_error=runner_error,
|
||||
task=t,
|
||||
role_str=role_str,
|
||||
briefing=briefing,
|
||||
agent_id=agent_id,
|
||||
task_id=task_id,
|
||||
verb="escalate_to_ceo",
|
||||
)
|
||||
t = updated
|
||||
# Same as main_pm_complete: CEO acts via UI, not as a spawnable agent.
|
||||
await self.task.reassign(task_id, None)
|
||||
return Envelope.ok(
|
||||
@@ -4737,6 +4850,6 @@ class Choreographer:
|
||||
).with_introspection(task=t, role=role_str)
|
||||
|
||||
# board_triage + auditor_triage moved to ``board.py`` as the first
|
||||
# per-role mixin extraction (audit P2-2). The Choreographer class is
|
||||
# per-role mixin extraction. The Choreographer class is
|
||||
# composed in ``__init__.py`` from BoardMixin + the rest of this
|
||||
# _impl. Methods now resolve via Python's MRO.
|
||||
|
||||
@@ -57,6 +57,8 @@ class ChoreographerHelpers:
|
||||
self,
|
||||
agent_id: UUID,
|
||||
task_id: UUID | None,
|
||||
*,
|
||||
task: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -86,5 +88,7 @@ class ChoreographerHelpers:
|
||||
agent_id: UUID,
|
||||
task_id: UUID,
|
||||
missing: list[str],
|
||||
*,
|
||||
task: Any | None = None,
|
||||
) -> Envelope:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Board + Auditor verbs (P2-2 first per-role split).
|
||||
"""Board + Auditor verbs (first per-role split).
|
||||
|
||||
Mixin extracted from ``_impl.py`` to prove the per-role pattern. Relies
|
||||
on ``self.task`` and ``self._briefing_for`` from the base class via
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Documenter verbs (audit P2-2 second per-role split).
|
||||
"""Documenter verbs (second per-role split).
|
||||
|
||||
Mixin for ``claim_doc_task`` and ``i_documented``. Inherits typed
|
||||
helpers via ``ChoreographerHelpers`` under ``TYPE_CHECKING``; runtime
|
||||
class is the composed ``Choreographer``.
|
||||
|
||||
Tasks 22 (lifecycle canonical spec): both verbs route their role/state
|
||||
gate through ``spec.can_invoke_intent``. The verb-specific helpers
|
||||
Both verbs route their role/state gate through
|
||||
``spec.can_invoke_intent``. The verb-specific helpers
|
||||
(``_verify_doc_owner``, ``_check_doc_gates``) STAY — they encode the
|
||||
notes-length / files-list / journal:reflect gates the spec doesn't
|
||||
model. The self-review block on ``docs_complete`` lives at the atomic-
|
||||
@@ -14,7 +14,7 @@ and naturally fires when the verb body builds a Context with
|
||||
``actor_slug == original_developer_slug``; no verb-body retrofits
|
||||
needed.
|
||||
|
||||
P2 Task 10: ``_check_doc_gates`` delegates the actual requirement
|
||||
``_check_doc_gates`` delegates the actual requirement
|
||||
checking to ``foundation.policy.tracing.check_requirements`` — the
|
||||
verb→required-set mapping lives in ``VERB_REQUIREMENTS`` (single
|
||||
source of truth). The hint translation lives in the shared
|
||||
@@ -60,7 +60,7 @@ def _doc_refs_for(files: list[str], agent_id: UUID) -> list[dict[str, Any]]:
|
||||
``i_documented`` receives a flat list of file paths, but
|
||||
``Task.documents`` is ``list[DocRef]`` persisted as dicts — readers do
|
||||
``DocRef(**d)`` / ``d["path"]`` and the doc indexer does ``d.get``.
|
||||
Stamping bare strings 500s ``list_docs`` and breaks indexing (#169).
|
||||
Stamping bare strings 500s ``list_docs`` and breaks indexing.
|
||||
"""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
slug = str(agent_id)
|
||||
@@ -192,7 +192,7 @@ class DocMixin(_Base):
|
||||
# "start") but doc_claim is the runtime-correct specialized form
|
||||
# that keeps status at AWAITING_DOCUMENTATION. See module docstring.
|
||||
t = await self.task.doc_claim(doc_agent_id, task_id)
|
||||
# Task #162: the documenter's clone is separate from the dev's;
|
||||
# The documenter's clone is separate from the dev's;
|
||||
# the task branch already exists (dev created it) so no checkout
|
||||
# ran in the doc's workspace. Put the doc on the task branch now
|
||||
# so roboco_docs_write / commit don't fail BRANCH_MISMATCH.
|
||||
@@ -214,7 +214,7 @@ class DocMixin(_Base):
|
||||
async def _claim_doc_evidence(self, task: Any, task_id: UUID) -> dict[str, Any]:
|
||||
"""Build the evidence dict surfaced inline on claim_doc_task ok envelopes.
|
||||
|
||||
Task #154: files_changed sourced from git (authoritative) instead
|
||||
files_changed sourced from git (authoritative) instead
|
||||
of ``work_session.files_modified``, which the gateway commit()
|
||||
does not populate. The docs writer sees an accurate file list.
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""QA verbs (audit P2-2 third per-role split).
|
||||
"""QA verbs (third per-role split).
|
||||
|
||||
Mixin for ``claim_review``, ``pass_review``, ``fail_review`` and the
|
||||
verb-specific helper ``_qa_pass_gate_check``. The helper stays with
|
||||
@@ -8,8 +8,8 @@ Inherits from ``ChoreographerHelpers`` under ``TYPE_CHECKING`` only so
|
||||
mypy resolves ``self.task`` etc. as typed; at runtime the composed
|
||||
``Choreographer`` supplies the real attributes via MRO.
|
||||
|
||||
Tasks 21 (lifecycle canonical spec): all three verbs now route their
|
||||
role/state gate through ``spec.can_invoke_intent``. The verb-specific
|
||||
All three verbs route their role/state gate through
|
||||
``spec.can_invoke_intent``. The verb-specific
|
||||
helpers (``_verify_qa_owner``, ``_qa_pass_gate_check``) STAY — they
|
||||
encode notes-length / journal:learning / qa_evidence_inspected gates
|
||||
the spec doesn't model. The self-review block lives at the atomic-
|
||||
@@ -18,7 +18,7 @@ action layer (``_ATOMIC_ACTIONS["qa_pass" | "qa_fail"]
|
||||
builds a Context with ``actor_slug == original_developer_slug``; no
|
||||
verb-body retrofits needed.
|
||||
|
||||
P2 Task 9: ``_qa_pass_gate_check`` delegates the actual requirement
|
||||
``_qa_pass_gate_check`` delegates the actual requirement
|
||||
checking to ``foundation.policy.tracing.check_requirements`` — the
|
||||
verb→required-set mapping lives in ``VERB_REQUIREMENTS`` (single
|
||||
source of truth). The hint translation lives in the shared
|
||||
@@ -185,7 +185,7 @@ class QAMixin(_Base):
|
||||
authoritative source) + journal_highlights so the QA agent has
|
||||
the full PR context up-front and can't miss a piece.
|
||||
|
||||
Task #154: files_changed comes from ``git.list_changed_files``
|
||||
files_changed comes from ``git.list_changed_files``
|
||||
instead of ``work_session.files_modified``. The legacy
|
||||
``add_files_modified`` HTTP path that populated files_modified
|
||||
is not called by the gateway ``commit()``, so the work_session
|
||||
|
||||
@@ -9,7 +9,7 @@ Scope: only system-level concurrency invariants the lifecycle spec does
|
||||
NOT model live here. Role/state/task_type checks (the former
|
||||
``role_typed_claim_guard`` and ``pm_cannot_execute_code_guard``) now route
|
||||
through ``spec.can_invoke_action``'s CLAIM_RULES + ``ActionSpec
|
||||
.allowed_task_types`` and have been deleted (Task 27, 2026-05-10).
|
||||
.allowed_task_types`` and have been deleted.
|
||||
|
||||
Pre-gateway location at commit 0c3d15a:
|
||||
roboco/mcp/tasks/handlers/_helpers.py:124-204
|
||||
|
||||
@@ -124,7 +124,7 @@ def _render_journal_content(scope: str, text: str, structured: dict[str, Any]) -
|
||||
return "\n\n".join(body_parts) if body_parts else text
|
||||
|
||||
|
||||
# Narrative fields that decision/reflect scopes want filled. Issue #15: a
|
||||
# Narrative fields that decision/reflect scopes want filled. A
|
||||
# missing or empty value used to hard-reject the note with `incomplete_input`
|
||||
# — and since that kind counts toward the do-server circuit breaker, three
|
||||
# well-intentioned-but-thin notes in a row tripped it. We now default the
|
||||
@@ -168,7 +168,7 @@ def _coerce_scalar_to_list(value: Any) -> Any:
|
||||
def _normalize_structured(scope: str, structured: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a tolerant copy of ``structured`` for decision/reflect scopes.
|
||||
|
||||
Issue #15: stop rejecting thin notes. List-typed fields tolerate a lone
|
||||
Stop rejecting thin notes. List-typed fields tolerate a lone
|
||||
scalar (wrapped into a one-element list), and missing/blank narrative
|
||||
fields are defaulted to a visible placeholder so the entry still records
|
||||
instead of returning `incomplete_input` (which trips the circuit breaker).
|
||||
@@ -237,7 +237,7 @@ class ContentActionsDeps:
|
||||
# `NotificationDeliveryService`, not `NotificationService`. Keeping
|
||||
# them separate so the sender vs receiver concerns stay split.
|
||||
notification_delivery: Any = None
|
||||
# Task #154: evidence() returns journal_highlights for QA/reviewer
|
||||
# evidence() returns journal_highlights for QA/reviewer
|
||||
# context. Matches the choreographer's EvidenceRepo wiring so both
|
||||
# paths surface the same shape.
|
||||
evidence_repo: Any = None
|
||||
@@ -382,7 +382,7 @@ class ContentActions:
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
# Board roles co-review board/coordination tasks (cluster C5): a
|
||||
# Board roles co-review board/coordination tasks: a
|
||||
# board/coordination task is dispatched to BOTH the Product Owner and the
|
||||
# Head of Marketing, but it carries a single ``assigned_to``. The
|
||||
# non-assignee reviewer must still be able to record its review note on
|
||||
@@ -429,7 +429,7 @@ class ContentActions:
|
||||
Allows ``assigned_to=None`` (post-handoff transient state) so QA /
|
||||
documenter can still inspect tasks between reassignments. A board role
|
||||
co-reviewing a board/coordination task is also allowed even when the
|
||||
task is assigned to the other board member (cluster C5).
|
||||
task is assigned to the other board member.
|
||||
"""
|
||||
t = await self.task.get(task_id)
|
||||
if t is None:
|
||||
@@ -456,7 +456,7 @@ class ContentActions:
|
||||
- decision: context, options[], chosen, rationale, consequences
|
||||
- reflect: what_done, what_learned, what_struggled, next_steps
|
||||
|
||||
Issue #15: the note is always recorded. List-typed fields tolerate a
|
||||
The note is always recorded. List-typed fields tolerate a
|
||||
lone scalar (wrapped into a one-element list) and missing decision/
|
||||
reflect narrative fields default to a visible placeholder, so a
|
||||
well-intentioned note is never hard-rejected (which previously tripped
|
||||
@@ -481,7 +481,7 @@ class ContentActions:
|
||||
t = await self.task.get_journal_context_task_for_agent(agent_id)
|
||||
if t is not None:
|
||||
task_id = t.id
|
||||
# Issue #15: tolerate thin notes instead of rejecting them. List-typed
|
||||
# Tolerate thin notes instead of rejecting them. List-typed
|
||||
# fields accept a lone scalar; missing decision/reflect narrative fields
|
||||
# are defaulted to a visible placeholder. The note is always recorded so
|
||||
# the audit trail survives and a well-intentioned note never trips the
|
||||
@@ -608,7 +608,7 @@ class ContentActions:
|
||||
# error escapes here it's caught by FastAPI's middleware and
|
||||
# rendered as RobocoError.to_dict() — a dict-shaped 'error'
|
||||
# field that breaks do_server's circuit-breaker frozenset
|
||||
# check (smoke-7: TypeError: unhashable type: 'dict').
|
||||
# check (a TypeError: unhashable type: 'dict').
|
||||
from roboco.enforcement.a2a_access import A2AAccessDeniedError
|
||||
|
||||
try:
|
||||
@@ -875,7 +875,7 @@ class ContentActions:
|
||||
) -> Envelope:
|
||||
"""Append a progress update; % is derived from the plan checklist.
|
||||
|
||||
#173: pass ``plan_step`` (a sub_task id or 1-based order) as you
|
||||
Pass ``plan_step`` (a sub_task id or 1-based order) as you
|
||||
finish each plan step — it is marked complete and the % is
|
||||
computed from completed/total (the agent cannot set it). A
|
||||
narrative entry without ``plan_step`` is allowed for important
|
||||
@@ -1184,7 +1184,7 @@ class ContentActions:
|
||||
) -> Envelope:
|
||||
"""Update an existing PR's title, body, and/or requested reviewers.
|
||||
|
||||
Smoke-5 surfaced this gap: agents who needed to edit a PR's
|
||||
Dogfooding surfaced this gap: agents who needed to edit a PR's
|
||||
title/body or assign a reviewer after ``open_pr`` had no verb
|
||||
for it and got bash-shimmed by the ``gh pr edit`` guard. This
|
||||
verb is the gateway-native replacement.
|
||||
@@ -1295,6 +1295,23 @@ class ContentActions:
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
async def read_messages(self, *, agent_id: UUID) -> Envelope:
|
||||
"""Mark all of the caller's unread A2A direct messages as read.
|
||||
|
||||
Clears the A2A side of ``i_am_idle``'s unread soft-block: zeroes the
|
||||
per-conversation unread counter and stamps ``read_at`` on the inbound
|
||||
messages. Notifications are separate (notify_list / notify_get /
|
||||
notify_ack).
|
||||
"""
|
||||
cleared = await self.a2a.mark_all_read(agent_id)
|
||||
return Envelope.ok(
|
||||
status="read",
|
||||
task_id=None,
|
||||
next="retry i_am_idle() — your A2A inbox is clear",
|
||||
evidence={"conversations_cleared": cleared},
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
|
||||
def _strip_task_prefix(msg: str) -> str:
|
||||
"""Strip any [task-id] prefix the agent supplied; gateway re-adds canonical."""
|
||||
|
||||
@@ -171,8 +171,7 @@ class Envelope:
|
||||
Distinct from `tracing_gap` and `incomplete_input`. The agent receives
|
||||
a structured "stop hammering this verb" signal with a remediate hint
|
||||
pointing to i_am_blocked() / i_am_idle() as graceful exits. Wired by
|
||||
the agent_sdk runtime tracker (Phase 3 Task 14) — the gateway itself
|
||||
does not raise this.
|
||||
the agent_sdk runtime tracker — the gateway itself does not raise this.
|
||||
"""
|
||||
return cls(
|
||||
error="circuit_open",
|
||||
|
||||
@@ -41,6 +41,11 @@ class BriefingInputs:
|
||||
task_metadata_gaps: list[str]
|
||||
recent_team_activity: list[dict[str, Any]]
|
||||
blockers_in_my_lane: list[dict[str, Any]]
|
||||
# Prior-work digest for the briefed task (None when there is no task in
|
||||
# scope or no prior work to resume from). Pushed so a freshly spawned or
|
||||
# respawned agent picks up where the previous worker left off instead of
|
||||
# re-exploring the codebase from cold.
|
||||
task_handoff: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def build_evidence_for_task(
|
||||
@@ -63,6 +68,60 @@ def build_evidence_for_task(
|
||||
)
|
||||
|
||||
|
||||
def _typed(value: Any, expected: type | tuple[type, ...], default: Any) -> Any:
|
||||
"""Return ``value`` only when it is the expected type, else ``default``.
|
||||
|
||||
Keeps every handoff field serialisable: a bare mock or unexpected attribute
|
||||
type degrades to a safe default rather than leaking a non-JSON object.
|
||||
"""
|
||||
return value if isinstance(value, expected) else default
|
||||
|
||||
|
||||
def build_task_handoff(
|
||||
task: Any, journal_highlights: list[dict[str, Any]]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Compose a compact prior-work digest for the briefed task.
|
||||
|
||||
Returns ``None`` when there is no task or no prior work worth resuming
|
||||
from, so the briefing only carries a handoff when one genuinely exists.
|
||||
DB-only by design — no git diff — so it is cheap enough to attach to
|
||||
every task-scoped briefing.
|
||||
"""
|
||||
if task is None:
|
||||
return None
|
||||
commits = _typed(task.commits, list, [])
|
||||
acceptance = _typed(task.acceptance_criteria_status, list, [])
|
||||
highlights = _typed(journal_highlights, list, [])
|
||||
pr_number = _typed(task.pr_number, int, None)
|
||||
dev_summary = _typed(task.dev_notes, str, None)
|
||||
# Upstream dependencies that completed and were cleared — present only on a
|
||||
# just-unblocked task, so the revived dependent knows what it can build on.
|
||||
completed_deps = _typed(getattr(task, "completed_dependency_ids", None), list, [])
|
||||
has_prior = bool(
|
||||
commits
|
||||
or acceptance
|
||||
or highlights
|
||||
or pr_number is not None
|
||||
or dev_summary
|
||||
or completed_deps
|
||||
)
|
||||
if not has_prior:
|
||||
return None
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"pr_url": _typed(task.pr_url, str, None),
|
||||
"branch_name": _typed(task.branch_name, str, None),
|
||||
"commit_count": len(commits),
|
||||
"recent_commits": commits[-BRIEFING_LIST_CAP:],
|
||||
"dev_summary": dev_summary,
|
||||
"acceptance_criteria_status": acceptance[:BRIEFING_LIST_CAP],
|
||||
"journal_highlights": highlights[:BRIEFING_LIST_CAP],
|
||||
"completed_dependency_ids": [
|
||||
str(d) for d in completed_deps[:BRIEFING_LIST_CAP]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_context_briefing(inputs: BriefingInputs) -> dict[str, Any]:
|
||||
"""Compose the context_briefing dict; caps each list at BRIEFING_LIST_CAP items."""
|
||||
return {
|
||||
@@ -72,4 +131,5 @@ def build_context_briefing(inputs: BriefingInputs) -> dict[str, Any]:
|
||||
"task_metadata_gaps": list(inputs.task_metadata_gaps),
|
||||
"recent_team_activity": inputs.recent_team_activity[:BRIEFING_LIST_CAP],
|
||||
"blockers_in_my_lane": inputs.blockers_in_my_lane[:BRIEFING_LIST_CAP],
|
||||
"task_handoff": inputs.task_handoff,
|
||||
}
|
||||
|
||||
@@ -68,35 +68,42 @@ class EvidenceRepo:
|
||||
return items
|
||||
|
||||
async def list_unread_mentions(self, agent_id: UUID) -> list[dict[str, Any]]:
|
||||
"""Recent channel messages that @mention this agent.
|
||||
"""Unacknowledged @mention notifications for this agent.
|
||||
|
||||
Messages carry no per-recipient read state, so this surfaces the most
|
||||
recent mentions (capped); the briefing is rebuilt each turn.
|
||||
Each channel @mention raises a MENTION-type notification (see
|
||||
``messaging._notify_mentions``); surface the ones this agent has not yet
|
||||
acked. The agent clears them with ``notify_ack`` — so ``i_am_idle``'s
|
||||
mention soft-block is satisfiable rather than a permanent dead-end.
|
||||
(Channel messages carry no per-recipient read state of their own, so the
|
||||
notification's ``acked_by`` is the read signal.)
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.db.tables import MessageTable
|
||||
from roboco.db.tables import NotificationTable
|
||||
from roboco.models import NotificationType
|
||||
|
||||
result = await self._db.execute(
|
||||
select(
|
||||
MessageTable.id,
|
||||
MessageTable.agent_id,
|
||||
MessageTable.channel_id,
|
||||
MessageTable.content,
|
||||
MessageTable.task_id,
|
||||
MessageTable.timestamp,
|
||||
NotificationTable.id,
|
||||
NotificationTable.from_agent,
|
||||
NotificationTable.subject,
|
||||
NotificationTable.body,
|
||||
NotificationTable.related_task_id,
|
||||
NotificationTable.timestamp,
|
||||
)
|
||||
.where(MessageTable.mentions.contains([agent_id]))
|
||||
.order_by(MessageTable.timestamp.desc())
|
||||
.where(NotificationTable.type == NotificationType.MENTION)
|
||||
.where(NotificationTable.to_agents.contains([agent_id]))
|
||||
.where(~NotificationTable.acked_by.contains([agent_id]))
|
||||
.order_by(NotificationTable.timestamp.desc())
|
||||
.limit(10)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"message_id": str(row.id),
|
||||
"from_agent": str(row.agent_id),
|
||||
"channel_id": str(row.channel_id) if row.channel_id else None,
|
||||
"excerpt": (row.content or "")[:280],
|
||||
"task_id": str(row.task_id) if row.task_id else None,
|
||||
"notification_id": str(row.id),
|
||||
"from_agent": str(row.from_agent) if row.from_agent else None,
|
||||
"subject": row.subject,
|
||||
"excerpt": (row.body or "")[:280],
|
||||
"task_id": (str(row.related_task_id) if row.related_task_id else None),
|
||||
"timestamp": row.timestamp.isoformat() if row.timestamp else None,
|
||||
}
|
||||
for row in result.all()
|
||||
|
||||
@@ -66,13 +66,13 @@ async def resolve_parent_branch(task: Any, task_service: Any) -> str:
|
||||
The parent task's stored ``branch_name`` is authoritative — branch
|
||||
creation already cuts and pushes each child from it
|
||||
(``TaskService._resolve_parent_branch``). Unlike :func:`parent_branch_for`
|
||||
it is correct across a team boundary (#181).
|
||||
it is correct across a team boundary.
|
||||
|
||||
When the parent is a branchless coordination/fan-out task (it carries a
|
||||
product but no repo of its own, so it never gets a branch), string
|
||||
derivation off the child's own branch would yield a ref the parent never
|
||||
created — the merge then has no valid target and the cell↔Main-PM loop
|
||||
wedges (#17). In that case fall back to the child task's own project
|
||||
wedges. In that case fall back to the child task's own project
|
||||
default branch (e.g. master), which is what the child branch was actually
|
||||
cut from. Only when there is genuinely no project to consult do we fall
|
||||
back to pure string derivation.
|
||||
|
||||
@@ -31,9 +31,10 @@ class RoleConfig:
|
||||
description: str
|
||||
|
||||
|
||||
# Wave 1 receivers — every role with inbox access gets notify_list/get/ack
|
||||
# so `i_am_idle()` doesn't soft-block forever on unread notifications.
|
||||
_NOTIFY_RECEIVER = ("notify_list", "notify_get", "notify_ack")
|
||||
# Wave 1 receivers — every role with inbox access gets notify_list/get/ack for
|
||||
# notifications and read_messages for A2A, so `i_am_idle()`'s unread soft-block
|
||||
# is satisfiable rather than a permanent dead-end.
|
||||
_NOTIFY_RECEIVER = ("notify_list", "notify_get", "notify_ack", "read_messages")
|
||||
# Wave 2 — channel discovery. Every role gets `channels()` so the LLM stops
|
||||
# inventing slugs ("backend-dev", "backend") that don't exist.
|
||||
_CHANNEL_DISCOVERY = ("channels",)
|
||||
|
||||
+77
-30
@@ -1053,9 +1053,34 @@ class GitService(BaseService):
|
||||
if force:
|
||||
args.insert(1, "--force")
|
||||
|
||||
await self._run_git(
|
||||
workspace, args, token=token, timeout=_network_git_timeout()
|
||||
)
|
||||
try:
|
||||
await self._run_git(
|
||||
workspace, args, token=token, timeout=_network_git_timeout()
|
||||
)
|
||||
except GitCommandError as e:
|
||||
# A >100MB file trips GitHub's GH001 pre-receive hook — a PERMANENT
|
||||
# rejection that retrying can never fix. Restate it unmistakably so
|
||||
# the agent stops blind-retrying (it otherwise mis-reads the raw
|
||||
# output as a transient timeout) and removes the file / blocks.
|
||||
blob = f"{e}".lower()
|
||||
if any(
|
||||
m in blob
|
||||
for m in (
|
||||
"gh001",
|
||||
"exceeds github's file size",
|
||||
"100.00 mb",
|
||||
"pre-receive hook declined",
|
||||
)
|
||||
):
|
||||
raise GitCommandError(
|
||||
"push",
|
||||
"rejected — a committed file exceeds GitHub's 100 MB limit"
|
||||
" (GH001). Retrying will NOT help: remove the oversized file"
|
||||
" (usually a build/dependency artifact like a node or pnpm"
|
||||
" store) from the commit and re-commit, or call i_am_blocked"
|
||||
" if you cannot.",
|
||||
) from e
|
||||
raise
|
||||
|
||||
return branch, commits_to_push
|
||||
|
||||
@@ -1536,10 +1561,9 @@ class GitService(BaseService):
|
||||
{"task_id": str(task_id)},
|
||||
)
|
||||
|
||||
project_service = get_project_service(self.session)
|
||||
project = await project_service.get(UUID(str(task.project_id)))
|
||||
project = await self._project_for_task(task)
|
||||
if project is None:
|
||||
raise NotFoundError("Project", str(task.project_id))
|
||||
raise NotFoundError("Project for task", str(task.id))
|
||||
|
||||
workspace_agent_id = self._resolve_workspace_agent_id(task, None)
|
||||
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
|
||||
@@ -1968,7 +1992,7 @@ class GitService(BaseService):
|
||||
def _resolve_workspace_agent_id(
|
||||
task: Any, actor_agent_id: UUID | None
|
||||
) -> UUID | None:
|
||||
"""Workspace-agent resolution priority (audit D-40).
|
||||
"""Workspace-agent resolution priority.
|
||||
|
||||
actor_agent_id → task.assigned_to → task.created_by → None.
|
||||
Centralised so push_branch/create_pr/commit/diff/pr_target/pr_merge
|
||||
@@ -2008,7 +2032,7 @@ class GitService(BaseService):
|
||||
async def _token_for_branch(self, branch_name: str) -> str | None:
|
||||
"""Best-effort project PAT for authenticated fetch in the diff path.
|
||||
|
||||
Task #168: an unauthenticated ``git fetch`` fails on a private
|
||||
An unauthenticated ``git fetch`` fails on a private
|
||||
repo ("could not read Username for github.com"), so the diff base
|
||||
stays the stale clone-time ``origin/<default>`` and the three-dot
|
||||
diff spans the whole repo delta instead of the branch's change.
|
||||
@@ -2021,8 +2045,7 @@ class GitService(BaseService):
|
||||
task = await self._task_for_branch(branch_name)
|
||||
if task is None:
|
||||
return None
|
||||
project_service = get_project_service(self.session)
|
||||
project = await project_service.get(UUID(str(task.project_id)))
|
||||
project = await self._project_for_task(task)
|
||||
if project is None:
|
||||
return None
|
||||
return await self._get_project_token_or_raise(project.slug)
|
||||
@@ -2037,7 +2060,7 @@ class GitService(BaseService):
|
||||
) -> None:
|
||||
"""Check out `branch_name` into the actor's own clone.
|
||||
|
||||
Task #162: dev/PM workspaces land on the right branch because
|
||||
Dev/PM workspaces land on the right branch because
|
||||
``_auto_create_branch`` runs ``git checkout -b`` in the dev's
|
||||
clone at claim time. The documenter's clone is a *separate*
|
||||
workspace; when it claims an awaiting_documentation task the
|
||||
@@ -2064,7 +2087,7 @@ class GitService(BaseService):
|
||||
Gateway-only entry point — the legacy `push(workspace, force)`
|
||||
signature stays intact for non-gateway callers. Resolves the
|
||||
workspace from the task that owns the branch (with caller-actor
|
||||
fallback per audit D-40), then delegates. Returns
|
||||
fallback), then delegates. Returns
|
||||
(branch, commits_pushed).
|
||||
"""
|
||||
workspace = await self._workspace_for_branch(
|
||||
@@ -2089,7 +2112,7 @@ class GitService(BaseService):
|
||||
|
||||
``actor_agent_id`` lets PMs opening the master PR (where
|
||||
``task.assigned_to`` may be None at completion time) resolve a
|
||||
workspace via the actor's clone (audit D-40).
|
||||
workspace via the actor's clone.
|
||||
"""
|
||||
task = await self._task_for_branch(branch_name)
|
||||
if task is None:
|
||||
@@ -2176,7 +2199,7 @@ class GitService(BaseService):
|
||||
|
||||
@staticmethod
|
||||
def _resolve_merger_id(task: Any, actor_agent_id: UUID | None) -> UUID:
|
||||
"""merged_by attribution priority for pr_merge (audit D-43).
|
||||
"""merged_by attribution priority for pr_merge.
|
||||
|
||||
actor → assigned_to → created_by → UUID(int=0) sentinel.
|
||||
``UUID(0)`` is the explicit "nothing was recoverable" marker
|
||||
@@ -2248,10 +2271,9 @@ class GitService(BaseService):
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise NotFoundError("PR", str(pr_number))
|
||||
project_service = get_project_service(self.session)
|
||||
project = await project_service.get(UUID(str(task.project_id)))
|
||||
project = await self._project_for_task(task)
|
||||
if project is None:
|
||||
raise NotFoundError("Project", str(task.project_id))
|
||||
raise NotFoundError("Project for task", str(task.id))
|
||||
|
||||
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
|
||||
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
|
||||
@@ -2308,7 +2330,7 @@ class GitService(BaseService):
|
||||
"""Return the current target (base) branch of an open PR.
|
||||
|
||||
Workspace resolution mirrors pr_merge: actor → assigned_to →
|
||||
created_by (audit D-40). Lets the Main PM call pr_target after
|
||||
created_by. Lets the Main PM call pr_target after
|
||||
``submit_qa`` has cleared ``assigned_to`` without ValidationError.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
@@ -2321,10 +2343,9 @@ class GitService(BaseService):
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise NotFoundError("PR", str(pr_number))
|
||||
project_service = get_project_service(self.session)
|
||||
project = await project_service.get(UUID(str(task.project_id)))
|
||||
project = await self._project_for_task(task)
|
||||
if project is None:
|
||||
raise NotFoundError("Project", str(task.project_id))
|
||||
raise NotFoundError("Project for task", str(task.id))
|
||||
|
||||
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
|
||||
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
|
||||
@@ -2377,7 +2398,7 @@ class GitService(BaseService):
|
||||
``origin/master`` so the diff command is still well-formed even
|
||||
on a misconfigured remote (an empty/garbage diff is recoverable;
|
||||
a malformed git invocation is not). This only resolves the ref
|
||||
NAME — the caller is responsible for fetching it fresh (#168).
|
||||
NAME — the caller is responsible for fetching it fresh.
|
||||
"""
|
||||
head = await self._run_git(
|
||||
workspace,
|
||||
@@ -2404,14 +2425,14 @@ class GitService(BaseService):
|
||||
) -> str:
|
||||
"""Best diff base for `branch_name` when no explicit base is given.
|
||||
|
||||
Task #161: a leaf dev branch's ``parent_branch_for`` is the
|
||||
A leaf dev branch's ``parent_branch_for`` is the
|
||||
cell-PM branch (``feature/{team}/{root}--{cellpm}``) which is
|
||||
NEVER pushed — only devs push their own leaf branch. Diffing
|
||||
against a non-existent ``origin/<parent>`` returns an empty
|
||||
diff, so QA / docs see nothing. Fall back to the repo default
|
||||
branch when the computed parent ref is absent on origin.
|
||||
|
||||
Task #168: the default-branch ref in an inspecting clone is the
|
||||
The default-branch ref in an inspecting clone is the
|
||||
stale clone-time tip (origin/HEAD is set, so _default_branch_ref
|
||||
early-returns its NAME without fetching). A three-dot diff against
|
||||
a stale base spans the whole repo delta, not the branch's change.
|
||||
@@ -2438,7 +2459,7 @@ class GitService(BaseService):
|
||||
) -> str:
|
||||
"""Ref for the branch tip that actually resolves in `workspace`.
|
||||
|
||||
Task #161 (facet): the local ``<branch_name>`` ref only exists in
|
||||
The local ``<branch_name>`` ref only exists in
|
||||
the clone where the dev ran ``git checkout -b`` at claim. QA /
|
||||
documenter / PM inspect from their OWN clones, which never had
|
||||
that local branch — a bare ``<branch_name>`` resolves
|
||||
@@ -2471,11 +2492,11 @@ class GitService(BaseService):
|
||||
|
||||
When `base` is omitted, diffs against the branch's parent (per
|
||||
`parent_branch_for`), falling back to the repo default branch
|
||||
when that parent was never pushed (Task #161). Content_actions
|
||||
when that parent was never pushed. Content_actions
|
||||
evidence path can pass `HEAD~1` for an incremental diff.
|
||||
|
||||
``actor_agent_id`` resolves the workspace via the caller's clone
|
||||
when ``task.assigned_to`` is None (audit D-40) — important for
|
||||
when ``task.assigned_to`` is None — important for
|
||||
QA reviewing post-submit_qa.
|
||||
"""
|
||||
workspace = await self._workspace_for_branch(
|
||||
@@ -2507,7 +2528,7 @@ class GitService(BaseService):
|
||||
authoritative git state — independent of whether the agent
|
||||
ever called the legacy ``add_files_modified`` HTTP endpoint
|
||||
(which the gateway commit() does not call). Empty paths are
|
||||
skipped; output preserves git's order. Same Task #161 default-
|
||||
skipped; output preserves git's order. Same default-
|
||||
branch fallback as ``diff``.
|
||||
"""
|
||||
workspace = await self._workspace_for_branch(
|
||||
@@ -2527,6 +2548,33 @@ class GitService(BaseService):
|
||||
)
|
||||
return [line for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
async def read_file_at_branch(
|
||||
self,
|
||||
*,
|
||||
branch_name: str,
|
||||
path: str,
|
||||
actor_agent_id: UUID | None = None,
|
||||
) -> str | None:
|
||||
"""Return the content of ``path`` as committed on ``branch_name``.
|
||||
|
||||
Reads the file straight out of the branch tip with ``git show`` in the
|
||||
resolved workspace, so the orchestrator can capture a doc an agent
|
||||
authored (and committed) in its OWN clone — without any shared
|
||||
filesystem mount. A missing file, an uncommitted file, or a bad ref
|
||||
yields ``None`` rather than raising; callers treat this as best-effort.
|
||||
"""
|
||||
workspace = await self._workspace_for_branch(
|
||||
branch_name, actor_agent_id=actor_agent_id
|
||||
)
|
||||
token = await self._token_for_branch(branch_name)
|
||||
head_ref = await self._resolve_head_ref(workspace, branch_name, token=token)
|
||||
result = await self._run_git(
|
||||
workspace, ["show", f"{head_ref}:{path}"], check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return result.stdout
|
||||
|
||||
async def commit(
|
||||
self,
|
||||
*,
|
||||
@@ -2546,8 +2594,7 @@ class GitService(BaseService):
|
||||
applies to the structured `commit_for_task` API path.
|
||||
|
||||
``actor_agent_id`` falls back through the same chain as pr_merge
|
||||
when ``task.assigned_to`` was cleared by an earlier transition
|
||||
(audit D-40).
|
||||
when ``task.assigned_to`` was cleared by an earlier transition.
|
||||
|
||||
Returns a dict shaped for the gateway: ``{"sha": str, "message": str,
|
||||
"files_changed": int, "insertions": int, "deletions": int}``. Tests
|
||||
|
||||
@@ -777,9 +777,9 @@ class JournalService(BaseService):
|
||||
(agent, task), or ``None`` if no decision exists.
|
||||
|
||||
Backs the windowed-satisfaction variant of the PM-decision
|
||||
tracing gate (Wave C8): the choreographer treats decisions older
|
||||
than ``settings.pm_decision_window_seconds`` as missing so PMs
|
||||
write a fresh decision around each decision point.
|
||||
tracing gate: the choreographer treats decisions older than
|
||||
``settings.pm_decision_window_seconds`` as missing so PMs write a
|
||||
fresh decision around each decision point.
|
||||
"""
|
||||
query = (
|
||||
select(func.max(JournalEntryTable.created_at))
|
||||
|
||||
@@ -968,11 +968,11 @@ class MessagingService(BaseService):
|
||||
) -> list[SessionTaskTable]:
|
||||
"""Link every session attached to ``parent_task_id`` onto ``subtask_id``.
|
||||
|
||||
Task #156: pre-gateway flow created a session with the whole task
|
||||
tree at once, so subtasks were visible in the parent's group chat
|
||||
the moment they existed. The gateway creates subtasks one at a
|
||||
time via ``delegate()``, so this step re-attaches every existing
|
||||
parent session link to the new child.
|
||||
The pre-gateway flow created a session with the whole task tree at
|
||||
once, so subtasks were visible in the parent's group chat the moment
|
||||
they existed. The gateway creates subtasks one at a time via
|
||||
``delegate()``, so this step re-attaches every existing parent
|
||||
session link to the new child.
|
||||
|
||||
``link_session_to_task`` is idempotent on duplicate (session, task)
|
||||
pairs, so re-runs are no-ops. Primary status is NOT propagated —
|
||||
|
||||
@@ -330,8 +330,8 @@ class NotificationService:
|
||||
task_id: Related task ID
|
||||
a2a_context: Dict with from_agent, to_agent, skill, message,
|
||||
priority. `priority` is a `NotificationPriority` (full
|
||||
tristate: NORMAL / HIGH / URGENT). Before P3 Task 9 this
|
||||
key was `urgent: bool` which collapsed HIGH to NORMAL —
|
||||
tristate: NORMAL / HIGH / URGENT). This key used to be
|
||||
`urgent: bool`, which collapsed HIGH to NORMAL —
|
||||
A2AService now sends Priority directly.
|
||||
"""
|
||||
from_agent = a2a_context.get("from_agent", "unknown")
|
||||
@@ -340,8 +340,8 @@ class NotificationService:
|
||||
message = a2a_context.get("message", "")
|
||||
priority = a2a_context.get("priority", NotificationPriority.NORMAL)
|
||||
# Defensive coerce — accept enum, str, or a stray bool from a
|
||||
# legacy caller. The point of Task 9 is that HIGH survives, so
|
||||
# only collapse to URGENT/NORMAL if the input is genuinely a bool.
|
||||
# legacy caller. The point is that HIGH survives, so only collapse
|
||||
# to URGENT/NORMAL if the input is genuinely a bool.
|
||||
if isinstance(priority, bool):
|
||||
priority = (
|
||||
NotificationPriority.URGENT if priority else NotificationPriority.NORMAL
|
||||
@@ -441,6 +441,38 @@ class NotificationService:
|
||||
subject=params.subject[:80],
|
||||
)
|
||||
return
|
||||
# Purpose-based dedup (CEO directive, 2026-06-10): do NOT create a
|
||||
# second notification for the SAME purpose — same sender, same type,
|
||||
# same task, overlapping recipients — while a prior one is still
|
||||
# unacknowledged. Agents loop and re-send the same signal (often
|
||||
# reworded); each copy inflates the recipient's unacked set, which
|
||||
# soft-blocks their i_am_idle and drives respawn churn. A different
|
||||
# type, a different task, a different sender, or a recipient who has
|
||||
# already acked all go through. Body text is NOT compared, so
|
||||
# rewording cannot defeat the guard.
|
||||
related = params.related_task_id
|
||||
dup_q = (
|
||||
select(NotificationTable.id)
|
||||
.where(NotificationTable.from_agent == from_agent_uuid)
|
||||
.where(NotificationTable.type == params.notification_type)
|
||||
.where(NotificationTable.to_agents.overlap(to_agents_uuids))
|
||||
.where(~NotificationTable.acked_by.contains(to_agents_uuids))
|
||||
.where(
|
||||
NotificationTable.related_task_id == related
|
||||
if related is not None
|
||||
else NotificationTable.related_task_id.is_(None)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if await db.scalar(dup_q) is not None:
|
||||
logger.info(
|
||||
"Suppressed duplicate notification (same purpose, unacked)",
|
||||
from_agent=str(from_agent_uuid),
|
||||
type=params.notification_type.value,
|
||||
related_task_id=str(related) if related is not None else None,
|
||||
to_agents=[str(a) for a in to_agents_uuids],
|
||||
)
|
||||
return
|
||||
notification = NotificationTable(
|
||||
type=params.notification_type,
|
||||
priority=params.priority,
|
||||
|
||||
@@ -78,6 +78,16 @@ class PrompterLiveRegistry:
|
||||
def get(self, session_id: str) -> LiveIntakeSession | None:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def is_alive(self, session_id: str) -> bool:
|
||||
"""True when a live, un-closed session exists for this id.
|
||||
|
||||
The panel calls this after a page reload to decide whether it can
|
||||
reconnect to a still-running intake agent rather than dropping the
|
||||
chat back to the scope form.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
return session is not None and not session.closed
|
||||
|
||||
def close(self, session_id: str) -> None:
|
||||
"""End a live session and unblock its stream (called on reap)."""
|
||||
session = self._sessions.pop(session_id, None)
|
||||
|
||||
+102
-24
@@ -141,7 +141,7 @@ _TRUNCATION_MARKER = "[...earlier notes truncated for size...]\n"
|
||||
|
||||
|
||||
def _mark_subtask_complete(sub_tasks: list[dict[str, Any]], plan_step: str) -> bool:
|
||||
"""Mark the matching sub_task ``completed`` in place (#173).
|
||||
"""Mark the matching sub_task ``completed`` in place.
|
||||
|
||||
``plan_step`` matches a sub_task by its id, its ``order``, or its
|
||||
1-based position. Returns True iff a sub_task matched (mutated in
|
||||
@@ -173,7 +173,7 @@ def _derive_plan_pct(
|
||||
sub_tasks: list[dict[str, Any]], fallback: int | None
|
||||
) -> int | None:
|
||||
"""% = completed/total of the checklist (equal weight); ``fallback``
|
||||
only when there is no checklist (#173)."""
|
||||
only when there is no checklist."""
|
||||
if not sub_tasks:
|
||||
return fallback
|
||||
done = sum(1 for st in sub_tasks if st.get("completed"))
|
||||
@@ -801,7 +801,7 @@ class TaskService(BaseService):
|
||||
a child task's parent is branchless (a coordination/fan-out parent that
|
||||
owns no repo): the real merge target is the child's own project default
|
||||
branch — the branch the child was actually cut from — not a derived ref
|
||||
the parent never created (#17). Returns None for a task with no
|
||||
the parent never created. Returns None for a task with no
|
||||
project_id (e.g. a coordination task itself).
|
||||
"""
|
||||
project_id = getattr(task, "project_id", None)
|
||||
@@ -1220,7 +1220,7 @@ class TaskService(BaseService):
|
||||
On branch-creation failure, the claim fields are rolled back to
|
||||
their pre-claim values so a retry starts from a clean state. Without
|
||||
this, a partial failure leaves the task CLAIMED with branch_name=NULL,
|
||||
and `git checkout -b` on retry fails non-idempotent (audit S-01/D-39).
|
||||
and `git checkout -b` on retry fails non-idempotent.
|
||||
"""
|
||||
# Set context for QA/Documenter claims (only if not already set)
|
||||
self._set_original_developer_context(task, agent)
|
||||
@@ -1246,7 +1246,7 @@ class TaskService(BaseService):
|
||||
task.last_heartbeat_at = now
|
||||
# Single-claimant invariant (alembic 006): claimant_lock.try_acquire
|
||||
# and trigger_filter.decide_spawn both branch on this column. Was
|
||||
# declared but never written (audit D-05); now wired so the
|
||||
# declared but never written; now wired so the
|
||||
# invariant is functional.
|
||||
task.active_claimant_id = cast("Any", agent_id)
|
||||
|
||||
@@ -1326,7 +1326,7 @@ class TaskService(BaseService):
|
||||
outer claim() transaction could roll back (branch-creation
|
||||
failure, FOR UPDATE conflict, etc.) but this fire-and-forget
|
||||
survived and wrote stale context onto a task whose claim was
|
||||
reverted (audit D-44).
|
||||
reverted.
|
||||
|
||||
Now performs a confirm-after-commit check at the top: re-reads
|
||||
the task in a fresh session and skips if (a) task is gone, or
|
||||
@@ -1818,12 +1818,20 @@ class TaskService(BaseService):
|
||||
return str(DOCS_BASE_PATH / path)
|
||||
|
||||
async def _index_docs_background(
|
||||
self, task_id: UUID, documents: list[dict[str, Any]]
|
||||
self,
|
||||
task_id: UUID,
|
||||
documents: list[dict[str, Any]],
|
||||
actor_agent_id: UUID | None = None,
|
||||
) -> None:
|
||||
"""Index documentation from completed doc task (fire-and-forget)."""
|
||||
from roboco.services.optimal import get_optimal_service
|
||||
|
||||
try:
|
||||
# Land any workspace-authored docs server-side first, so the
|
||||
# indexer (which reads /app/docs) can see docs the agent wrote with
|
||||
# Edit/Write in its own clone rather than through roboco_docs_write.
|
||||
await self._capture_workspace_docs(task_id, documents, actor_agent_id)
|
||||
|
||||
optimal = await get_optimal_service()
|
||||
|
||||
# Extract doc paths from documents array and resolve to absolute paths
|
||||
@@ -1847,6 +1855,57 @@ class TaskService(BaseService):
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def _capture_workspace_docs(
|
||||
self,
|
||||
task_id: UUID,
|
||||
documents: list[dict[str, Any]],
|
||||
actor_agent_id: UUID | None,
|
||||
) -> None:
|
||||
"""Copy workspace-authored docs into ``/app/docs`` so they index.
|
||||
|
||||
Docs written through ``roboco_docs_write`` already live under
|
||||
``DOCS_BASE_PATH`` on the orchestrator. Docs an agent wrote with
|
||||
Edit/Write live only in that agent's own clone, so resolving their path
|
||||
under ``/app/docs`` finds nothing and they never reach RAG (the
|
||||
cross-container miss). Read each missing doc's committed content out of
|
||||
the branch and write it server-side. Best-effort: one unreadable file
|
||||
must not abort the rest of the batch.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from roboco.services.git import get_git_service
|
||||
|
||||
task = await self.get(task_id)
|
||||
if task is None or not task.branch_name:
|
||||
return
|
||||
git = get_git_service(self.session)
|
||||
for d in documents:
|
||||
rel_path = d.get("path")
|
||||
# git show needs a repo-relative path; an absolute path can't be
|
||||
# mapped back to the repo, and the indexer already skips it.
|
||||
if not rel_path or Path(rel_path).is_absolute():
|
||||
continue
|
||||
abspath = Path(self._resolve_doc_abspath(rel_path))
|
||||
if abspath.exists():
|
||||
continue
|
||||
try:
|
||||
content = await git.read_file_at_branch(
|
||||
branch_name=task.branch_name,
|
||||
path=rel_path,
|
||||
actor_agent_id=actor_agent_id,
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.debug(
|
||||
"Workspace doc capture read failed",
|
||||
path=rel_path,
|
||||
error=str(e),
|
||||
)
|
||||
continue
|
||||
if not content:
|
||||
continue
|
||||
abspath.parent.mkdir(parents=True, exist_ok=True)
|
||||
abspath.write_text(content, encoding="utf-8")
|
||||
|
||||
# =========================================================================
|
||||
# QA AND ERROR INDEXING HOOKS
|
||||
# =========================================================================
|
||||
@@ -2172,7 +2231,7 @@ class TaskService(BaseService):
|
||||
async def unclaim_for_reaper(self, task_id: UUID) -> None:
|
||||
"""Reaper-only unclaim: skip role checks, force the row back to pending.
|
||||
|
||||
Routes through ``_validate_and_set_status`` (audit P2-4/D-20) so the
|
||||
Routes through ``_validate_and_set_status`` so the
|
||||
canonical state machine in ``enforcement/task_lifecycle.py`` records
|
||||
the transition. Pre-fix this used raw UPDATE which bypassed
|
||||
VALID_TRANSITIONS — making the lifecycle module's invariants diverge
|
||||
@@ -2180,7 +2239,7 @@ class TaskService(BaseService):
|
||||
|
||||
Also abandons the active WorkSession so a re-claim by the same
|
||||
agent doesn't trip the uniqueness constraint at
|
||||
``WorkSessionService.create`` (audit D-41). Best-effort: if the
|
||||
``WorkSessionService.create``. Best-effort: if the
|
||||
WorkSession lookup fails for any reason, the task is still
|
||||
rolled back to pending.
|
||||
|
||||
@@ -2255,7 +2314,7 @@ class TaskService(BaseService):
|
||||
) -> None:
|
||||
"""Mark a WorkSession ABANDONED. Logs and continues on any failure.
|
||||
|
||||
Audit D-41 fix — unclaim must not leave ACTIVE WorkSessions
|
||||
Unclaim must not leave ACTIVE WorkSessions
|
||||
behind, but a service-layer error here mustn't block the task-
|
||||
side unclaim from completing.
|
||||
"""
|
||||
@@ -2969,10 +3028,16 @@ class TaskService(BaseService):
|
||||
|
||||
await self.session.flush()
|
||||
|
||||
# Index documentation artifacts (fire-and-forget)
|
||||
# Index documentation artifacts (fire-and-forget). Capture the current
|
||||
# owner (the documenter) now — i_documented reassigns to the PM right
|
||||
# after this returns, so reading assigned_to inside the background task
|
||||
# would resolve the wrong workspace.
|
||||
if task.documents:
|
||||
documenter_id = to_python_uuid(task.assigned_to)
|
||||
bg_task = asyncio.create_task(
|
||||
self._index_docs_background(require_uuid(task.id), task.documents)
|
||||
self._index_docs_background(
|
||||
require_uuid(task.id), task.documents, documenter_id
|
||||
)
|
||||
)
|
||||
self._background_tasks.add(bg_task)
|
||||
bg_task.add_done_callback(self._background_tasks.discard)
|
||||
@@ -3384,7 +3449,7 @@ class TaskService(BaseService):
|
||||
) -> TaskTable | None:
|
||||
"""Run the PM-completion approval chain; return escalated task or None.
|
||||
|
||||
#178: the cell_pm branch was removed (it reassigned every
|
||||
The cell_pm branch was removed (it reassigned every
|
||||
``awaiting_pm_review`` task from the cell PM to the main PM and
|
||||
kept it in ``awaiting_pm_review`` — a legacy two-step "cell PM
|
||||
approves, main PM approves" review chain). The gateway model
|
||||
@@ -3456,7 +3521,7 @@ class TaskService(BaseService):
|
||||
"""
|
||||
Mark task as completed (PM only).
|
||||
|
||||
Approval model (post-#178 — matches the gateway invariant
|
||||
Approval model (matches the gateway invariant
|
||||
``main_pm_complete`` rejects any non-root task):
|
||||
|
||||
- Cell PM completes a non-root task → COMPLETED. The cell→main
|
||||
@@ -4152,10 +4217,17 @@ class TaskService(BaseService):
|
||||
blocked_tasks = result.scalars().all()
|
||||
|
||||
for task in blocked_tasks:
|
||||
# Remove the completed task from dependencies
|
||||
# Remove the completed task from dependencies, but remember it so the
|
||||
# unblock briefing can tell the revived dependent which upstream task
|
||||
# just landed (otherwise the only record of it is destroyed here).
|
||||
task.dependency_ids = [
|
||||
dep_id for dep_id in task.dependency_ids if dep_id != completed_task_id
|
||||
]
|
||||
if completed_task_id not in task.completed_dependency_ids:
|
||||
task.completed_dependency_ids = [
|
||||
*task.completed_dependency_ids,
|
||||
completed_task_id,
|
||||
]
|
||||
# If no more dependencies, unblock (system action - no role validation)
|
||||
if not task.dependency_ids and task.status == TaskStatus.BLOCKED:
|
||||
await self._revive_unblocked_dependent(task)
|
||||
@@ -4228,7 +4300,7 @@ class TaskService(BaseService):
|
||||
) -> dict[str, Any] | None:
|
||||
"""Append a progress update whose % is DERIVED from the plan checklist.
|
||||
|
||||
#173: the plan's sub_tasks ARE the progress skeleton. When
|
||||
The plan's sub_tasks ARE the progress skeleton. When
|
||||
``plan_step`` (a sub_task id, or 1-based order/index) is given,
|
||||
that step is marked ``completed`` and the percentage is computed
|
||||
as completed/total (equal weight) — the agent cannot game it.
|
||||
@@ -5014,7 +5086,7 @@ class TaskService(BaseService):
|
||||
Raises UnauthorizedError or ValidationError on failure; returns
|
||||
cleanly when every gate passes. Extracted from
|
||||
``escalate_to_ceo_for_agent`` to keep that orchestrating method
|
||||
below B-rank cyclomatic complexity (audit P2-2 / xenon).
|
||||
below B-rank cyclomatic complexity (xenon).
|
||||
"""
|
||||
if not permissions.can_perform_task_action(agent, TaskAction.CLOSE, task.team):
|
||||
raise UnauthorizedError(
|
||||
@@ -5176,7 +5248,13 @@ class TaskService(BaseService):
|
||||
update_data: dict[str, Any] = {
|
||||
"status": new_status.value,
|
||||
"dev_notes": f"[SUBSTITUTE] Reason: {reason}\n{details}",
|
||||
"assigned_to": None,
|
||||
# Keep the task with its current owner. A substitute-out is almost
|
||||
# always a transient stall (a verb that kept 500-ing, a retry-limit
|
||||
# trip, a low-context bail), so the task re-dispatches to the SAME
|
||||
# agent — which resumes from the briefing handoff — instead of being
|
||||
# orphaned to pending+unassigned and going dormant. The only handoff
|
||||
# that changes owner is the task_complete → PM-review case below.
|
||||
"assigned_to": agent_id,
|
||||
}
|
||||
|
||||
target_pm_slug: str | None = None
|
||||
@@ -5370,7 +5448,7 @@ class TaskService(BaseService):
|
||||
|
||||
Wider than ``get_active_task_for_agent`` — includes BLOCKED, PAUSED,
|
||||
and NEEDS_REVISION so journal entries written while stuck still
|
||||
get the task_id auto-attached. Smoke-5 surfaced the bug: PMs
|
||||
get the task_id auto-attached. Dogfooding surfaced the bug: PMs
|
||||
wrote decisions during blocked state, auto-injection returned
|
||||
None, entries persisted with task_id=NULL, the C8 tracing gate
|
||||
never saw them, agents spiraled forever.
|
||||
@@ -5390,7 +5468,7 @@ class TaskService(BaseService):
|
||||
async def list_pending_for_agent(self, agent_id: UUID) -> list[TaskTable]:
|
||||
"""Tasks assigned to this agent that are still in PENDING status.
|
||||
|
||||
Pre-gateway parity (Wave B6, 2026-05-12): give_me_work missed the
|
||||
Pre-gateway parity: give_me_work missed the
|
||||
pre-assigned case before this. PMs whose root was seeded with
|
||||
assigned_to=<them> + status=pending got 'no work' until they
|
||||
triage()'d explicitly.
|
||||
@@ -5501,7 +5579,7 @@ class TaskService(BaseService):
|
||||
) -> None:
|
||||
"""Create a WorkSession row if one does not already exist for this claim.
|
||||
|
||||
Wave C4 (2026-05-12) — pre-gateway parity. The gateway's claim/plan/
|
||||
Pre-gateway parity. The gateway's claim/plan/
|
||||
start path calls this after the task reaches in_progress so every
|
||||
(agent, task) claim cycle has a WorkSession row that downstream
|
||||
subsystems (panel, PR tracking, merge chain) can use. Delegates to
|
||||
@@ -5527,7 +5605,7 @@ class TaskService(BaseService):
|
||||
async def set_acceptance_criteria_status(
|
||||
self, task_id: UUID, status: list[dict[str, Any]]
|
||||
) -> TaskTable | None:
|
||||
"""Persist per-criterion addressing status. Wave C5 (2026-05-12).
|
||||
"""Persist per-criterion addressing status.
|
||||
|
||||
Replaces the full acceptance_criteria_status list with `status`.
|
||||
Each entry must have the shape:
|
||||
@@ -5712,7 +5790,7 @@ class TaskService(BaseService):
|
||||
The audit row is attributed to QA via task.claimed_by (set by
|
||||
qa_claim). We assert qa_agent_id matches claimed_by so any future
|
||||
divergence surfaces loudly instead of silently mis-recording the
|
||||
actor (audit D-18). Clears the single-claimant lock so the
|
||||
actor. Clears the single-claimant lock so the
|
||||
documenter can claim cleanly.
|
||||
"""
|
||||
task = await self.get(task_id)
|
||||
@@ -5742,7 +5820,7 @@ class TaskService(BaseService):
|
||||
|
||||
`notes` is the QA narrative (stored on `qa_notes`); `issues` is
|
||||
appended to `dev_notes` as a checklist for the dev's revision.
|
||||
Asserts the actor matches claimed_by (audit D-18).
|
||||
Asserts the actor matches claimed_by.
|
||||
"""
|
||||
task = await self.get(task_id)
|
||||
if task is None:
|
||||
|
||||
@@ -309,9 +309,9 @@ class WorkspaceService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.root = Path(settings.workspaces_root)
|
||||
# Wave C2 (2026-05-12) — TTL cache for refresh fetches. Smoke run 3
|
||||
# fired 9 refresh-fetch warnings per run because each evidence()
|
||||
# call triggered ensure_workspace → fetch. The workspace doesn't
|
||||
# TTL cache for refresh fetches. Dogfooding fired 9 refresh-fetch
|
||||
# warnings per run because each evidence() call triggered
|
||||
# ensure_workspace → fetch. The workspace doesn't
|
||||
# change in subseconds. 30s TTL eliminates the noise without
|
||||
# compromising freshness (commits land slower than 30s in practice;
|
||||
# force=True override exists for the rare need-fresh case).
|
||||
@@ -636,8 +636,8 @@ class WorkspaceService:
|
||||
# network blips and offline mode must not break workspace
|
||||
# setup; checkout is unchanged.
|
||||
#
|
||||
# Wave C2 (2026-05-12): 30s TTL cache keyed by workspace
|
||||
# path. Smoke run 3 fired this fetch 9x/run because every
|
||||
# 30s TTL cache keyed by workspace path. Dogfooding fired
|
||||
# this fetch 9x/run because every
|
||||
# evidence() call triggers ensure_workspace within the same
|
||||
# few seconds. Skip redundant fetches; force=True overrides.
|
||||
_FETCH_CACHE_TTL_SECONDS = 30.0
|
||||
|
||||
@@ -87,6 +87,10 @@ class _FakeDb:
|
||||
result.scalars.return_value.all.return_value = []
|
||||
return result
|
||||
|
||||
async def scalar(self, *_args, **_kwargs):
|
||||
# _create_notification's purpose-dedup lookup — no existing duplicate.
|
||||
return None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_ctx(db: _FakeDb):
|
||||
|
||||
@@ -12,7 +12,13 @@ from uuid import uuid4 as _u
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.db.tables import A2AConversationTable, AgentTable, ProjectTable, TaskTable
|
||||
from roboco.db.tables import (
|
||||
A2AConversationTable,
|
||||
A2AMessageTable,
|
||||
AgentTable,
|
||||
ProjectTable,
|
||||
TaskTable,
|
||||
)
|
||||
from roboco.enforcement.a2a_access import A2AAccessDeniedError
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models.a2a import (
|
||||
@@ -451,6 +457,24 @@ async def test_get_messages_returns_chronological(a2a_setup: dict) -> None:
|
||||
assert len(msgs) == _SENT_COUNT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_chat_message_dedups_identical_unread(a2a_setup: dict) -> None:
|
||||
"""An identical message re-sent while still unread is suppressed (one copy),
|
||||
but a different message is not collapsed."""
|
||||
svc = a2a_setup["svc"]
|
||||
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
|
||||
cid = UUID(conv.id)
|
||||
first = await svc.send_chat_message(cid, "be-dev-1", "ping: are you free?")
|
||||
again = await svc.send_chat_message(cid, "be-dev-1", "ping: are you free?")
|
||||
distinct = await svc.send_chat_message(cid, "be-dev-1", "different message")
|
||||
# The duplicate returns the SAME stored message and adds no new row.
|
||||
assert again.id == first.id
|
||||
assert distinct.id != first.id
|
||||
msgs = await svc.get_messages(cid, "be-dev-1")
|
||||
_EXPECTED = 2 # the deduped "ping" + the distinct one
|
||||
assert len(msgs) == _EXPECTED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_conversation_with_resolution(a2a_setup: dict) -> None:
|
||||
svc = a2a_setup["svc"]
|
||||
@@ -1472,3 +1496,43 @@ async def test_send_gateway_adapter_with_mocked_conv(
|
||||
body="hi-2",
|
||||
)
|
||||
assert result2.content == "hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_all_read_clears_unread_for_agent(a2a_setup: dict) -> None:
|
||||
"""mark_all_read zeroes the agent's unread counter across its conversations
|
||||
and stamps read_at on the inbound messages, returning the count cleared —
|
||||
the bulk ack that lets an agent satisfy i_am_idle's unread-A2A soft-block."""
|
||||
svc: A2AService = a2a_setup["svc"]
|
||||
db = a2a_setup["db"]
|
||||
qa = a2a_setup["qa"]
|
||||
conv = await svc.get_or_create_conversation(
|
||||
agent_a="be-dev-1", agent_b="be-qa", task_id=a2a_setup["task_id"]
|
||||
)
|
||||
conv_id = UUID(conv.id)
|
||||
# be-dev-1 < be-qa canonically → dev is agent_a; dev's messages bump qa's
|
||||
# unread (unread_by_b).
|
||||
await svc.send_chat_message(conv_id, "be-dev-1", "one")
|
||||
await svc.send_chat_message(conv_id, "be-dev-1", "two")
|
||||
|
||||
cleared = await svc.mark_all_read(qa.id)
|
||||
assert cleared == 1
|
||||
|
||||
row = await db.get(A2AConversationTable, conv_id)
|
||||
assert row is not None
|
||||
assert row.unread_by_b == 0
|
||||
msgs = (
|
||||
(
|
||||
await db.execute(
|
||||
select(A2AMessageTable).where(
|
||||
A2AMessageTable.conversation_id == conv_id
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert all(m.read_at is not None for m in msgs)
|
||||
|
||||
# Idempotent — nothing left unread for qa.
|
||||
assert await svc.mark_all_read(qa.id) == 0
|
||||
|
||||
@@ -95,6 +95,57 @@ async def test_pending_notifications_returns_unacked_for_agent(setup: dict) -> N
|
||||
assert await setup["repo"].list_pending_notifications(uuid4()) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unread_mentions_returns_unacked_mention_notifications(
|
||||
setup: dict,
|
||||
) -> None:
|
||||
"""list_unread_mentions surfaces only UNACKED MENTION-type notifications, so
|
||||
an agent can clear them via notify_ack and satisfy i_am_idle's soft-block."""
|
||||
agent = setup["agent"]
|
||||
db = setup["db"]
|
||||
db.add(
|
||||
NotificationTable(
|
||||
type=NotificationType.MENTION,
|
||||
priority=NotificationPriority.NORMAL,
|
||||
from_agent=agent.id,
|
||||
to_agents=[agent.id],
|
||||
subject="You were mentioned in #backend-cell",
|
||||
body="hey can you look at this",
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
# A non-mention notification must NOT surface here (type filter).
|
||||
db.add(
|
||||
NotificationTable(
|
||||
type=NotificationType.ALERT,
|
||||
priority=NotificationPriority.HIGH,
|
||||
from_agent=agent.id,
|
||||
to_agents=[agent.id],
|
||||
subject="not a mention",
|
||||
body="x",
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
# An already-acked mention must NOT surface (acked_by is the read signal).
|
||||
db.add(
|
||||
NotificationTable(
|
||||
type=NotificationType.MENTION,
|
||||
priority=NotificationPriority.NORMAL,
|
||||
from_agent=agent.id,
|
||||
to_agents=[agent.id],
|
||||
acked_by=[agent.id],
|
||||
subject="already handled",
|
||||
body="y",
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
out = await setup["repo"].list_unread_mentions(agent.id)
|
||||
assert [o["subject"] for o in out] == ["You were mentioned in #backend-cell"]
|
||||
# An unrelated agent sees nothing.
|
||||
assert await setup["repo"].list_unread_mentions(uuid4()) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unread_a2a_returns_conversations_with_unread(setup: dict) -> None:
|
||||
agent = setup["agent"]
|
||||
|
||||
@@ -75,6 +75,22 @@ async def test_relay_event_unknown_session_is_noop(live_client: dict) -> None:
|
||||
assert resp.json() == {"pushed": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_reports_alive_for_open_session(live_client: dict) -> None:
|
||||
client, registry = live_client["client"], live_client["registry"]
|
||||
registry.open("s1", "intake-1")
|
||||
resp = await client.get("/api/prompter/live/s1/status")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
assert resp.json() == {"alive": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_reports_dead_for_unknown_session(live_client: dict) -> None:
|
||||
resp = await live_client["client"].get("/api/prompter/live/nope/status")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
assert resp.json() == {"alive": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_delivers_to_container(live_client: dict) -> None:
|
||||
client, registry = live_client["client"], live_client["registry"]
|
||||
|
||||
@@ -497,6 +497,63 @@ async def test_index_docs_swallows_errors(
|
||||
await svc._index_docs_background(uuid4(), [{"path": "x.md"}])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_workspace_docs_lands_missing_doc(
|
||||
task_setup: dict,
|
||||
db_session: AsyncSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
"""A doc that lives only in the agent's clone is read out of the branch and
|
||||
written under DOCS_BASE_PATH so the indexer can see it."""
|
||||
svc = task_setup["svc"]
|
||||
monkeypatch.setattr("roboco.services.docs.DOCS_BASE_PATH", tmp_path)
|
||||
task = await svc.create(_req(task_setup))
|
||||
task.branch_name = "feature/backend/abc"
|
||||
task.assigned_to = task_setup["agent_id"]
|
||||
await db_session.flush()
|
||||
|
||||
fake_git = MagicMock()
|
||||
fake_git.read_file_at_branch = AsyncMock(return_value="# Guide\ncontent\n")
|
||||
monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)
|
||||
|
||||
await svc._capture_workspace_docs(
|
||||
task.id, [{"path": "guide.md"}], task_setup["agent_id"]
|
||||
)
|
||||
|
||||
assert (tmp_path / "guide.md").read_text(encoding="utf-8") == "# Guide\ncontent\n"
|
||||
fake_git.read_file_at_branch.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_workspace_docs_skips_doc_already_on_server(
|
||||
task_setup: dict,
|
||||
db_session: AsyncSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
"""A doc already under DOCS_BASE_PATH (written via roboco_docs_write) is not
|
||||
re-fetched from the branch."""
|
||||
svc = task_setup["svc"]
|
||||
monkeypatch.setattr("roboco.services.docs.DOCS_BASE_PATH", tmp_path)
|
||||
(tmp_path / "api.md").write_text("already here", encoding="utf-8")
|
||||
task = await svc.create(_req(task_setup))
|
||||
task.branch_name = "feature/backend/abc"
|
||||
task.assigned_to = task_setup["agent_id"]
|
||||
await db_session.flush()
|
||||
|
||||
fake_git = MagicMock()
|
||||
fake_git.read_file_at_branch = AsyncMock(return_value="should not be used")
|
||||
monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)
|
||||
|
||||
await svc._capture_workspace_docs(
|
||||
task.id, [{"path": "api.md"}], task_setup["agent_id"]
|
||||
)
|
||||
|
||||
assert (tmp_path / "api.md").read_text(encoding="utf-8") == "already here"
|
||||
fake_git.read_file_at_branch.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _index_qa_review_background / _index_qa_errors_background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1102,3 +1102,8 @@ async def test_substitute_task_for_agent_runs_update(
|
||||
"needs different agent",
|
||||
)
|
||||
assert out is not None
|
||||
# A transient substitute-out must NOT orphan the task: it stays with the
|
||||
# same agent (re-dispatchable, resumes from the briefing) — never
|
||||
# pending+unassigned.
|
||||
assert out.status == TaskStatus.PENDING
|
||||
assert out.assigned_to == task_setup["agent_id"]
|
||||
|
||||
@@ -798,6 +798,8 @@ async def test_unblock_dependents_clears_dep_id(
|
||||
refreshed = await svc.get(dependent.id)
|
||||
assert refreshed is not None
|
||||
assert blocker.id not in refreshed.dependency_ids
|
||||
# The cleared dependency is remembered so the unblock briefing can surface it.
|
||||
assert blocker.id in refreshed.completed_dependency_ids
|
||||
assert refreshed.status == TaskStatus.IN_PROGRESS
|
||||
|
||||
|
||||
@@ -817,6 +819,9 @@ async def test_unblock_dependents_keeps_blocked_when_other_deps_remain(
|
||||
assert refreshed is not None
|
||||
# Still blocked because other_blocker is still in deps
|
||||
assert refreshed.status == TaskStatus.BLOCKED
|
||||
# but the one dependency that did complete is recorded.
|
||||
assert blocker.id in refreshed.completed_dependency_ids
|
||||
assert other_blocker.id not in refreshed.completed_dependency_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -138,6 +138,30 @@ async def test_board_escalate_to_ceo_blocks_wrong_state() -> None:
|
||||
task_svc.escalate_to_ceo.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_board_escalate_to_ceo_rejects_when_runner_declines() -> None:
|
||||
"""Runner returns None (service declined — e.g. the task left
|
||||
awaiting_pm_review mid-flight) → a clean invalid_state, NOT an unhandled
|
||||
``None.status`` 500 (the board's escalate-from-blocked crash loop)."""
|
||||
agent_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t = MagicMock(id=task_id, status="awaiting_pm_review", team="backend")
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = MagicMock(role="product_owner")
|
||||
task_svc.escalate_to_ceo.return_value = None
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_decision_for_task.return_value = True
|
||||
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.escalate_to_ceo(agent_id, task_id, reason="ready for CEO sign-off")
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "awaiting_pm_review" in body["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_board_escalate_to_ceo_blocks_disallowed_role() -> None:
|
||||
agent_id = uuid4()
|
||||
|
||||
@@ -139,6 +139,42 @@ async def test_cell_pm_complete_allows_when_all_terminal() -> None:
|
||||
task_svc.cell_pm_complete.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_pm_complete_allows_decision_without_separate_reflect() -> None:
|
||||
"""A PM with a fresh decision but NO separate reflect can still complete —
|
||||
the decision documents the close, so the reflect gate no longer loops
|
||||
weak-model PMs into respawn churn."""
|
||||
pm_id = uuid4()
|
||||
parent_id = uuid4()
|
||||
t = MagicMock(
|
||||
id=parent_id,
|
||||
status="awaiting_pm_review",
|
||||
assigned_to=pm_id,
|
||||
pr_number=10,
|
||||
team="backend",
|
||||
branch_name="feature/backend/abc",
|
||||
parent_task_id=None,
|
||||
)
|
||||
after = MagicMock(**{**t.__dict__, "status": "completed"})
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.all_subtasks_terminal.return_value = True
|
||||
task_svc.get_subtasks.return_value = []
|
||||
task_svc.cell_pm_complete.return_value = after
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_decision_for_task.return_value = True
|
||||
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||
journal_svc.has_reflect_for_task.return_value = False # no separate reflect
|
||||
git_svc = AsyncMock()
|
||||
git_svc.pr_merge.return_value = {"merge_commit_sha": "abc"}
|
||||
deps = _make_deps(task=task_svc, journal=journal_svc, git=git_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.cell_pm_complete(pm_id, parent_id, "cell scope reviewed and approved")
|
||||
assert env.error is None
|
||||
task_svc.cell_pm_complete.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main_pm_complete subtask gate (root-task case)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -667,7 +667,7 @@ async def test_i_am_idle_with_unread_a2a_soft_blocks() -> None:
|
||||
env = await c.i_am_idle(agent_id)
|
||||
body = env.as_dict()
|
||||
assert body["status"] == "idle_with_unread"
|
||||
assert "address" in body["next"].lower()
|
||||
assert "read_messages" in body["next"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -167,3 +167,39 @@ async def test_i_am_idle_pending_guard_runs_after_unread_check() -> None:
|
||||
assert body["error"] is None
|
||||
assert body["status"] == "idle_with_unread"
|
||||
task_svc.mark_agent_idle.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_idle_refuses_pm_owning_awaiting_pm_review() -> None:
|
||||
"""A PM that still owns a task awaiting its own review cannot idle — it must
|
||||
complete / reassign / delegate (a DM does not route work)."""
|
||||
agent_id = uuid4()
|
||||
review = MagicMock(id=uuid4(), status="awaiting_pm_review")
|
||||
task_svc = AsyncMock()
|
||||
task_svc.list_assigned_for_agent.return_value = [review]
|
||||
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.i_am_idle(agent_id)
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "reassign" in body["remediate"] or "delegate" in body["remediate"]
|
||||
task_svc.mark_agent_idle.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_idle_allows_dev_owning_awaiting_pm_review() -> None:
|
||||
"""The review guard is PM-only — a non-PM owning such a task still idles."""
|
||||
agent_id = uuid4()
|
||||
review = MagicMock(id=uuid4(), status="awaiting_pm_review")
|
||||
task_svc = AsyncMock()
|
||||
task_svc.list_assigned_for_agent.return_value = [review]
|
||||
task_svc.list_in_progress_for_agent.return_value = []
|
||||
task_svc.agent_for.return_value = MagicMock(role="developer")
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.i_am_idle(agent_id)
|
||||
assert env.status == "idle"
|
||||
task_svc.mark_agent_idle.assert_awaited_once()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -10,6 +11,7 @@ from roboco.services.gateway.evidence_builder import (
|
||||
BriefingInputs,
|
||||
build_context_briefing,
|
||||
build_evidence_for_task,
|
||||
build_task_handoff,
|
||||
)
|
||||
|
||||
|
||||
@@ -84,3 +86,80 @@ class TestContextBriefing:
|
||||
assert len(b["pending_notifications"]) == BRIEFING_LIST_CAP
|
||||
assert len(b["recent_team_activity"]) == BRIEFING_LIST_CAP
|
||||
assert len(b["blockers_in_my_lane"]) == BRIEFING_LIST_CAP
|
||||
|
||||
def test_task_handoff_defaults_none_and_surfaces_in_briefing(self) -> None:
|
||||
inputs = BriefingInputs(
|
||||
unread_a2a=[],
|
||||
unread_mentions=[],
|
||||
pending_notifications=[],
|
||||
task_metadata_gaps=[],
|
||||
recent_team_activity=[],
|
||||
blockers_in_my_lane=[],
|
||||
)
|
||||
assert build_context_briefing(inputs)["task_handoff"] is None
|
||||
|
||||
with_handoff = BriefingInputs(
|
||||
unread_a2a=[],
|
||||
unread_mentions=[],
|
||||
pending_notifications=[],
|
||||
task_metadata_gaps=[],
|
||||
recent_team_activity=[],
|
||||
blockers_in_my_lane=[],
|
||||
task_handoff={"pr_number": 8},
|
||||
)
|
||||
assert build_context_briefing(with_handoff)["task_handoff"] == {"pr_number": 8}
|
||||
|
||||
|
||||
class TestTaskHandoff:
|
||||
def test_none_task_returns_none(self) -> None:
|
||||
assert build_task_handoff(None, []) is None
|
||||
|
||||
def test_no_prior_work_returns_none(self) -> None:
|
||||
t = _task(pr_number=None, pr_url=None, dev_notes="")
|
||||
t.commits = [] # _task's `commits or [...]` default would re-seed one
|
||||
t.acceptance_criteria_status = []
|
||||
assert build_task_handoff(t, []) is None
|
||||
|
||||
def test_digest_from_prior_work(self) -> None:
|
||||
pr = 8
|
||||
t = _task(
|
||||
pr_number=pr,
|
||||
commits=[{"sha": "abc", "message": "feat: x"}],
|
||||
dev_notes="implemented the parser",
|
||||
)
|
||||
t.branch_name = "feature/backend/abc"
|
||||
t.acceptance_criteria_status = [{"criterion": "parses", "met": True}]
|
||||
digest = build_task_handoff(t, [{"summary": "chose recursive descent"}])
|
||||
assert digest is not None
|
||||
assert digest["pr_number"] == pr
|
||||
assert digest["branch_name"] == "feature/backend/abc"
|
||||
assert digest["commit_count"] == 1
|
||||
assert digest["dev_summary"] == "implemented the parser"
|
||||
assert digest["journal_highlights"] == [{"summary": "chose recursive descent"}]
|
||||
|
||||
def test_surfaces_completed_dependencies(self) -> None:
|
||||
dep_id = uuid4()
|
||||
t = _task(pr_number=None, pr_url=None, dev_notes="")
|
||||
t.commits = []
|
||||
t.acceptance_criteria_status = []
|
||||
t.completed_dependency_ids = [dep_id]
|
||||
digest = build_task_handoff(t, [])
|
||||
# A just-unblocked task with no other prior work still surfaces the dep.
|
||||
assert digest is not None
|
||||
assert digest["completed_dependency_ids"] == [str(dep_id)]
|
||||
|
||||
def test_caps_lists_and_type_guards(self) -> None:
|
||||
thirty = [{"sha": str(i)} for i in range(30)]
|
||||
t = _task(pr_number=7, commits=thirty)
|
||||
# Non-list / mismatched-type attributes degrade safely, never leak.
|
||||
t.acceptance_criteria_status = object()
|
||||
t.pr_url = object()
|
||||
t.branch_name = None
|
||||
not_a_list: Any = object()
|
||||
digest = build_task_handoff(t, not_a_list)
|
||||
assert digest is not None
|
||||
assert len(digest["recent_commits"]) == BRIEFING_LIST_CAP
|
||||
assert digest["acceptance_criteria_status"] == []
|
||||
assert digest["journal_highlights"] == []
|
||||
assert digest["pr_url"] is None
|
||||
assert digest["branch_name"] is None
|
||||
|
||||
@@ -15,6 +15,7 @@ from uuid import uuid4
|
||||
import pytest
|
||||
from roboco.api.schemas.git import GitCreateBranchRequest
|
||||
from roboco.config import settings
|
||||
from roboco.exceptions import GitCommandError
|
||||
from roboco.services.base import NotFoundError, UnauthorizedError
|
||||
from roboco.services.git import GitService
|
||||
|
||||
@@ -193,6 +194,41 @@ async def test_diff_returns_diff_stdout() -> None:
|
||||
assert "+hello" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_at_branch_returns_committed_content() -> None:
|
||||
svc = _service()
|
||||
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_token_for_branch", AsyncMock(return_value=None))
|
||||
_bind(svc, "_resolve_head_ref", AsyncMock(return_value="HEAD"))
|
||||
_bind(
|
||||
svc,
|
||||
"_run_git",
|
||||
AsyncMock(return_value=MagicMock(stdout="# API\nbody\n", returncode=0)),
|
||||
)
|
||||
out = await svc.read_file_at_branch(
|
||||
branch_name="feature/backend/abc", path="docs/api.md"
|
||||
)
|
||||
assert out == "# API\nbody\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_at_branch_missing_returns_none() -> None:
|
||||
svc = _service()
|
||||
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_token_for_branch", AsyncMock(return_value=None))
|
||||
_bind(svc, "_resolve_head_ref", AsyncMock(return_value="HEAD"))
|
||||
# git show on a path that isn't in the tree exits non-zero.
|
||||
_bind(
|
||||
svc,
|
||||
"_run_git",
|
||||
AsyncMock(return_value=MagicMock(stdout="", returncode=128)),
|
||||
)
|
||||
out = await svc.read_file_at_branch(
|
||||
branch_name="feature/backend/abc", path="nope.md"
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_target: GitHub round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -522,3 +558,43 @@ async def test_create_branch_keeps_existing_branch_that_has_work() -> None:
|
||||
assert not any(c[:2] == ["reset", "--hard"] for c in calls), (
|
||||
"a branch with real work must never be reset"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_restates_gh001_as_permanent() -> None:
|
||||
"""A >100MB push rejection (GH001) is re-raised with a clear, permanent
|
||||
message that points at i_am_blocked — not the raw output an agent mis-reads
|
||||
as a transient timeout and blind-retries."""
|
||||
svc = _service()
|
||||
_bind(svc, "get_current_branch", AsyncMock(return_value="feature/x"))
|
||||
_bind(svc, "_token_for_workspace", AsyncMock(return_value=None))
|
||||
|
||||
async def _run_git(_workspace: object, args: list[str], **_kw: object) -> object:
|
||||
if args[:1] == ["push"]:
|
||||
raise GitCommandError(
|
||||
"git push",
|
||||
"remote: error: GH001: large.bin is 115.00 MB; this exceeds "
|
||||
"GitHub's file size limit of 100.00 MB",
|
||||
)
|
||||
return MagicMock(returncode=0, stdout="1", stderr="")
|
||||
|
||||
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
|
||||
with pytest.raises(GitCommandError, match="i_am_blocked"):
|
||||
await svc.push(Path("/tmp/ws"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_propagates_non_gh001_error_unchanged() -> None:
|
||||
"""A non-size push failure is re-raised as-is (not reclassified)."""
|
||||
svc = _service()
|
||||
_bind(svc, "get_current_branch", AsyncMock(return_value="feature/x"))
|
||||
_bind(svc, "_token_for_workspace", AsyncMock(return_value=None))
|
||||
|
||||
async def _run_git(_workspace: object, args: list[str], **_kw: object) -> object:
|
||||
if args[:1] == ["push"]:
|
||||
raise GitCommandError("git push", "fatal: Authentication failed")
|
||||
return MagicMock(returncode=0, stdout="1", stderr="")
|
||||
|
||||
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
|
||||
with pytest.raises(GitCommandError, match="Authentication failed"):
|
||||
await svc.push(Path("/tmp/ws"))
|
||||
|
||||
@@ -56,6 +56,11 @@ class _FakeDb:
|
||||
result.scalars.return_value.all.return_value = []
|
||||
return result
|
||||
|
||||
async def scalar(self, *_args, **_kwargs):
|
||||
# _create_notification's purpose-dedup lookup runs db.scalar(); model
|
||||
# "no existing duplicate" so creation proceeds.
|
||||
return None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_ctx(db: _FakeDb):
|
||||
|
||||
@@ -521,6 +521,30 @@ async def _seed_project_and_ceo(db_session: Any) -> tuple[UUID, UUID]:
|
||||
)
|
||||
db_session.add_all([project, ceo])
|
||||
await db_session.flush()
|
||||
# The "& Start" routes assign the draft to a fixed board/PM agent
|
||||
# (product-owner for "Board review", main-pm for "Approve & Start"); those
|
||||
# rows must exist for the assigned_to FK. merge() is idempotent, so this is
|
||||
# safe whether or not another test already committed them on the shared DB.
|
||||
for slug, role, team in (
|
||||
("product-owner", AgentRole.PRODUCT_OWNER, None),
|
||||
("main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
|
||||
):
|
||||
await db_session.merge(
|
||||
AgentTable(
|
||||
id=UUID(AGENT_UUIDS[slug]),
|
||||
name=slug,
|
||||
slug=slug,
|
||||
role=role,
|
||||
team=team,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt=slug,
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
return project_id, ceo_id
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,16 @@ def test_open_get_close() -> None:
|
||||
assert reg.get("s1") is None
|
||||
|
||||
|
||||
def test_is_alive_tracks_open_and_close() -> None:
|
||||
"""is_alive backs the panel's after-reload reconnect decision."""
|
||||
reg = PrompterLiveRegistry()
|
||||
assert reg.is_alive("s1") is False # never opened
|
||||
reg.open("s1", "intake-1")
|
||||
assert reg.is_alive("s1") is True
|
||||
reg.close("s1")
|
||||
assert reg.is_alive("s1") is False # reaped
|
||||
|
||||
|
||||
def test_open_is_idempotent_for_a_live_session() -> None:
|
||||
"""Re-opening a live session returns the SAME object (same queue).
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""NotificationService._create_notification purpose-based dedup (unit).
|
||||
|
||||
The dedup short-circuit returns before any row is created when a same-purpose
|
||||
(same sender, type, task, overlapping recipients) notification is still
|
||||
unacknowledged. The real-DB query shape is exercised by the route/integration
|
||||
suites; here we assert the branch wiring with a mocked db context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.models import NotificationPriority, NotificationType
|
||||
from roboco.models.notification import CreateNotificationParams
|
||||
from roboco.services.notification import NotificationService
|
||||
|
||||
|
||||
class _FakeDBCtx:
|
||||
"""Minimal async-context-manager yielding a mocked db handle."""
|
||||
|
||||
def __init__(self, db: object) -> None:
|
||||
self._db = db
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
return self._db
|
||||
|
||||
async def __aexit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _params() -> CreateNotificationParams:
|
||||
return CreateNotificationParams(
|
||||
notification_type=NotificationType.ALERT,
|
||||
priority=NotificationPriority.NORMAL,
|
||||
from_agent="from-1",
|
||||
to_agents=["to-1"],
|
||||
subject="s",
|
||||
body="b",
|
||||
related_task_id="t1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_notification_suppresses_same_purpose_duplicate() -> None:
|
||||
"""An existing same-purpose unacked notification suppresses a new insert."""
|
||||
db = MagicMock()
|
||||
db.scalar = AsyncMock(return_value=uuid4()) # a same-purpose duplicate exists
|
||||
db.add = MagicMock()
|
||||
db.flush = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
svc = NotificationService()
|
||||
svc._resolve_recipients = AsyncMock(return_value=[uuid4()]) # type: ignore[method-assign]
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.notification.get_db_context",
|
||||
return_value=_FakeDBCtx(db),
|
||||
),
|
||||
patch(
|
||||
"roboco.services.notification._resolve_agent_uuid",
|
||||
AsyncMock(return_value=uuid4()),
|
||||
),
|
||||
):
|
||||
await svc._create_notification(_params())
|
||||
|
||||
# Dedup hit → no row created, nothing committed/delivered.
|
||||
db.add.assert_not_called()
|
||||
db.commit.assert_not_called()
|
||||
db.scalar.assert_awaited_once()
|
||||
Reference in New Issue
Block a user