MCP execution tools
Module: apps/mcp/src/tools/execution/ Scopes: tasks:read for get_next_actionable, tasks:write for the rest.
The agent loop
[ get_next_actionable ] ──▶ list of ready tasks + knowledge digests
│
▼
[ claim_task(taskId) ] ──▶ CAS UPDATE: state ready→claimed, lease 900s
│
▼
[ update_task_status(taskId, 'in_progress') ]
│ ↻ heartbeat extends lease (default heartbeat=true)
▼
[ complete_task(taskId, output, learnings) ]
│ ─ verification.kind=none → state=done
│ ─ verification.kind=manual → state=needs_review (human gate)
│ ─ verification.kind=github_pr → state=needs_review + BullMQ verify job
└─ learnings → knowledge_entries (source='task_completion') + task_knowledge edgeget_next_actionable
| Argument | Type | Notes |
|---|---|---|
limit | 1..20? | default 5 |
planId | uuid? | filter to a specific plan |
priorityLte | 0..1000? | inclusive upper bound (smaller = higher priority) |
Returns up to N state='ready' tasks for the agent's org, sorted by (priority ASC, created_at ASC). Each result is enriched with a knowledge digest (top-3 hybrid search hits, min score 0.5) and the repository row when the task is wired to one. Search failures degrade gracefully — the task is still returned with knowledge.references=[].
The :read scope makes this safe for read-only agents (UI introspection, dashboards).
claim_task
| Argument | Type | Notes |
|---|---|---|
taskId | uuid | required |
leaseSec | 60..3600? | default 900 |
Atomic CAS — racing agents can never both succeed (the UPDATE … WHERE state='ready' filter is the chokepoint). Idempotent on (taskId, agentId): re-claim by the same agent returns the current lease.
Task-29a integration: if isBlockedByReview(taskId) reports an open blocker comment the claim is rejected with task.not_actionable. Today the helper is a stub that always returns blocked: false; comments land in task 29a.
update_task_status
| Argument | Type | Notes | |
|---|---|---|---|
taskId | uuid | required | |
status | `'in_progress' \ | 'needs_review'` | required |
heartbeat | boolean? | default true (renews lease on in_progress) | |
reason | string? | required-ish for needs_review; defaults to a generic message | |
context | Record<string,unknown>? | merged into tasks.context JSONB | |
externalRef | string? | e.g. PR URL, commit SHA |
Only the claim holder can call this — non-holder → task.already_claimed. Transitions are enforced by the core state machine; an invalid combination (e.g. done → in_progress) surfaces as task.invariant_violated.
complete_task
| Argument | Type | Notes |
|---|---|---|
taskId | uuid | required |
output | Record<string,unknown>? | free-form result payload (rendered as pretty JSON in UI) |
learnings | Learning[]? | up to 20; each entry → knowledge row |
Dispatches on verificationContract.kind:
kind | next state | verification status | side effect |
|---|---|---|---|
none | done | skipped | — |
manual | needs_review | pending | UI shows approve gate (task 38) |
github_pr | needs_review | pending | enqueues verification job (task 32) |
gitlab_mr | needs_review | pending | enqueues verification job |
commands | needs_review | pending | enqueues verification job |
verificationEtaSec is a non-binding hint (60s for CI-bound kinds, 30s for commands, 0 for none, null for manual).
Each learning becomes a knowledge_entries row with source='task_completion', source_task_id=taskId. A task_knowledge edge of kind 'produced' is inserted, and an embedding job is enqueued (fire-and-forget). Learnings count against the knowledge_stored quota.
Quota touch-points
complete_taskcallscheckQuota(orgId, 'knowledge_stored')only when
learnings.length > 0, before the transaction. On success the counter is bumped after commit (best-effort — under-count is preferable to rolling back a successful business write).
claim_taskdoes not count toward quotas.