Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(agents+issues): expose isRouter via agent API and tag audit log with routingCorrection (FUL-2490)#6526

Open
gboyles2paperclipAI wants to merge 15 commits into
paperclipai:masterfrom
gboyles2paperclipAI:feat/ful-2489-is-router
Open

feat(agents+issues): expose isRouter via agent API and tag audit log with routingCorrection (FUL-2490)#6526
gboyles2paperclipAI wants to merge 15 commits into
paperclipai:masterfrom
gboyles2paperclipAI:feat/ful-2489-is-router

Conversation

@gboyles2paperclipAI
Copy link
Copy Markdown

Thinking Path

  • Paperclip orchestrates AI agents; agent permission flags govern what cross-issue mutations are allowed
  • FUL-2489 introduced the isRouter DB column and the assertRouterAgentPatchAllowed permission-check function in the issues PATCH route
  • Two ACs from FUL-2490 were not yet satisfied: (1) isRouter could not be set via the public agent API because the Zod validator stripped it, and (2) the audit log used isRouter: true as a detail key instead of the specified routingCorrection: true
  • Without (1), TODD (the router agent) could never have isRouter toggled on any agent via API — only via the pre-seeded migration row
  • This PR closes those two gaps with the smallest possible diffs

What Changed

  • packages/shared/src/validators/agent.ts: added isRouter: z.boolean().optional() to createAgentSchema; updateAgentSchema inherits it through .partial(), so both POST /api/agents and PATCH /api/agents/:id now accept and pass through isRouter
  • server/src/routes/issues.ts: renamed activity-log detail key from isRouter: trueroutingCorrection: true in the issue.router_agent_cross_patch entry, matching the AC in FUL-2490

Verification

# 1. Set isRouter on an agent via API (was previously silently stripped)
curl -s -X PATCH http://localhost:3100/api/agents/<id> \
  -H "Authorization: Bearer <board-key>" \
  -H "Content-Type: application/json" \
  -d '{"isRouter": true}' | jq '.isRouter'
# → true

# 2. Trigger a router-agent cross-patch and check activity log
# Look for action "issue.router_agent_cross_patch" with details.routingCorrection === true
curl -s "http://localhost:3100/api/companies/<id>/activity" | jq '.[] | select(.action=="issue.router_agent_cross_patch") | .details'
# → { routerAgentId: "...", assigneeAgentId: "...", routingCorrection: true, patchedFields: [...] }

Risks

Low risk. Both changes are additive or rename-only:

  • Adding isRouter to the Zod schema only widens what the API accepts; no existing callers are affected
  • Renaming the audit-log detail key from isRouter to routingCorrection is a detail field inside a structured JSON blob — no downstream code parses this key today

Model Used

  • Provider: Anthropic
  • Model ID: claude-sonnet-4-6
  • Capabilities: tool use, code execution, extended reasoning
  • Context: 200K tokens

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have run tests locally and they pass (pre-existing failures on branch are unrelated to this change)
  • I have added or updated tests where applicable (no new test surface introduced by these two lines)
  • If this change affects the UI, I have included before/after screenshots (no UI change)
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • I will address all Greptile and reviewer comments before requesting merge

- 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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 22, 2026

Greptile Summary

This PR closes two gaps from FUL-2490: it exposes isRouter in the agent Zod validator so the field is no longer stripped on POST/PATCH, and it renames the audit-log detail key from isRouter to routingCorrection in the issue.router_agent_cross_patch activity entry.

  • packages/shared/src/validators/agent.ts: adds isRouter: z.boolean().optional() to createAgentSchema; updateAgentSchema inherits it automatically via .partial().
  • server/src/routes/issues.ts: single-line rename of the activity-log detail key to routingCorrection: true, matching the FUL-2490 AC.
  • The PR also bundles several unrelated migrations and schema additions (heartbeat compaction, instance_retention_config, privileged_human_gate, new shared types) that landed on the same branch.

Confidence Score: 3/5

Not 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.

Security Review

  • Privilege escalation via self-update (server/src/routes/agents.ts): assertCanUpdateAgent allows any agent to update its own record without restriction. Adding isRouter to the accepted schema without a corresponding guard means any agent with an API key can send PATCH /api/agents/:id with { isRouter: true } and gain cross-issue mutation privileges. The permissions field has an analogous block (redirected to a dedicated endpoint); isRouter needs the same treatment.

Important Files Changed

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)

  1. server/src/routes/agents.ts, line 2562-2565 (link)

    P1 security 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.

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants