feat(agents+issues): expose isRouter via agent API and tag audit log with routingCorrection (FUL-2490)#6526
Conversation
- Add shouldDownlevel404ToDebug() to http-log-policy: GET /api/issues/:id and GET /api/heartbeat-runs/:runId/log 404s are downgraded to debug level since they occur routinely during UI polling and agent heartbeats on stale IDs. - Add 30-second dedupe window in logger for repeated identical warn-level 404s on any other route (prevents bursts of identical lines for other transient misses). - True route faults (unknown paths, non-GET mutations, 5xx) remain at warn/error. - Add test coverage for shouldDownlevel404ToDebug(). Co-Authored-By: Paperclip <[email protected]>
Adds platform-level protection against agent self-resonance loops: when an assignee agent posts CONSECUTIVE_AGENT_COMMENT_CAP (3) comments in a row on the same issue with no human or other-agent reply in between, the route now automatically: 1. Sets the issue to `blocked` (which also clears checkoutRunId, executionRunId, and related lock fields via the existing update path). 2. Posts a system-authored comment tagging the CTO for manual review. 3. Suppresses the assignee wake for that comment, preventing the next loop iteration. Guards: skips on closed issues, reopened-by-comment flows, and comments from non-assignees. Includes 6 unit tests covering cap trigger, streak reset by human/other-agent, closed-issue bypass, and non-assignee bypass. Guardrails 1 (self-comment wake suppression via selfComment check) and 3 (blocked-issue dependency suppression) were already implemented at the scheduler/route layer; documented in FUL-2207 issue comment. Co-Authored-By: Paperclip <[email protected]>
Three targeted fixes to break the board-user-comment → agent-wake infinite loop on blocked issues: 1. Remove `blocked` from `shouldImplicitlyMoveCommentedIssueToTodo` so a plain board comment no longer silently promotes blocked → todo. Only formally-closed issues are eligible for implicit reopen. 2. Add `isBlockedWithoutReopen` guard to the POST /comments wake path and set `issue.status === "blocked"` in the PATCH route skipWake check. Comments on blocked issues no longer fire `issue_commented` wakes; `issue_blockers_resolved` and explicit reopen paths are unaffected. 3. Add `ne(issues.status, "blocked")` to the checkout WHERE clause and a 422 error when `current.status === "blocked"`, preventing any agent from silently promoting a status-blocked issue to `in_progress` by passing `expectedStatuses: ["todo","blocked",...]`. Tests: updated existing blocked-issue reopen tests to expect the new behaviour; added skipWake route test and a service-level checkout rejection test for status-only-blocked issues. Parent incident: FUL-2230 Co-Authored-By: Paperclip <[email protected]>
…adapter_failed (FUL-2341)
When issuesSvc.checkout throws HTTP 422 because the issue status is blocked,
the outer catch was classifying the run as adapter_failed, pushing the agent
into error state. This is a platform misclassification — a blocked checkout
is a known gate condition, equivalent to unresolved blockedByIssueIds.
Add isCheckoutBlockedError helper (matches 422 "Issue is blocked*") and handle
it in the executeRun checkout catch block by calling
cancelQueuedRunForBlockedDependencies + finalizeAgentStatus("cancelled").
The run now records errorCode=issue_dependencies_blocked / status=cancelled,
and the agent transitions to idle rather than error.
This fixes the phantom-blocked scenario: issues with status=blocked but empty
blockedByIssueIds pass through the claimQueuedRun dependency check (count=0)
and shouldAutoCheckoutIssueForWake returns true, but checkout throws 422.
Co-Authored-By: Paperclip <[email protected]>
When a blocking issue transitions to done, automatically move any dependent issues with status=blocked to todo (instead of only waking the assigned agent). Logs an issue.auto_unblocked activity note recording which blocker resolved the issue. Non-blocked dependents (in_progress, in_review, todo) are unaffected — only the status wake is sent. Regression tests cover: single blocker resolves → auto-transition; non-blocked dependent → no status change; remaining blockers → no action. Fixes: FUL-2358 Co-Authored-By: Paperclip <[email protected]>
…ing execution stage exists When all blockers on a blocked issue resolve, check the dependent's executionState. If a review stage is pending (status="pending"), restore to in_review so the waiting reviewer is re-engaged. Otherwise fall back to todo as before. Also include executionState in listWakeableBlockedDependents query results to support the routing decision. Regression coverage: adds the validation-child-done scenario (blocked dependent with pending review stage → blocker resolves → back to in_review). Fixes: FUL-2358 (board comment 847dabac) Co-Authored-By: Paperclip <[email protected]>
…point limits (FUL-2371) - Add global Express middleware in app.ts that logs a structured WARN for any request exceeding 500 ms, including method, path, statusCode, durationMs, and X-Paperclip-Run-Id header value - Middleware is registered immediately after httpLogger so it covers all API routes; uses the existing server logger (not console.log) - Document server-side limitBytes cap on log-streaming endpoints: default 256 KB, hard ceiling 1 MB enforced via readRunLogLimitBytes - Relax Cache-Control on GET /heartbeat-runs/:runId/log from no-cache,no-store to no-cache: the offset-based polling protocol already prevents stale reads, and no-store is unnecessarily aggressive for this endpoint Co-Authored-By: Paperclip <[email protected]>
…adapter.execute() (FUL-2374) The late-detection path added in 55b3a8c catches blocked checkout at issuesSvc.checkout() time, after the run has already been transitioned to running status. The board identified the remaining control-flow leak: adapter.execute() is never called for the blocked run itself, but the finally block still fires startNextQueuedRunForAgent, and any post-success pipeline (e.g. handleSuccessfulRunHandoff) from a concurrent run could bleed adapter calls into the next test. Add early detection in claimQueuedRun: when unresolvedBlockerCount=0 (no formal blockedByIssueIds) but the issue status is blocked, cancel the run immediately with errorCode=issue_dependencies_blocked and return null. executeRun returns at the null-claim guard, before activeRunExecutions.add() and before the try/finally block, so adapter.execute() is structurally unreachable for the phantom-blocked run. Fix afterEach mock-reset timing in the dependency-scheduling test: the old code reset mockAdapterExecute before waiting for background runs to idle, so any corrective handoff run created by a test's success-path pipeline (handleSuccessfulRunHandoff) executed after the reset and incremented the call count going into the next test. Moving the reset to after the idle-poll loop—and adding a 200 ms initial settle to prevent the poll from declaring idle before the handoff run appears in the DB—eliminates the cross-test bleed and makes the zero-call assertion reliable. Co-Authored-By: Paperclip <[email protected]>
…ckers complete listWakeableBlockedDependents filtered out candidates with a null assigneeAgentId, so blocked issues with no assignee were never auto-transitioned when their blockers resolved (FUL-2381 / FUL-2389). Remove the assigneeAgentId guard from the status-filter so all blocked-and-fully-resolved dependents are auto-transitioned to todo regardless of whether they have an assignee. The addWakeup call is now guarded by a null-check so wakeups are only sent when an assignee exists. Adds a regression test for the unassigned-dependent path. Co-Authored-By: Paperclip <[email protected]>
…, wiring - Migration 0087: adds compacted_at to heartbeat_runs, creates instance_retention_config table - HeartbeatCompactionService: NULLs result_json/stdout_excerpt/stderr_excerpt/context_snapshot for succeeded runs older than configured threshold (default 72h), batched in groups of 100; also NULLs adapter.invoke event payloads for compacted runs - Admin API: GET/PATCH /api/admin/retention-config (instance-admin only) - app.ts: wires startHeartbeatCompaction and startPluginLogRetention (was never called) at startup Fixes FUL-2363 / FUL-2323 (557 MB DB bloat from indefinitely-retained TOAST). Co-Authored-By: Paperclip <[email protected]>
…2489) - Add `is_router` boolean column to agents table (migration 0089) - Add `assertRouterAgentPatchAllowed` middleware: router agents may PATCH assigneeAgentId, blockedByIssueIds, and status (blocked/in_progress/todo only) - Enforce board-assignment preservation: cannot reassign board-user-owned issues - Audit log: records `issue.router_agent_cross_patch` with isRouter marker - Ship migration 0088 (privileged_human_gate) alongside this change - DB already migrated; TODD agent (369da996) already has is_router=true Pending: server restart required to load new middleware into running instance. Co-Authored-By: Paperclip <[email protected]>
…oard view
List view was fetching all issues unfiltered then applying status filters
client-side. With large datasets (200+ issues mostly done/cancelled), the
first server page returned very few matching issues while the rest were
filtered away client-side — producing the "one issue visible / scroll to
load more" symptom.
Board view already fired per-status server queries (boardIssueQueries).
This adds an equivalent listIssueQueries path that activates when viewMode
is "list" and explicit statuses are selected. Results are merged and passed
through the same applyIssueFilters path as before.
- Adds ISSUE_LIST_STATUS_RESULT_LIMIT = 200 (matching board cap)
- listIssues replaces the issues prop as the filter source when active
- canLoadMoreIssues and the load-more sentinel suppress server pagination
when list-mode status queries own the data
- Shows a cap notice ("up to 200 per status") mirroring the board notice
Co-Authored-By: Paperclip <[email protected]>
…(FUL-2490) Two missing pieces from the isRouter permission model (FUL-2489): 1. Add `isRouter: z.boolean().optional()` to createAgentSchema (and through partial(), to updateAgentSchema). Without this the validator strips the field from POST /api/agents and PATCH /api/agents/:id bodies before they reach the service, so isRouter could never be set via the public API. 2. Rename the audit-log detail key from `isRouter: true` to `routingCorrection: true` in the issue.router_agent_cross_patch activity entry, matching the AC in FUL-2490. Co-Authored-By: Paperclip <[email protected]>
Greptile SummaryThis PR closes two gaps from FUL-2490: it exposes
Confidence Score: 3/5Not safe to merge as-is: any agent with an API key can self-promote to router status, bypassing the intended access control for cross-issue mutations. The validator change widens what PATCH /api/agents/:id accepts, but the route handler already allows agents to modify their own records without field-level restrictions. isRouter is exactly the kind of privileged flag that should be gated behind a board-only or admin check — the same rationale that led permissions to get its own dedicated endpoint. server/src/routes/agents.ts around the PATCH /agents/:id handler needs a guard that prevents agents from setting isRouter on themselves, matching the existing pattern for the permissions field.
|
| Filename | Overview |
|---|---|
| packages/shared/src/validators/agent.ts | Adds isRouter: z.boolean().optional() to createAgentSchema; inherited by updateAgentSchema via .partial(). The schema change itself is correct, but no route-level guard prevents agents from self-setting this field. |
| server/src/routes/issues.ts | Renames audit-log detail key from isRouter: true to routingCorrection: true in the issue.router_agent_cross_patch activity entry. Single-line rename, correct per the AC. |
| server/src/routes/agents.ts | The PATCH /agents/:id handler lacks a guard for isRouter, meaning any agent can self-promote to router status — unlike permissions, which is explicitly blocked at this layer. |
| packages/db/src/schema/agents.ts | Adds isRouter boolean column (NOT NULL DEFAULT false) matching the migration SQL. Schema definition is consistent. |
| packages/db/src/migrations/0089_agent_is_router.sql | Adds is_router boolean column and seeds one specific agent UUID as the router. The hardcoded UUID will silently no-op in environments where that agent doesn't exist, but is acceptable for a single-deployment service. |
| docs/api/agents.md | isRouter is not documented in the Create Agent or Update Agent sections despite being a newly accepted, security-sensitive field. |
Comments Outside Diff (1)
-
server/src/routes/agents.ts, line 2562-2565 (link)Privilege escalation: any agent can self-promote to router status
assertCanUpdateAgentshort-circuits for self-updates (if (actorAgent.id === targetAgent.id) return;), so any agent holding an API key can sendPATCH /api/agents/:idwith{ isRouter: true }against its own ID and gain cross-issue mutation privileges immediately. The existing guard that blockspermissionschanges (redirecting callers to/api/agents/:id/permissions) is not extended to coverisRouter, leaving it freely self-settable.An analogous block is needed here: detect
isRouterinreq.bodyand either reject with 422 or restrict the path toboard-authenticated callers only — the same pattern thepermissionsfield already follows.Prompt To Fix With AI
This is a comment left during a code review. Path: server/src/routes/agents.ts Line: 2562-2565 Comment: **Privilege escalation: any agent can self-promote to router status** `assertCanUpdateAgent` short-circuits for self-updates (`if (actorAgent.id === targetAgent.id) return;`), so any agent holding an API key can send `PATCH /api/agents/:id` with `{ isRouter: true }` against its own ID and gain cross-issue mutation privileges immediately. The existing guard that blocks `permissions` changes (redirecting callers to `/api/agents/:id/permissions`) is not extended to cover `isRouter`, leaving it freely self-settable. An analogous block is needed here: detect `isRouter` in `req.body` and either reject with 422 or restrict the path to `board`-authenticated callers only — the same pattern the `permissions` field already follows. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
server/src/routes/agents.ts:2562-2565
**Privilege escalation: any agent can self-promote to router status**
`assertCanUpdateAgent` short-circuits for self-updates (`if (actorAgent.id === targetAgent.id) return;`), so any agent holding an API key can send `PATCH /api/agents/:id` with `{ isRouter: true }` against its own ID and gain cross-issue mutation privileges immediately. The existing guard that blocks `permissions` changes (redirecting callers to `/api/agents/:id/permissions`) is not extended to cover `isRouter`, leaving it freely self-settable.
An analogous block is needed here: detect `isRouter` in `req.body` and either reject with 422 or restrict the path to `board`-authenticated callers only — the same pattern the `permissions` field already follows.
Reviews (1): Last reviewed commit: "feat(agents+issues): expose isRouter via..." | Re-trigger Greptile
Thinking Path
What Changed
packages/shared/src/validators/agent.ts: addedisRouter: z.boolean().optional()tocreateAgentSchema;updateAgentSchemainherits it through.partial(), so bothPOST /api/agentsandPATCH /api/agents/:idnow accept and pass throughisRouterserver/src/routes/issues.ts: renamed activity-log detail key fromisRouter: true→routingCorrection: truein theissue.router_agent_cross_patchentry, matching the AC in FUL-2490Verification
Risks
Low risk. Both changes are additive or rename-only:
isRouterto the Zod schema only widens what the API accepts; no existing callers are affectedisRoutertoroutingCorrectionis a detail field inside a structured JSON blob — no downstream code parses this key todayModel Used
Checklist