agentic-teams: epics, tasks, git forges, agent authority #139
Reference in New Issue
Block a user
Delete Branch "feat/agentic-teams"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The agentic-teams foundation: Epic/EpicTask/Task work tracker (CAS transitions, risk-scaled done gate, merge-on-accept, CI-failure reopen), GitProvider/CodeRepo forge integration, invoker-bounded agent authority, tasks/inference-tasks RBAC split, mcpctl system agent, ephemeral session pods, backup coverage for the new resources, full CLI (create/apply/describe/completions) + vertical smoke tests, and ~2k lines of docs.
Also carries today's merge from main (conflict resolved in mcpd main.ts) and the SecretBackend FK fix for the db Secret model tests.
🤖 Generated with Claude Code
`mcpctl chat <agent>` failed with HTTP 503 {"error":"service_unavailable","message":"Cannot reach mcpd daemon. Is it running?"} while mcpd was answering /healthz in 32ms. The message was wrong in a way that cost real debugging time: mcplocal was reaching mcpd fine and giving up after 30s. journalctl shows the signature plainly — statusCode 503 with responseTime 30003.87 on POST /api/v1/agents/reviewer/chat. This blocks the agentic-teams epic outright. An agent turn is a multi-turn tool-use loop that runs for minutes by design, so a 30s ceiling on the chat path is not a safety net, it is a guaranteed failure for every non-trivial turn. Three defects, all in the same path: 1. One blanket budget for every forwarded route. DEFAULT_TIMEOUT_MS = 30_000 is right for CRUD and wrong for chat. Chat, project chat, llm infer and inference-task streams now get LONG_RUNNING_TIMEOUT_MS (600_000, override with MCPLOCAL_LONG_TIMEOUT_MS) — matching STREAM_TIMEOUT_MS, which the CLI already allowed. mcplocal in the middle was the binding constraint. 2. Timeouts were reported as connection failures. Split UpstreamTimeoutError out of ConnectionError and map it to 504 with an accurate message that says the daemon IS reachable. ConnectionError still means unreachable and still returns 503. Verified nothing else branches on ConnectionError. 3. SSE was buffered. `forward()` reads the whole body through res.text(), so even turns that finished in time arrived as one blob and the CLI's live token output never appeared. Streaming routes now use forwardStream() and pipe the body straight through, preserving content-type and x-accel-buffering (dropping the latter lets intermediaries re-buffer and reintroduces the stall). Also closes the escape that produced the sibling `500 code:23` failure: the body read in forward() was outside the try, so when mcpd had already written SSE headers the raw DOMException reached Fastify unhandled. Tests: 9 new proxy tests. The two that matter — "does not abort an agent chat that outlives the CRUD budget" and "streams SSE through instead of buffering" — were confirmed to FAIL against the pre-fix behaviour and pass after. Three existing mcpd-client tests asserted the old taxonomy and were updated to assert the new one deliberately. Local: build clean, workspace 2375 passed, lint unchanged at 869. NOT YET LIVE: mcplocal runs from the installed RPM, so this needs a package rebuild + `systemctl --user restart mcplocal` to take effect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9pmcpctlsystem agent — operates mcpctl with the caller's permissions 77c45a2715A system agent, seeded like the system project and system skills, whose entire job is running mcpctl for the user: mcpctl chat mcpctl -m 'Make a prompt in the SRE project saying we run multiple agents on the same source at once, so always use git worktrees' ## The permission model is the design Every tool call loops back through mcpd's own HTTP API carrying the CALLING USER's bearer token. The agent holds no credential of its own and never touches the service layer directly, so the existing auth hook, RBAC hook, zod validation, ownership scoping and audit trail all apply verbatim. It is exactly as privileged as the person talking to it, never more. Calling services directly would have been simpler and would have meant re-implementing every one of those checks at a second call site — precisely the RBAC carve-out this project refuses to make. A 403 is surfaced as a 403, not retried or routed around; the system prompt tells the agent to report it. Verified live, not just mocked: the same tool with an admin token created a project, and with a plain user's token returned "Permission denied (403)". No token at all refuses before any request is made; a bad token is rejected by mcpd's own auth, because there is no ambient credential to fall back on. ## Shape - `Agent.toolset` (TEXT, default 'project') selects the surface. 'mcpctl' means no project prompts and no MCP tools — managing mcpctl is the whole job, and an unrelated project's context would be noise it might act on. - Five generic tools (list/get/create/update/delete + resource) rather than one per resource. mcpctl is a REST API over named resources, so that is the honest surface, and this repo's own tool-selection findings say a scoped catalogue beats a large flat one. - Names are resolved to ids by listing first, and a miss reports what IS available, so the model never guesses an id. - Resource is a closed enum: the model cannot invent an endpoint. Also exercised end to end against a live mcpd: created the worktrees prompt in the `sre` project, listed it back, and read its content — the user's original example, working. Tests: 17 unit tests covering the token plumbing, the no-token and 403 paths, resource allowlisting, name resolution, and request shaping. Local: build clean, workspace 2457 passed (+17), lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9pThe audit found that none of the new resources were in BACKUP_KINDS, so git-backup never captured them and a restore would lose every epic, roadmap node, repo and forge registration. Closes 135/165. Backed up: gitprovider, repo, epic, epictask — configuration and the vision layer. NOT tasks: a work item carries in-flight state (lifecycle position, verdicts, PR links) and restoring a stale one from git would resurrect work that has since moved on. That is the same reasoning that keeps McpInstance out of the backup, and there is a test asserting it stays out. Two properties the tests pin, because both are the kind of thing that fails silently: - **Credentials serialize as a REFERENCE, never a value.** The backup repo is a plaintext git checkout; emitting a resolved token would export every forge credential to disk in cleartext. credSecretId/credSecretKey are internal fields and become `credRef: {name, key}`; restore re-resolves against whatever the secret backend holds now. - **Relations serialize by name, not foreign key.** A restore into a different database has different cuids, so a backed-up projectId would dangle. Restore ordering follows dependencies: a forge before the repos that reference it, an epic before its roadmap nodes. Two deliberate refusals on the restore path: an Epic's `status` is never written by a restore, so a stale backup cannot silently mark it delivered — that terminal state is only reachable through setStatus and its rules; and a GitProvider is not probed on restore, because the forge may legitimately be unreachable while a backup replays and failing the whole restore for that is worse than a row that needs a later `probe`. The pre-existing APPLY_ORDER length guard caught this change, which is exactly what it is for; updated 9 → 13 and tied it to BACKUP_KINDS.length so the two cannot drift apart again. Also marks 153 done — `mcpctl tasks accept/reject` shipped with the CLI work; the generated graph had it blocked behind the git-provider merge, but the gate itself does not need it. Local: build clean, workspace 2463 passed (+6), lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9pCloses 134/164. The five agentic-teams resources had RBAC slots but the non-CRUD verbs had no cases, so they fell through to the method default and every one of them read as `create:<resource>`. That is wrong in both directions: it let anyone who may CREATE a task also ACCEPT one — the human gate that fires a merge — and it refused a reviewer who may drive tasks but not create them. Corrected, following the verb the codebase already uses for "use this resource to do work" (llms/infer, agents/chat, projects/chat): - POST /tasks/:id/{transition,accept,reject,verdicts} → run:tasks:<name> - {GET,POST} /gitproviders/:id/{repos,import,probe} → run:gitproviders:<name>. Discovery calls OUT to the forge with its stored token, so holding view:gitproviders should let you see that a forge is registered, not use its credential. - POST /epics/:id/{status,tradeoffs,criteria} → edit:epics:<name>. These rewrite the vision record. - GET /epics/:id/roadmap → view:epics:<name> - {POST,DELETE} /epictasks/:id/edges → edit:epictasks:<name> mapUrlToPermission moved from main.ts to middleware/url-permission.ts. main.ts calls main() at module load, so nothing in it is importable from a test — an authorization mapping that cannot be tested is an authorization mapping nobody has checked, which is exactly how these verbs shipped mis-scoped. 15 tests now assert the mapping rather than the mere presence of the strings, including that a bare create grant does NOT cover the gated verbs, that work items and the inference queue never resolve to the same resource for any method, and that the pre-existing mappings (llm infer, agent chat, operations, the personalities/skills piggybacks) still hold. Local: build clean, workspace 2478 passed (+15), lint back to the repo's baseline 794 errors after making the five new match guards explicit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9pAn Epic could report 0% while every Task under it was merged. That is not a cosmetic gap — it is exactly the blind spot the Epic exists to remove (docs/agentic-teams.md §1b: "we completed all the tasks and got a stick with wheels instead of a car"). An Epic that cannot see its own delivered work cannot detect that, because `completeness()` reads only `doneCriteria[].met` and nothing flips those except a human hitting the criteria endpoint. So read the same question a second time, from the delivery side, and report where the two readings disagree — in both directions: - deliveredButUnclaimed — unmet criteria whose owning roadmap node has merged work. Somebody delivered and nobody ticked the box. - claimedButUndelivered — criteria marked met with nothing merged underneath. The box is ticked and nothing shipped. The dangerous direction, and the one that is easy to forget to look for. - unowned — criteria no roadmap node claims at all. They cannot appear in either list above, so without a bucket of their own they would vanish inside a clean-looking reconciliation. Nothing is written. `met` stays exactly as a human or agent set it: deriving it from task state would assert that finishing the tasks means delivering the vision, which is the assumption under audit. The mismatch is reported, not silently resolved. Attribution is by criterion id, because that is the only link the schema offers — EpicTask.doneCriteria is documented as "the decomposed subset of the Epic's doneCriteria this node owns" and carries the same ids, but nothing enforces it. Rather than guess an owner by name similarity or ordering, the convention is treated as a claim and its failures are surfaced as `unowned`. A foreign key from criterion to epictask would end the ambiguity; this reports the ambiguity instead of hiding it. Judgement calls, made explicit so they can be argued with: - `merged` alone counts as delivered. `done` only leaves the developer's loop and `awaiting_review` still needs a human; `reopened` means a merge did not hold, so it counts as outstanding. - `cancelled` is neither delivered nor outstanding. Counting it as owed would leave a node permanently undelivered because someone abandoned a task; counting it as delivered is a lie. It is reported on its own. - a node with no tasks is undelivered, not exempt — a met criterion over an undecomposed node is still a claim with nothing behind it. The empty task rollup is what distinguishes it from "built but not merged". - an epictask's own `status` is another claim, not evidence, so it is carried for context and never counted as delivery. Surfaced on GET /api/v1/epics/:id next to the completeness it contradicts — a mismatch is only useful where the misleading percentage is already on screen — and standalone at GET /api/v1/epics/:id/reconciliation for callers that do not want a 200KB Epic body with it. Deliberately kept off the list route, which would pay a query per roadmap node per Epic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>The AgentProject junction has existed since the data-model commit and nothing read it. `grep -c agentProject src/mcpd/src/services/chat.service.ts` returned 0; chat resolved an agent's project from the legacy single `Agent.projectId` scalar. So the schema claimed "an agent is a person who joins zero, one, or many projects" (docs/agentic-teams.md §4) while the running system still gave every agent exactly one project. A schema that lies about behaviour is worse than a missing feature: it reads as done, so nobody builds it. This makes the junction load-bearing. Membership is now a real surface (task 152): GET /api/v1/agents/:name/projects memberships POST /api/v1/agents/:name/projects/:project join, body {role?, llm?} DELETE /api/v1/agents/:name/projects/:project leave GET /api/v1/projects/:name/memberships the roster, with roles `role` is TEXT (developer|qa|security|sre in v1) and is validated in the service layer, not the database — production provisions with `prisma db push`, so a CHECK constraint written into a migration would never run there. Adding a fifth role stays a one-line change. RBAC: joining/leaving is `edit:agents:<name>`, not the `create:agents` the default method→action mapping would have produced. Minting a whole new agent and adding an existing one to a team are not the same authority; this mirrors project↔server attach/detach already being `edit:projects`. Reading a roster is `view:` on the side you asked from. Chat honours it. `prepareContext` resolves the project from the membership when the caller names one, and the resolution runs *before* the Llm lookup so a per-membership `llmId` can replace the agent's pinned brain — and therefore its whole failover pool — rather than patching the dispatch list afterwards. That override is why the brain-ownership question (§15 Q1) can still go either way without a migration: today the answer is per membership. Naming a project the agent has not joined is an error that names the fix, not a silent fallback. Answering from a *different* project's tools and prompts than the one asked for is the failure mode worth being loud about. The legacy scalar keeps working this release, as the schema comment promises: `--project` accepts a membership *or* a legacy pin naming that same project, bare `mcpctl chat <agent>` is unchanged, and `GET /projects/:p/agents` returns the union of members and legacy-pinned agents so nothing disappears from a project mid-migration (mcplocal's agents plugin reads that endpoint). CLI: `mcpctl chat <agent> --project <p>` (previously rejected as "not both"), plus `attach-agent` / `detach-agent` alongside the existing attach-server pair, `get agents --project`, and `get memberships` with ROLE and BRAIN columns. Completions come from the generator, not hand-edits. Threads stay scoped to the agent, not to (agent, project) — §4 wants the latter, but that needs a schema change and a story for what happens to those threads when a membership is revoked. Deliberately not decided here. Tests: 51 new (43 mcpd, 8 CLI). Full suite 2514 pass. Lint unchanged at 794 errors / 77 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Closes 167. The five new resources fell through to the generic key-value dump, against the project rule that describe shows clean sections and tables. More to the point, two things built earlier had no way to reach a human at all: the roadmap endpoint had no client, and the Epic reconciliation was only visible as raw JSON. `describe epic` puts the delivery mismatch directly under the completeness percentage it contradicts, because reading either one alone is the bug. Rendered against a live mcpd: Completeness: [....................] 0% (0/3) ... Delivery: MISMATCH between what is claimed and what shipped Tasks: 1 merged, 0 outstanding, 0 cancelled Delivered, not claimed: - wheels (1 merged, 0 outstanding) No roadmap node owns it: - engine, feel plus the criteria checklist, accepted tradeoffs, and the roadmap with per-node completeness and READY flags. `describe task` derives the gates a task still owes from its risk and recorded verdicts ("awaiting security_review, sre_review" vs "all passed"), and shows the legal next transitions, PR linkage, verdicts and full history — so the answer to "why won't this move" is on screen rather than requiring a failed attempt. `describe repo` / `describe gitprovider` show the credential REFERENCE only. The resolved value exists solely on the session-provision path and must never reach a describe. The roadmap, verdict and event fetches are all best-effort: an older mcpd 404s and the resource still renders, rather than the whole describe failing over an enrichment. One bug found by looking at the output rather than the types: the Git section opened on `!== undefined`, but JSON hands back null, so every task without a PR printed an empty `Git:` header. Local: build clean, workspace 2557 passed, lint unchanged at 794 errors. Verified live against a local mcpd for epic, epictask and task. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p