mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 01:59:24 +02:00
* feat(attribution): Phase 1 foundation — provenance schema + resolver (MUL-4302)
Human Attribution, Phase 1 (地基) first increment. Every agent run must be
traceable to exactly one accountable human AND record at which waterfall level
that human was resolved, so a NULL originator can be told apart from a genuine
'no human in the chain'.
- migration 149: add originator_source (waterfall label) + delegation/retry/
rerun/rule-version lineage + kind-tagged trigger evidence to agent_task_queue.
No FK, no cascade, no CHECK on the source enum (MUL-4302 §7); nullable ADD
COLUMNs = fast metadata-only change on the hot queue table.
- internal/attribution: the accountable-human vocabulary (Source, EvidenceKind,
TriggerKind) + pure, unit-tested classification rules (ClassifyComment/
ClassifyDirect). No DB, no authorization — provenance labeling only.
- service: attributionFor{IssueTask,TriggerComment} gather facts and delegate to
the pure classifier; the legacy originator resolvers now delegate here so
there is one source of truth. originator_user_id's VALUE is unchanged, so the
Composio-overlay and canInvokeAgent A2A authorization boundaries are
byte-for-byte preserved (MUL-4302 §1.3).
- enqueueIssueTask / enqueueMentionTask stamp originator_source + evidence;
CreateRetryTask carries the parent attribution forward and records
retry_of_task_id so retry and manual rerun stay separable (MUL-4302 §5).
Verified: go build ./..., go vet, gofmt clean; new attribution unit tests +
enqueue stamping integration test green; existing resolve_originator tests
unchanged.
Co-authored-by: multica-agent <github@multica.ai>
* feat(attribution): split accountable_user_id from originator, close enqueue bypasses (MUL-4302)
Phase 1, per Bohan's decision on the MUL-4302 thread: audit and authorization
answer different questions and get different columns.
- Migration 151 adds agent_task_queue.accountable_user_id (no FK, no cascade).
Authorization keeps reading ONLY originator_user_id (canInvokeAgent A2A gate,
Composio overlay); audit/UI/usage read accountable_user_id + source + evidence.
- Invariant (finalizeAttribution, single chokepoint + §11 tests): originator
non-null ⟹ accountable equals it. The two diverge only when originator is null
(autopilot / degraded fallback), which is the deferred rule_owner/owner_fallback
increment; this lands the column + mirror-write so that split has a home.
- Close the NULL-source enqueue bypasses Elon flagged: chat, quick-create,
deferred-fallback and run_only-autopilot now stamp originator_source + evidence
(+ accountable where a human exists). Autopilot stays unattributed until the
rule-version snapshot table lands, but is no longer a silent NULL-source row.
Retry inherits accountable_user_id like the rest of the attribution lineage.
- Fix assign/promote attribution (§4): a member who assigns/promotes an existing
issue is now the accountable human (and, by the invariant, originator) ahead of
the issue creator. Threaded as an OPTIONAL actor override, so comment/rerun/
autopilot paths keep today's resolution and create-with-assignee (creator ==
actor) is unchanged. The squad leader gate already judged the same member.
Also merges origin/main: renumbers the attribution migration 149→150 (main took
149 for issue_origin_agent_create) and folds agent_create into ClassifyDirect's
origin inheritance.
go build/vet/gofmt clean; attribution unit tests + service stamp/actor tests +
handler suite pass on a fresh DB migrated through 151.
Co-authored-by: multica-agent <github@multica.ai>
* docs(attribution): fix accountable NULL semantics + close chat/quick-create evidence boundary (MUL-4302)
Addresses Elon's 2nd-round review on PR #5150 (pre-merge doc/evidence items):
- Migration 151 no longer overclaims NULL. accountable_user_id is NULL not only
on pre-migration rows but on NEW rows whose audit source resolved no human yet
(run_only autopilot writes originator_source='unattributed' with NULL
accountable until rule_owner lands). Header + COMMENT ON COLUMN reworded so a
schema reader does not misjudge the invariant.
- Chat now uses the UNIFORM evidence pair (kind=chat, ref=chat_session_id), like
autopilot_run/issue_assignment, instead of relying only on the dedicated
chat_session_id column — new EvidenceChat kind. Added a service test asserting
chat stamps direct_human + chat evidence.
- Quick-create is documented as the ONE intentional no-antecedent-row path: no
comment/issue/session/run exists at enqueue time (the run creates the issue), so
trigger_evidence_kind/ref stay NULL while the human rides originator/accountable
and source is direct_human — not a NULL-source bypass.
No authorization behavior change. attribution + service + handler suites pass on a
DB migrated through 151.
Co-authored-by: multica-agent <github@multica.ai>
* chore(attribution): renumber migrations 150/151 → 157/158 after merging main (MUL-4302)
main's #5162 ("unblock release migrations") renumbered the chat migrations and
took 150 (agent_task_coalesced_comments) and 151 (chat_read_cursor), colliding
with this branch's attribution migrations. Renumber them above main's new highest
(156) so TestMigrationNumericPrefixesStayUniqueAfterLegacySet passes:
- 150_agent_task_attribution → 157_agent_task_attribution
- 151_agent_task_accountable_user → 158_agent_task_accountable_user
Fixed the internal "migration 150" references in 158's header to 157. Migrations
apply cleanly through 158 on a fresh DB; migration lint green.
Co-authored-by: multica-agent <github@multica.ai>
* feat(attribution): autopilot rule_owner — accountable = rule version publisher (MUL-4302)
Implements rule_owner (MUL-4302 §3.4), the first attribution source where the
accountable human diverges from the (NULL) authorization originator.
- Migration 159 adds the append-only autopilot_rule_version snapshot table (no FK,
no cascade); migration 160 adds its CONCURRENTLY lookup index.
- Write-on-publish: CreateAutopilot appends v1 (publisher = creator); UpdateAutopilot
appends a new version when a SUBSTANTIVE autopilot-row field changes (assignee /
status / execution_mode) — cosmetic edits (title/description/template) write none.
Both run inside the existing handler tx (atomic with the autopilot write).
- Dispatch resolution: both autopilot execution modes now resolve the active rule
version and stamp originator_source='rule_owner', accountable_user_id=publisher,
rule_version_id=<snapshot>, with originator_user_id left NULL (authorization
unchanged). run_only stamps CreateAutopilotTask directly; create_issue resolves in
attributionForIssueTask so both modes attribute identically. A missing version /
non-member publisher degrades to unattributed — never fabricates a human.
- finalizeAttribution now enforces the invariant ONE-WAY: it mirrors originator onto
accountable only when originator is valid, leaving an explicitly-set accountable
(rule_owner / future owner_fallback) intact when originator is NULL. Added
rule_version_id to CreateAgentTask so the create_issue path persists it too.
Also merges origin/main and renumbers this branch's attribution migrations
150/151 → 157/158 (main's #5162 took 150/151); rule_version table is 159/160.
Tests: attribution unit RuleOwner + one-way invariant table; service integration
tests proving an autopilot-origin issue stamps rule_owner + rule_version_id (and
degrades to unattributed with no version). Full service/attribution/handler/
migration suites pass on a DB migrated through 160; build/vet/gofmt clean.
Deferred (same PR): trigger-table republish (cron/webhook/event_filters) and
system-pause/archive versioning; owner_fallback + fail-closed; manual rerun.
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): manual autopilot trigger → direct_human to the triggering member (MUL-4302)
Elon's blocking finding: a member manually triggering an autopilot was attributed
rule_owner (accountable = rule publisher, originator NULL) like a schedule/webhook
run, so member B triggering member A's autopilot landed accountable=A and carried
no originator authorization context for the run. Per MUL-4302 §4 a manual "run now"
is a direct human action and must attribute direct_human to the triggering member.
- Thread the triggering member from TriggerAutopilot into dispatch: new
DispatchAutopilotManual carries actorUserID (resolved via resolveActor +
memberActorUserID, so only a member actor is a human; an A2A agent actor falls
back to rule_owner). DispatchAutopilot / DispatchAutopilotForPlan keep their
public signatures (pass an invalid actor); only the internal dispatchAutopilot /
dispatchCreateIssue / dispatchRunOnly gained the param, so the many existing
callers are untouched.
- run_only: dispatchRunOnly stamps direct_human (originator == accountable ==
actor, no rule_version) for a manual actor, else rule_owner. CreateAutopilotTask
gains an originator_user_id param for the manual case.
- create_issue: dispatchCreateIssue enqueues a manual trigger via the actor-carrying
*WithHandoff entry points; attributionForIssueTask's autopilot-origin rule_owner
branch is now guarded on !actorUserID.Valid, so a valid actor falls through to the
direct_human override. Both execution modes attribute identically.
- schedule / webhook keep rule_owner (no actor). Trigger-table + system-pause/archive
versioning remain the pre-merge follow-ups.
Tests: the run_only row assertion Elon asked for (schedule → rule_owner row on
CreateAutopilotTask), plus manual direct_human on BOTH modes (run_only and
create_issue), including a manual actor distinct from the rule publisher. Full
service/attribution/handler/migration/scheduler/cmd suites pass on a DB migrated
through 160; build/vet/gofmt clean.
Co-authored-by: multica-agent <github@multica.ai>
* feat(attribution): owner_fallback + fail-closed policy, and manual-rerun direct_human (MUL-4302)
Two of the three remaining Phase 1 items (trigger-table / system-pause versioning
is deferred — see PR description).
owner_fallback + fail-closed (§1/§3.5) — the never-null accountable guarantee:
- attribution.OwnerFallback degrades an UNATTRIBUTED result to owner_fallback:
accountable = agent owner, originator stays NULL (audit-only, authz untouched),
Source.Precise()==false. finalizeAttribution's one-way invariant already allows
accountable-set / originator-NULL divergence, so nothing else changes.
- Migration 161 adds workspace.attribution_fail_closed (default FALSE) + a lean
GetWorkspaceAttributionFailClosed read. (Also added the column to ListWorkspaces'
explicit column list so its row type stays db.Workspace.)
- applyAttributionFallback is applied at every enqueue boundary (issue, mention,
chat, quick-create, deferred-fallback, autopilot run_only): unattributed →
owner_fallback (agent owner) by default, or ErrAttributionFailClosed when the
workspace is fail-closed, which the caller surfaces to refuse the enqueue (the
run does not start). So no run is left without an accountable human, and a
compliance workspace can block unattributable runs instead.
manual rerun (§5) — a rerun is a NEW direct_human trigger to the rerunning member:
- RerunIssue threads the acting member (resolved in the handler via resolveActor)
down to enqueueRerunTask, and attributionForIssueTask is now actor-first so the
actor wins over an INHERITED trigger comment (a rerun keeps the comment for the
daemon's prompt context but must attribute to whoever clicked rerun, not the
original comment's human).
- rerun_of_task_id lineage is recorded via a targeted SetAgentTaskRerunOf update on
the rerun path only (keeping the shared CreateAgentTask insert untouched), so
system retry (retry_of_task_id) and human rerun stay separable in reporting.
Tests: OwnerFallback unit test; owner_fallback + fail-closed-refusal + manual-rerun
(direct_human + rerun_of_task_id) service tests; the prior "degrades to unattributed"
test updated to owner_fallback. Full service/attribution/handler/migration/scheduler/
cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): close fail-open holes + move rerun_of_task_id into creation snapshot (MUL-4302)
Addresses Elon's two must-fixes on PR #5150.
1. accountable-never-null / fail-closed had fail-open holes. applyAttributionFallback
now, for an UNATTRIBUTED run, refuses the enqueue (ErrAttributionFailClosed) in
THREE cases instead of silently degrading to a runnable NULL-accountable task:
- workspace policy read fails (or no workspace) → fail closed; we cannot confirm
fallback is permitted, so we don't run an unattributable task on a DB hiccup.
(Only the rare unattributed path pays this; precise runs never read the policy.)
- workspace is fail-closed → refuse (unchanged).
- owner_fallback has no valid agent owner → refuse rather than enqueue a task with
a NULL accountable_user_id.
ErrAttributionFailClosed's doc now covers all three "cannot guarantee an
accountable human" refusals. Added missing-owner / policy-read-failure /
precise-passthrough tests.
2. manual rerun rerun_of_task_id was a post-notify UPDATE (race: the queued event /
daemon claim could see rerun_of_task_id = NULL, and a failed update degraded the
run to a plain direct_human). It now rides the CreateAgentTask insert — threaded
through enqueueIssueTask / enqueueMentionTask as a creation param (like
retry_of_task_id) so it is written in the same statement before the daemon is
notified. Removed the SetAgentTaskRerunOf follow-up query.
Also merges origin/main (unrelated CLI fix #5167, no conflict). Full service /
attribution / handler / migration / scheduler / cmd suites pass on a DB migrated
through 161; build / vet / gofmt clean.
Co-authored-by: multica-agent <github@multica.ai>
* feat(attribution): rule_owner versioning on trigger edits + system-pause/archive (MUL-4302)
The final remaining Phase 1 item: substantive publishes beyond the autopilot row now
republish the rule version, so a run's rule_owner accountable follows whoever last
changed what the rule does.
- Extracted the config-summary + insert into service.RecordAutopilotRuleVersion so
the handler and the (different-package) failure monitor share one writer; the
handler's recordAutopilotRuleVersion is now a thin wrapper.
- Trigger edits: UpdateAutopilotTrigger and DeleteAutopilotTrigger republish the rule
version with the acting member as publisher, ATOMICALLY (tx-wrapped mutation +
version write, mirroring CreateAutopilot/UpdateAutopilot). CreateAutopilotTrigger
republishes best-effort — the webhook path mints its token with a retry loop that
cannot share one tx, and a create is usually initial setup already covered by v1;
a failed write there is benign (active version stays the current publisher, the new
trigger fires under it, no immediate daemon claim rides it).
- Archive (DeleteAutopilot) republishes (member, status=archived), tx-wrapped.
- System auto-pause (failure monitor) republishes with a 'system' publisher,
best-effort — a background sweep to a non-dispatching state (a paused autopilot
never dispatches; a later member resume supersedes).
- RotateWebhookToken / SetSigningSecret deliberately do NOT version: they rotate
credentials, not the rule's behavior (not §3.4 substantive).
Semantics: a system-published (no-member) active version degrades dispatch to
unattributed → owner_fallback, never fabricating a human.
Tests: republish-reattributes (member A → member B supersedes → dispatch resolves to
B; system publisher → unattributed). Full service/attribution/handler/migration/
scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.
Also merges origin/main (unrelated frontend feature #5074).
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): make trigger-create rule-version republish atomic (MUL-4302)
Addresses Elon's final Phase 1 blocking finding: CreateAutopilotTrigger recorded the
rule-version republish best-effort AFTER the trigger insert. If member B added a
schedule/webhook trigger to member A's autopilot and the version write failed, future
schedule/webhook dispatches would keep attributing to A — violating the rule_owner
invariant that the last member to substantively change the rule owns future runs
("no immediate daemon claim" doesn't save it, since the miss surfaces at the LATER
trigger firing).
Both create paths now write the version in the SAME tx as the trigger INSERT:
- schedule create: wrap CreateAutopilotTrigger + recordAutopilotRuleVersion in one tx.
- webhook create: each mint-with-retry attempt runs in its own tx (insert + version
commit together; a token collision rolls that attempt back and retries with a fresh
token; a version-write failure rolls the trigger back). Passes ap + the acting
member id into the helper.
- removed the best-effort recordTriggerRuleVersionBestEffort helper (and the now-unused
slog import).
Test: TestCreateTrigger_RepublishesRuleVersionAtomically drives both create paths
through the handler and asserts a rule version is published by the acting member.
Existing webhook/trigger/archive handler tests still pass. Also merges origin/main
(unrelated avatar feature #5074). Full service/attribution/handler/migration/
scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.
Co-authored-by: multica-agent <github@multica.ai>
* feat(attribution): Phase 2.1 — surface run attribution on the task API (MUL-4302 §9)
First Phase 2 (visibility) increment: the agent-task API now returns the resolved
accountable-human provenance so the UI can render an "on behalf of" badge.
- AgentTaskResponse gains an `attribution` object: source label (never blank —
pre-migration NULL renders "unattributed") + `precise` flag (false for the degraded
owner_fallback / backfill / unattributed sources), the initiator (accountable) and
originator (authorization) user refs, the evidence {kind, ref_id} pointer, and the
rule_version / delegated / retry / rerun lineage ids.
- The label + evidence + raw ids are built in the PURE taskToResponse (no DB), so
every task response carries them. Names are hydrated separately, only on the
user-facing surfaces (ListAgentTasks, ListWorkspaceAgentTaskSnapshot, RerunIssue,
CancelTaskByUser) — daemon-claim paths stay lean.
- Hydration resolves initiator/originator from the GLOBAL user table (departed-member
safe) via a new batch GetUsersByIDs query (no N+1); best-effort, so a lookup hiccup
leaves the raw ids intact.
Tests: pure taskAttributionBase (direct_human / rule_owner NULL-originator /
owner_fallback degraded / pre-migration→unattributed) + DB hydration (fills known
ref, leaves unknown id un-filled, skips nil). Full handler/service/attribution/
migration/scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt
clean. The field is additive — the frontend's parseWithFallback ignores unknown keys,
so nothing breaks until the UI increment consumes it.
Also merges origin/main (unrelated editor feature #5090).
Remaining Phase 2 (next increments, same PR): frontend zod schema + "on behalf of"
badge + evidence-chain jump; append-only correction events (write + display).
Co-authored-by: multica-agent <github@multica.ai>
* feat(attribution): Phase 2.2 — on-behalf-of badge in the execution log (MUL-4302 §9)
Surface the accountable human on every agent run row:
- AttributionBadge composes Badge + ActorAvatar, shows "on behalf of <member>"
with the resolution source as a tooltip; degraded (non-precise) attribution
gets a warning tone, and an unresolved initiator renders an explicit
"no responsible member" chip.
- Wire the badge into both active and past rows of the execution log.
- Mirror the attribution shape into AgentTaskResponseSchema (defensive, .loose())
so the cancel-task path carries it through zod; add parse tests.
- Export TaskAttribution/AttributionUser/TaskEvidence from @multica/core/types
and add the attribution block to all four issues.json locales.
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): hydrate initiator names on issue-facing task endpoints + bound the badge (MUL-4302 §9)
Address Elon's PR #5150 review:
- ListTasksByIssue (the execution-log data source), GetActiveTaskForIssue and the
issue-scoped CancelTask now call hydrateTaskAttributions, so the "on behalf of
<member>" badge shows the real member name on issue detail instead of falling
back to "someone". Mirrors the existing ListAgentTasks / snapshot behavior.
- AttributionBadge: cap width (max-w-40, min-w-0) and truncate the name span so a
long name / narrow right column can't squeeze out trigger/status/actions; keep
the avatar shrink-0.
- Add a handler test asserting the issue task list returns a hydrated
attribution.initiator.name.
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): use semantic AvatarSize 'xs' for the badge avatar
main refactored ActorAvatar.size from a raw pixel number to the semantic
AvatarSize union (packages/ui/lib/avatar-size). Switch the on-behalf-of badge
avatar from size={14} to size="xs" (16px) after merging main.
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): stage-cascade falls back to parent-issue provenance, not agent owner (MUL-4302)
When closing the last sub-issue in a Stage wakes the parent's assignee agent, the
run was enqueued via a system-authored child-done comment with no actor, which the
resolver classified as unattributed and then degraded to owner_fallback (the agent's
own owner). That is the wrong accountable human: the woken run should be accountable
to whoever caused the parent issue to exist.
attributionForIssueTask now detects a system-authored trigger comment and falls
through to the parent issue's own provenance — the same creator / agent_create-origin
/ autopilot-origin chain a direct enqueue resolves (so an agent-decomposed parent
attributes via delegation to the human who drove it; a member-created parent to that
member; an autopilot parent to the rule publisher). owner_fallback is now only the
last resort when the parent provenance itself has no human.
- Extract attributionFromComment so attributionForIssueTask can inspect author_type
without a second GetComment; authorization resolution stays byte-identical.
- Add a DB-backed test asserting a system child-done comment resolves to the parent
issue's origin human (delegation), not owner_fallback.
Co-authored-by: multica-agent <github@multica.ai>
* feat(attribution): autopilot runs attribute to the firing trigger's creator (MUL-4302)
Per Bohan: an autopilot schedule/webhook run should be accountable to the human
who created the SPECIFIC trigger that fired it, not the rule publisher. (Manual
triggers already attribute to the invoking member via direct_human — unchanged.)
- Migration 162: add autopilot_trigger.created_by_type/created_by_id (nullable, no
FK/cascade). Capture the creating member at both trigger-create sites (schedule +
webhook).
- New precise source trigger_owner: originator stays NULL (an autonomous fire
carries no human authorization — same authz-safe divergence as rule_owner),
accountable = the trigger's member creator.
- triggerOwnerAttribution resolves run.trigger_id → creator; wired into run_only
dispatch and the create_issue path (bridging issue → active run → trigger_id).
Legacy triggers with no recorded creator, and agent-created triggers, degrade to
rule_owner then owner_fallback — nothing regresses.
- Frontend: trigger_owner source label in all four locales + badge switch case.
- Tests: attribution TriggerOwner unit + Precise/invariant; DB-backed resolver
tests (member creator → trigger_owner; creatorless → rule_owner fallback).
Co-authored-by: multica-agent <github@multica.ai>
* chore(attribution): re-trigger CI (dropped synchronize event on 249090260)
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): mirror accountable_user_id on comment-coalescing merge (MUL-4302)
The one-way invariant is 'originator_user_id IS NOT NULL ⟹ accountable_user_id =
originator_user_id', enforced at finalizeAttribution for enqueues and preserved by
the retry-clone (copies both columns). But MergeCommentIntoPendingTask (main #5192)
re-stamps originator_user_id to the newly-coalesced comment's human WITHOUT touching
accountable_user_id — so folding member B's comment into member A's queued task left
originator=B / accountable=A, violating the invariant. Re-stamp accountable to mirror
the new originator (same thing finalizeAttribution does). Add a DB-backed regression
test.
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): Elon's 3 must-fixes + DB cross-column invariant CHECK (MUL-4302)
Bohan approved Elon's plan; this closes the three attribution boundaries he flagged
and locks the one-way invariant at the DB.
1. Delegation now inherits the parent's ACCOUNTABLE, not just its originator. An
autopilot-rooted chain (parent originator NULL, accountable = trigger creator)
@mentioning an agent / creating a sub-issue used to drop to unattributed →
owner_fallback and fail-closed workspaces wrongly rejected the fan-out. Added
ParentAccountable/OriginAccountable to CommentFacts/DirectFacts; ClassifyComment/
ClassifyDirect copy accountable down (source=delegation, precise, originator NULL)
so the chain root stays stable at any depth (§3.2).
2. The direct-chat send path (SendDirectChatMessage, MUL-4351) wrote only
originator_user_id — no accountable/source/evidence, a NULL-source bypass. It now
stamps the full direct_human attribution like EnqueueChatTask.
3. Comment-coalescing merge re-attribution is now ATOMIC: MergeCommentIntoPendingTask
re-stamps the whole snapshot (person columns + source + delegation lineage + rule
version + evidence) of the new comment, not just the two person fields, so a merged
run never shows B accountable while pointing at A's stale source/evidence.
4. Migration 169: NOT VALID CHECK (originator_user_id IS NULL OR (accountable_user_id
IS NOT NULL AND accountable_user_id = originator_user_id)). Enforces the invariant
on every new write (the class of bug #5192 introduced); historical rows not blocked,
VALIDATE after Phase 3 backfill. Updated test fixtures that seeded originator-only
rows to also set accountable.
Merged latest main (renumbered attribution migrations 163–168 after main took 161/162;
merged the retry-clone chat_input_task_id + attribution columns). Verified: go build/
vet, attribution/service/handler/cmd-server tests on a migrated DB, frontend
typecheck/lint/tests.
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): trigger responsibility transfers to effective publisher; fail-closed comment merge
Elon final-review must-fix 1 (MUL-4302): trigger_owner now resolves to the
member CURRENTLY responsible for the firing trigger's effective config, not the
fixed creator. Per-trigger published_by on autopilot_trigger, seeded to the
creator and re-stamped to the editor on a substantive edit — a trigger-scoped
edit bumps only that row (UpdateAutopilotTrigger), an autopilot-level edit bumps
all its triggers (UpdateAutopilot). Editing one trigger never reassigns another.
Adds real dispatchRunOnly transfer test + resolver-level isolation test.
Must-fix 3: AttributionForMergedComment reuses applyAttributionFallback and
returns ErrAttributionFailClosed; the merge caller refuses on fail-closed,
keeping the queued task's original precise snapshot instead of degrading it to
owner_fallback. Adds regression test.
Renumbered attribution migrations to 166-172 after merging main.
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): make migration 172 invariant CHECK upgrade-safe (Option A)
Elon final-review must-fix 2 (MUL-4302), rollout chosen by Bohan. The NOT VALID
CHECK still checks a pre-existing row on any later UPDATE (even one not touching
attribution columns), so cross-deployment stale queued/running tasks (originator
set, accountable NULL from before migration 167) would fail on their next
claim/complete/cancel. Exempt exactly those legacy rows via 'originator_source
IS NULL' — that column was added in 166 with no backfill, so it is NULL only on
pre-migration rows and non-NULL on every attribution-aware write. New writes
stay fully enforced; the #5192 bypass class (source always set) is unaffected.
Phase 3 backfills legacy rows then drops+re-adds the strict form + VALIDATE.
Adds TestAttributionInvariantCheck_ExemptsLegacyRows (legacy row survives a
status UPDATE) and updates the reject-bypass test to the enforced regime.
Co-authored-by: multica-agent <github@multica.ai>
* fix(attribution): pin substantive/cosmetic edit boundary for trigger transfer
Elon re-review must-fix 1: an autopilot-level edit only transfers trigger_owner
responsibility when it changes what the automation instructs or who/whether it
runs. autopilotRuleSubstantiveChange now includes description (the run Prompt)
and issue_title_template; title and project_id stay cosmetic/routing.
UpdateAutopilotTrigger no longer transfers on every PATCH — it compares the
persisted before/after and transfers only on a real cron/timezone/enabled/
event_filters change, not a label-only or no-op PATCH. Adds real handler tests
(prompt->all, title->none, cron->one+isolation, label->none, no-op->none).
Must-fix 3: real merge-path regression (TestMergeCommentIntoPendingTask_
FailClosedKeepsOriginalSnapshot) drives mergeCommentIntoPendingTask and asserts
a fail-closed workspace preserves the queued task's full snapshot; fail-open
control completes the owner_fallback merge.
Docs: migration 172 comment reworded to legacy-writer/unbackfilled-lineage
semantics (source NULL is not strictly pre-migration); PR description synced.
Migration renumber vs latest main (must-fix 2) deferred to pre-launch per Bohan.
Co-authored-by: multica-agent <github@multica.ai>
* chore(attribution): renumber migrations to 167-173 after syncing main
Merged latest main (which took 166_project_dates) and shifted the attribution
migration series off the 166 collision: 166->167 agent_task_attribution,
167->168 accountable_user, 168->169 rule_version, 169->170 rule_version_index,
170->171 fail_closed, 171->172 trigger_publisher, 172->173 invariant_check.
Updated the internal cross-references in the migration comments accordingly.
Fixes TestMigrationNumericPrefixesStayUniqueAfterLegacySet on the merge tree.
Co-authored-by: multica-agent <github@multica.ai>
* feat(admission): unify dispatch outcome + close rerun/chat/autopilot invoke holes (MUL-4525)
P0 first increment toward a platform-wide execution-admission contract so a
user who names an execution target always gets a definite result and never a
silent no-op, and so blocked targets are reported without leaking private-agent
details.
Backend:
- New shared contract (handler/admission.go): DispatchOutcome / DispatchStatus
(queued/coalesced/deferred/blocked) + stable, enumeration-safe
DispatchReasonCode set, plus writeDispatchBlocked() whose legacy `error`
string never reveals target existence.
- Rerun (task.go / task_lifecycle.go): re-validate the operator can invoke the
RESOLVED target agent (historical agent for a task_id rerun) before any
cancel/enqueue; blocked returns a structured 403 and mutates nothing
(ErrRerunInvokeNotAllowed).
- Chat send (chat.go): re-run canInvokeAgent on every send, not just the softer
canAccessPrivateAgent view gate; a revoked permission blocks before the
message/attachments/task persist.
- Autopilot manual "run now" (service/autopilot.go): admission now keys on the
current CLICKER, not the autopilot creator — clicker admission and clicker
attribution no longer fork. Automation (schedule/webhook) still falls back to
the creator gate. Added reason_code to the run response for the UI.
Frontend:
- triggerAutopilot response is schema-parsed; handleRunNow branches on run
status and shows a localized, reason_code-based warning for skipped/failed
instead of a false-success toast.
- Chat send and rerun surface the structured 403 reason_code as localized
toasts (dispatchReasonCode helper) instead of a generic failure.
- Additive fields only; older clients keep working. i18n added to all 4 locales.
Tests: rerun fail-before-mutation gate, autopilot clicker-vs-creator fork
(service + handler), reason-code classification, and a malformed-response
schema test. Backend handler+service suites and FE typecheck/lint green.
Co-authored-by: multica-agent <github@multica.ai>
* test(cmd/server): thread nil invoke gate through RerunIssue call sites (MUL-4525)
Co-authored-by: multica-agent <github@multica.ai>
* fix(admission): typed reason codes + run-now whitelist + real security tests (MUL-4525)
Addresses Elon's review of the P0 first increment (must-fix 1–3).
1. Run now no longer has a false-success branch. handleRunNow now classifies on
a whitelist via a pure runNowToastKind(): only issue_created/running →
success; skipped → warning; failed and any unknown/future status → error. Add
run-now-toast unit test over all five status classes plus reason-code → key
mapping.
2. reason_code is a typed value decided at the admission source, not
reverse-engineered from English failure text. New leaf package
internal/dispatch holds the canonical ReasonCode enum (shared by handler +
service so they can never drift). shouldSkipDispatch / errDispatchSkipped /
the fail path now carry a typed code through DispatchAutopilotManual straight
into the response; the substring classifier is deleted. Fixes the two missed
branches: attribution fail-closed → attribution_blocked (typed errors.Is),
"agent has no runtime bound" → runtime_offline. Regression tests for both.
3. Security acceptance tests exercise the REAL handlers, not injected callbacks:
- Chat: create session while invokable → revoke invoke (flip to private, keep
owner-view) → send returns 403 + reason_code with zero chat_message / task
writes.
- Rerun: private historical agent through RerunIssue + canInvokeAgent — a
non-invoking workspace owner is refused 403 + reason_code and mutates
nothing (fail-before-mutation); the agent owner is allowed 202.
No migration; reason_code is a decision-time value only the manual "run now"
response carries. Additive on the wire. Backend build/vet/handler+service
suites and FE typecheck/lint/vitest green. (Migration-prefix collision with main
remains the deferred pre-merge renumber.)
Co-authored-by: multica-agent <github@multica.ai>
* test(admission): make must-fix 3 acceptance tests prove the invariant (MUL-4525)
Addresses Elon's round-3 narrow review — the two security tests were not yet
falsifiable against the bugs they must catch.
- Rerun: after enqueuing the historical task, reassign the issue to a SECOND
agent that the denied user CAN invoke. Now the current assignee and the
task_id agent differ, so a rerun that wrongly validated the current assignee
would let the denied user through — the 403 proves the gate is keyed on the
historical private agent. The allow case now asserts the reran task's agent_id
is the historical agent, not the current assignee.
- Chat: the blocked send now carries a valid, still-unbound attachment. After
the 403 the test asserts the attachment's chat_session_id and chat_message_id
are both still NULL, guarding against anyone moving attachment binding ahead
of the invoke gate.
Test-only. Full internal/handler Go suite green.
Co-authored-by: multica-agent <github@multica.ai>
* feat(comments): surface blocked @mention trigger_outcomes instead of silent no-op (MUL-4525 §2)
A comment that @mentions an agent/squad the author cannot invoke used to save
with zero feedback — the user assumed a bug. Now the explicit-mention path
reports a per-target outcome on both preview and create/edit.
Backend (server/internal/handler/comment.go):
- resolveMentionedAgentCommentTriggers collects blocked outcomes instead of a
silent `continue`. The invoke gate is evaluated BEFORE any archived/runtime
state is read, so a caller who cannot invoke a private target only ever sees
the generic invocation_not_allowed and can never enumerate its existence.
- enqueueCommentAgentTriggers returns queued/coalesced/deferred/blocked per
explicit mention; enqueue errors are typed (attribution_blocked via
errors.Is), not swallowed. Implicit routing (assignee/thread/conversation)
carries no outcome — the user never named those targets.
- trigger-preview returns `blocked[]`; create/edit return additive
`trigger_outcomes[]`. One blocked mention never fails the comment.
Frontend:
- Composer shows a warning chip for blocked mentions before sending; after
sending, a "posted, but N not triggered" toast (blocked-only; coalesced/
deferred are success-shaped). Additive schema + defensive parse; i18n ×4.
Tests: handler partial-success + enumeration-safety acceptance tests; core
outcome-parse + preview-schema tests; hook/parity updated. Backend
handler+service suites and FE typecheck/lint/vitest green.
Co-authored-by: multica-agent <github@multica.ai>
* i18n(admission): clearer, consistent blocked-trigger copy (MUL-4525)
Reword the awkward "you are not allowed to run this autopilot's assignee" and
polish all MUL-4525 blocked/partial copy across en/zh-Hans/ja/ko:
- Unify on "you don't have permission to use this <agent|target>" (zh: 没有…的
使用权限) for autopilot Run now, comment mentions, rerun, and chat send —
replacing the various "not allowed to run/trigger" phrasings.
- Align zh to the glossary term 智能体 (was mixed "Agent"), matching the
surrounding UI voice (e.g. agent_link_no_access).
- Drop jargon: "blocked by an admission policy"/"被准入策略拦截" → "the run was
blocked"/"本次运行已被拦截"; "attributed"/"归因" → plainer wording.
- Comment copy counts "mentions" (was "targets") for consistency with the chip.
Backend dispatchBlockedFallbackMessage (old-client English fallback) reworded to
match; its enumeration-safety test assertion updated. Copy-only — no key/logic
changes; parity + typecheck green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): one outcome per explicit mention + FE success whitelist (MUL-4525)
Addresses Elon's round-2 review of the §2 comment trigger_outcomes.
1. Separate execution dedup from per-target outcomes. resolveMentionedAgent-
CommentTriggers now returns triggers (deduped by executing agent) AND one
commentMentionTarget per EXPLICIT mention. enqueueCommentAgentTriggers returns
a per-executing-agent result map; commentTriggerOutcomes fans each agent's
status to every target that resolved to it. So @Agent A + @Squad S(leader=A)
coalesces to ONE task but yields TWO outcomes. The squad-leader self-suppress
branch now returns a definite `deferred` outcome instead of no result. New
CreateComment test asserts 1 task, 2 outcomes.
2. Frontend no longer treats an unknown status as success. unhandledComment-
TriggerOutcomes whitelists queued/coalesced/deferred as handled; blocked and
any unknown/future/empty status warn (mirrors the Run now whitelist). The
preview schema's `blocked` now drops malformed entries INDIVIDUALLY instead of
z.array(...).catch([]) discarding the whole set. Regression tests for the
unknown status and the per-item drop.
Backend handler suite + go vet, FE typecheck/lint/vitest green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): honest role + status in trigger_outcomes fan-out (MUL-4525)
Addresses Elon's round-3 review of the §2 fan-out.
1. Execution merge preserves the squad-leader role instead of first-mention-
wins. The dedup now UPGRADES an already-added plain @agent trigger to a
@squad-leader trigger for the same agent, so @Agent A + @Squad S(leader=A)
always runs as a leader task (is_leader_task + squad_id=S) regardless of
mention order — the daemon still injects S's briefing. When two DIFFERENT
squads share one leader, the single run carries one squad's context and the
other squad is reported `coalesced` (folded), never a second `queued`. The
enqueue result now records the executed squad so the fan-out can tell them
apart. Tests assert the task role in both orders and the two-squad split.
2. Squad-leader self-suppression no longer fakes success. The self-trigger
guard keys on the latest task ROLE with no status filter, so a long-completed
task also suppresses; reporting `deferred/already_active` when nothing is
active was a false success. The branch now reports `deferred` only when a
real non-terminal task is active (its reconcile covers the comment), else a
non-success `blocked` + new `already_handled` reason. Fixed the reversed
helper-semantics comment. New handler test covers the completed-task branch.
Backend handler+service suites and go vet green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): fail-closed active-task check, never fake deferred (MUL-4525)
Elon round-4 must-fix: hasActiveTaskForIssueAndAgent swallowed DB errors by
returning true, so both call sites could turn a query failure into a
success-shaped `deferred/already_active` — a silent false success, exactly what
this issue forbids for admission-query failures.
- hasActiveTaskForIssueAndAgent now returns (bool, error).
- Two pure decision helpers govern the branches, fail closed on error:
- decidePostMergeMiss: on query error, do NOT enqueue a fresh task (duplicate
concurrent-run risk) AND report non-success blocked/internal_error; a
confirmed active task defers; a confirmed-none enqueues fresh.
- decideSuppressedLeaderOutcome: on query error, blocked/internal_error; a
confirmed active run defers; else self_trigger_suppressed. Never a fabricated
deferred.
- Renamed reason already_handled -> self_trigger_suppressed (Elon non-blocking
note): the old name implied the new comment was already processed, but the
real meaning is a suppressed self-trigger.
Deterministic unit tests cover the query-failure branch at both call sites
(no fresh enqueue, non-success outcome) — a real DB fault can't be forced
through valid handler inputs, and the decision is what governs the behavior.
Backend handler+service suites and go vet green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): honest merge outcome — refused merge is blocked, not fake coalesced (MUL-4525)
A pending-task merge previously reported success (coalesced) even when it
was refused or failed: attribution fail-closed and unknown DB errors both
returned handled=true, so the caller recorded coalesced for a merge that
never happened. mergeCommentIntoPendingTask now returns a distinguishable
commentMergeResult; commentMergeTerminalOutcome maps a real merge to
coalesced, a fail-closed refusal to blocked/attribution_blocked, and any
other failure to blocked/internal_error. Only "no queued task to fold"
falls through to the active-task decision. Adds pure coverage of the
mapping plus a fail-closed regression asserting the non-success outcome
and unchanged task count.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): name blocked @mentions in preview + toast instead of a vague count (MUL-4525)
The blocked-trigger preview showed a red "1 mention won't trigger" with no
name, and the post-send toast said "but 1 mention wasn't triggered" — the
user can't tell which target or why. Now each blocked mention renders its own
chip named from the mention markup the user typed ("Go · No permission"),
with an error indicator and a short reason; the toast names the single target
too. The wire outcome still omits the target name (enumeration-safety) — the
label comes from the user's own draft, so nothing new is disclosed.
Shares a blocked-trigger-copy module (long + short reason labels) between the
chip and the toast, and a pure mentionLabelsByTarget/parseMentions helper in
core (fresh regex per call — a shared global leaked lastIndex). Adds core +
chip tests; drops the now-unused trigger_blocked_count keys across locales.
Co-authored-by: multica-agent <github@multica.ai>
* feat(attribution): accountable-member avatar on agent task rows + transcript header (MUL-4302)
Surface who each agent run is on behalf of where runs are actually
browsed:
- Agent detail activity tab: an avatar-only AttributionBadge on every
task row's meta line (Now + Recent work), tooltip carries the name +
resolution source.
- Execution-record (transcript) dialog header: the full on-behalf-of
badge next to the status pill.
Adds a compact variant="avatar" mode to AttributionBadge, reusing its
source-label mapping and degraded-attribution tone. Renders nothing when
a run has no resolved accountable member.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
3829 lines
150 KiB
Go
3829 lines
150 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/multica-ai/multica/server/internal/analytics"
|
||
"github.com/multica-ai/multica/server/internal/auth"
|
||
"github.com/multica-ai/multica/server/internal/daemonws"
|
||
"github.com/multica-ai/multica/server/internal/integrations/slack"
|
||
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
||
"github.com/multica-ai/multica/server/internal/middleware"
|
||
"github.com/multica-ai/multica/server/internal/runtimeapps"
|
||
"github.com/multica-ai/multica/server/internal/service"
|
||
"github.com/multica-ai/multica/server/internal/util"
|
||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||
"github.com/multica-ai/multica/server/pkg/redact"
|
||
)
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Daemon workspace ownership helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// requireDaemonWorkspaceAccess verifies the caller has access to the given workspace.
|
||
// For daemon tokens (mdt_), compares the token's workspace ID directly.
|
||
// For PAT/JWT fallback, verifies user membership in the workspace.
|
||
func (h *Handler) requireDaemonWorkspaceAccess(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
|
||
if workspaceID == "" {
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
return false
|
||
}
|
||
|
||
// Daemon token: workspace must match.
|
||
if daemonWsID := middleware.DaemonWorkspaceIDFromContext(r.Context()); daemonWsID != "" {
|
||
if daemonWsID != workspaceID {
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// PAT/JWT fallback: check membership cache before hitting DB.
|
||
userID := requestUserID(r)
|
||
if userID != "" {
|
||
if h.MembershipCache.Get(r.Context(), userID, workspaceID) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
_, ok := h.requireWorkspaceMember(w, r, workspaceID, "not found")
|
||
if ok && userID != "" {
|
||
h.MembershipCache.Set(r.Context(), userID, workspaceID)
|
||
}
|
||
return ok
|
||
}
|
||
|
||
// requireDaemonRuntimeAccess looks up a runtime and verifies the caller owns its workspace.
|
||
//
|
||
// Only pgx.ErrNoRows is treated as a real "runtime gone" 404 — the daemon uses
|
||
// that response to drop the stale runtime from its in-memory map and re-register,
|
||
// so collapsing transient DB errors into the same 404 would force the daemon to
|
||
// self-cleanup on a hiccup. Other DB errors become 500.
|
||
func (h *Handler) requireDaemonRuntimeAccess(w http.ResponseWriter, r *http.Request, runtimeID string) (db.AgentRuntime, bool) {
|
||
runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
|
||
if !ok {
|
||
return db.AgentRuntime{}, false
|
||
}
|
||
rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
|
||
if err != nil {
|
||
if isNotFound(err) {
|
||
writeError(w, http.StatusNotFound, "runtime not found")
|
||
return db.AgentRuntime{}, false
|
||
}
|
||
slog.Warn("get agent runtime failed", "runtime_id", runtimeID, "error", err)
|
||
writeError(w, http.StatusInternalServerError, "failed to load runtime")
|
||
return db.AgentRuntime{}, false
|
||
}
|
||
if !h.requireDaemonWorkspaceAccess(w, r, uuidToString(rt.WorkspaceID)) {
|
||
return db.AgentRuntime{}, false
|
||
}
|
||
return rt, true
|
||
}
|
||
|
||
// requireDaemonTaskAccess looks up a task and verifies the caller owns its workspace.
|
||
func (h *Handler) requireDaemonTaskAccess(w http.ResponseWriter, r *http.Request, taskID string) (db.AgentTaskQueue, bool) {
|
||
task, _, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||
return task, ok
|
||
}
|
||
|
||
// requireDaemonTaskAccessWithWorkspace is the workspace-aware variant of
|
||
// requireDaemonTaskAccess. It returns the resolved workspace ID alongside
|
||
// the task row so callers that need to forward workspace_id into
|
||
// taskToResponse (powering RelativeWorkDir) don't have to repeat the
|
||
// ResolveTaskWorkspaceID lookup. The two helpers share their entire
|
||
// implementation; the simpler one is preserved for ergonomic call sites
|
||
// that genuinely don't need workspace_id.
|
||
func (h *Handler) requireDaemonTaskAccessWithWorkspace(w http.ResponseWriter, r *http.Request, taskID string) (db.AgentTaskQueue, string, bool) {
|
||
taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task_id")
|
||
if !ok {
|
||
return db.AgentTaskQueue{}, "", false
|
||
}
|
||
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
||
if err != nil {
|
||
// Only treat pgx.ErrNoRows as a real "task gone" signal — daemon
|
||
// uses this 404 to interrupt the running agent, so a transient DB
|
||
// error must not be reported as a deletion.
|
||
if isNotFound(err) {
|
||
writeError(w, http.StatusNotFound, "task not found")
|
||
return db.AgentTaskQueue{}, "", false
|
||
}
|
||
slog.Warn("get agent task failed", "task_id", taskID, "error", err)
|
||
writeError(w, http.StatusInternalServerError, "failed to load task")
|
||
return db.AgentTaskQueue{}, "", false
|
||
}
|
||
|
||
wsID := h.TaskService.ResolveTaskWorkspaceID(r.Context(), task)
|
||
if wsID == "" {
|
||
writeError(w, http.StatusNotFound, "task not found")
|
||
return db.AgentTaskQueue{}, "", false
|
||
}
|
||
|
||
if !h.requireDaemonWorkspaceAccess(w, r, wsID) {
|
||
return db.AgentTaskQueue{}, "", false
|
||
}
|
||
return task, wsID, true
|
||
}
|
||
|
||
// verifyDaemonWorkspaceAccess checks workspace access without writing an HTTP error.
|
||
// Used in loops where individual items may be skipped silently.
|
||
func (h *Handler) verifyDaemonWorkspaceAccess(r *http.Request, workspaceID string) bool {
|
||
if workspaceID == "" {
|
||
return false
|
||
}
|
||
if daemonWsID := middleware.DaemonWorkspaceIDFromContext(r.Context()); daemonWsID != "" {
|
||
return daemonWsID == workspaceID
|
||
}
|
||
userID := requestUserID(r)
|
||
if userID == "" {
|
||
return false
|
||
}
|
||
if h.MembershipCache.Get(r.Context(), userID, workspaceID) {
|
||
return true
|
||
}
|
||
_, err := h.getWorkspaceMember(r.Context(), userID, workspaceID)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
h.MembershipCache.Set(r.Context(), userID, workspaceID)
|
||
return true
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Daemon Registration & Heartbeat
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type DaemonRegisterRequest struct {
|
||
WorkspaceID string `json:"workspace_id"`
|
||
DaemonID string `json:"daemon_id"`
|
||
// LegacyDaemonIDs lists prior hostname-derived daemon_ids this machine
|
||
// may have registered under before switching to a persistent UUID. The
|
||
// handler merges any matching runtime rows into the new row so agents
|
||
// and tasks keep working without manual intervention.
|
||
LegacyDaemonIDs []string `json:"legacy_daemon_ids"`
|
||
DeviceName string `json:"device_name"`
|
||
CLIVersion string `json:"cli_version"` // multica CLI version
|
||
LaunchedBy string `json:"launched_by"` // "desktop" when spawned by the Electron app
|
||
Runtimes []struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Version string `json:"version"` // agent CLI version (claude/codex)
|
||
Status string `json:"status"`
|
||
// ProfileID, when non-empty, marks this as an instance of a custom
|
||
// runtime_profile (MUL-3284). Empty = built-in runtime (legacy path).
|
||
// Type carries the protocol family for both built-in and custom rows
|
||
// so task routing (agent.New) is unchanged.
|
||
ProfileID string `json:"profile_id"`
|
||
} `json:"runtimes"`
|
||
FailedProfiles []struct {
|
||
ProfileID string `json:"profile_id"`
|
||
CommandName string `json:"command_name"`
|
||
Reason string `json:"reason"`
|
||
} `json:"failed_profiles"`
|
||
}
|
||
|
||
type daemonWorkspaceReposResponse struct {
|
||
WorkspaceID string `json:"workspace_id"`
|
||
Repos []RepoData `json:"repos"`
|
||
ReposVersion string `json:"repos_version"`
|
||
Settings json.RawMessage `json:"settings,omitempty"`
|
||
}
|
||
|
||
func normalizeWorkspaceRepos(repos []RepoData) []RepoData {
|
||
if len(repos) == 0 {
|
||
return []RepoData{}
|
||
}
|
||
|
||
normalized := make([]RepoData, 0, len(repos))
|
||
seen := make(map[string]struct{}, len(repos))
|
||
for _, repo := range repos {
|
||
url := strings.TrimSpace(repo.URL)
|
||
if url == "" {
|
||
continue
|
||
}
|
||
if _, exists := seen[url]; exists {
|
||
continue
|
||
}
|
||
seen[url] = struct{}{}
|
||
normalized = append(normalized, RepoData{URL: url, Description: repo.Description})
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
func workspaceReposVersion(repos []RepoData) string {
|
||
urls := make([]string, 0, len(repos))
|
||
for _, repo := range repos {
|
||
if repo.URL == "" {
|
||
continue
|
||
}
|
||
urls = append(urls, repo.URL)
|
||
}
|
||
sort.Strings(urls)
|
||
sum := sha256.Sum256([]byte(strings.Join(urls, "\n")))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func parseWorkspaceRepos(raw []byte) []RepoData {
|
||
if len(raw) == 0 {
|
||
return []RepoData{}
|
||
}
|
||
|
||
var repos []RepoData
|
||
if err := json.Unmarshal(raw, &repos); err != nil {
|
||
return []RepoData{}
|
||
}
|
||
return normalizeWorkspaceRepos(repos)
|
||
}
|
||
|
||
func workspaceReposResponse(workspaceID string, raw []byte, settingsRaw []byte) daemonWorkspaceReposResponse {
|
||
repos := parseWorkspaceRepos(raw)
|
||
resp := daemonWorkspaceReposResponse{
|
||
WorkspaceID: workspaceID,
|
||
Repos: repos,
|
||
ReposVersion: workspaceReposVersion(repos),
|
||
}
|
||
if len(settingsRaw) > 0 {
|
||
resp.Settings = json.RawMessage(settingsRaw)
|
||
}
|
||
return resp
|
||
}
|
||
|
||
// normalizeProvider canonicalizes a provider string for storage: trimmed and
|
||
// lowercased so client-side pricing lookups tolerate case drift. Returns "" for
|
||
// a blank input.
|
||
func normalizeProvider(s string) string {
|
||
return strings.ToLower(strings.TrimSpace(s))
|
||
}
|
||
|
||
// inheritMachineCustomName gives a freshly-inserted runtime the machine's
|
||
// shared custom name (MUL-4217) when the machine is already named, so adding a
|
||
// provider — or recording a failed custom-runtime profile — on a named machine
|
||
// doesn't leave a custom_name = NULL row that makes the machine title revert to
|
||
// its hostname. Both the normal runtime path and the failed-profile path write
|
||
// daemon_id-scoped rows that show up in the machine grouping, so both call this.
|
||
//
|
||
// Only fresh inserts with no name of their own and a daemon_id participate;
|
||
// existing rows keep whatever they already have. A lookup/update error is
|
||
// non-fatal — registration must still succeed — so the input row is returned
|
||
// unchanged on any failure.
|
||
func (h *Handler) inheritMachineCustomName(ctx context.Context, rt db.AgentRuntime, inserted bool) db.AgentRuntime {
|
||
if !inserted || rt.CustomName.Valid || !rt.DaemonID.Valid {
|
||
return rt
|
||
}
|
||
names, err := h.Queries.ListDaemonCustomNames(ctx, db.ListDaemonCustomNamesParams{
|
||
WorkspaceID: rt.WorkspaceID,
|
||
DaemonID: rt.DaemonID,
|
||
ExcludeID: rt.ID,
|
||
})
|
||
if err != nil {
|
||
return rt
|
||
}
|
||
shared, ok := sharedDaemonCustomName(names)
|
||
if !ok {
|
||
return rt
|
||
}
|
||
updated, err := h.Queries.UpdateAgentRuntimeCustomName(ctx, db.UpdateAgentRuntimeCustomNameParams{
|
||
CustomName: pgtype.Text{String: shared, Valid: true},
|
||
ID: rt.ID,
|
||
})
|
||
if err != nil {
|
||
return rt
|
||
}
|
||
return updated
|
||
}
|
||
|
||
// sharedDaemonCustomName returns the machine-level name shared by all of a
|
||
// daemon's runtimes — the same rule the frontend's sharedCustomName applies:
|
||
// every runtime must carry the identical non-empty custom_name. Returns
|
||
// ("", false) when the set is empty, any runtime is unnamed, or the names
|
||
// disagree (i.e. there is no single machine name to inherit).
|
||
func sharedDaemonCustomName(names []pgtype.Text) (string, bool) {
|
||
if len(names) == 0 {
|
||
return "", false
|
||
}
|
||
var first string
|
||
for i, n := range names {
|
||
if !n.Valid {
|
||
return "", false
|
||
}
|
||
v := strings.TrimSpace(n.String)
|
||
if v == "" {
|
||
return "", false
|
||
}
|
||
if i == 0 {
|
||
first = v
|
||
} else if v != first {
|
||
return "", false
|
||
}
|
||
}
|
||
return first, true
|
||
}
|
||
|
||
func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
||
var req DaemonRegisterRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
|
||
req.WorkspaceID = strings.TrimSpace(req.WorkspaceID)
|
||
req.DaemonID = strings.TrimSpace(req.DaemonID)
|
||
req.DeviceName = strings.TrimSpace(req.DeviceName)
|
||
|
||
if req.DaemonID == "" {
|
||
writeError(w, http.StatusBadRequest, "daemon_id is required")
|
||
return
|
||
}
|
||
if req.WorkspaceID == "" {
|
||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||
return
|
||
}
|
||
if len(req.Runtimes) == 0 && len(req.FailedProfiles) == 0 {
|
||
writeError(w, http.StatusBadRequest, "at least one runtime or failed profile is required")
|
||
return
|
||
}
|
||
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||
if !ok {
|
||
return
|
||
}
|
||
req.WorkspaceID = uuidToString(wsUUID)
|
||
|
||
// Verify workspace access and resolve owner.
|
||
// Daemon tokens (mdt_) prove workspace access directly; OwnerID will be zero
|
||
// (the SQL COALESCE preserves any existing owner on upsert).
|
||
// PAT/JWT tokens require a membership check and set OwnerID from the member.
|
||
var ownerID pgtype.UUID
|
||
if daemonWsID := middleware.DaemonWorkspaceIDFromContext(r.Context()); daemonWsID != "" {
|
||
if daemonWsID != req.WorkspaceID {
|
||
writeError(w, http.StatusNotFound, "workspace not found")
|
||
return
|
||
}
|
||
// ownerID stays zero — COALESCE keeps the existing owner on upsert.
|
||
} else {
|
||
member, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found")
|
||
if !ok {
|
||
return
|
||
}
|
||
ownerID = member.UserID
|
||
}
|
||
|
||
ws, err := h.Queries.GetWorkspace(r.Context(), wsUUID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "workspace not found")
|
||
return
|
||
}
|
||
|
||
resp := make([]AgentRuntimeResponse, 0, len(req.Runtimes))
|
||
for _, runtime := range req.Runtimes {
|
||
provider := normalizeProvider(runtime.Type)
|
||
if provider == "" {
|
||
provider = "unknown"
|
||
}
|
||
name := strings.TrimSpace(runtime.Name)
|
||
if name == "" {
|
||
name = provider
|
||
if req.DeviceName != "" {
|
||
name = fmt.Sprintf("%s (%s)", provider, req.DeviceName)
|
||
}
|
||
}
|
||
deviceInfo := strings.TrimSpace(req.DeviceName)
|
||
if runtime.Version != "" && deviceInfo != "" {
|
||
deviceInfo = fmt.Sprintf("%s · %s", deviceInfo, runtime.Version)
|
||
} else if runtime.Version != "" {
|
||
deviceInfo = runtime.Version
|
||
}
|
||
status := "online"
|
||
if runtime.Status == "offline" {
|
||
status = "offline"
|
||
}
|
||
metadata, _ := json.Marshal(map[string]any{
|
||
"version": runtime.Version,
|
||
"cli_version": req.CLIVersion,
|
||
"launched_by": req.LaunchedBy,
|
||
})
|
||
|
||
var registered db.AgentRuntime
|
||
var inserted bool
|
||
isCustom := strings.TrimSpace(runtime.ProfileID) != ""
|
||
|
||
if isCustom {
|
||
profileUUID, pok := parseUUIDOrBadRequest(w, strings.TrimSpace(runtime.ProfileID), "profile_id")
|
||
if !pok {
|
||
return
|
||
}
|
||
// The profile must exist in this workspace and be enabled. Trust
|
||
// the profile's stored protocol_family over the daemon-sent type so
|
||
// the provider used for task routing cannot drift from the profile.
|
||
profile, perr := h.Queries.GetRuntimeProfileForWorkspace(r.Context(), db.GetRuntimeProfileForWorkspaceParams{
|
||
ID: profileUUID,
|
||
WorkspaceID: wsUUID,
|
||
})
|
||
if perr != nil {
|
||
writeError(w, http.StatusBadRequest, "unknown runtime profile: "+runtime.ProfileID)
|
||
return
|
||
}
|
||
if !profile.Enabled {
|
||
writeError(w, http.StatusConflict, "runtime profile is disabled: "+runtime.ProfileID)
|
||
return
|
||
}
|
||
provider = profile.ProtocolFamily
|
||
|
||
prow, err := h.Queries.UpsertAgentRuntimeWithProfile(r.Context(), db.UpsertAgentRuntimeWithProfileParams{
|
||
WorkspaceID: wsUUID,
|
||
DaemonID: strToText(req.DaemonID),
|
||
Name: name,
|
||
RuntimeMode: "local",
|
||
Provider: provider,
|
||
Status: status,
|
||
DeviceInfo: deviceInfo,
|
||
Metadata: metadata,
|
||
OwnerID: ownerID,
|
||
ProfileID: profileUUID,
|
||
})
|
||
if err != nil {
|
||
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.RuntimeFailed(
|
||
uuidToString(ownerID),
|
||
req.WorkspaceID,
|
||
req.DaemonID,
|
||
provider,
|
||
"registration_failed",
|
||
"db_error",
|
||
true,
|
||
))
|
||
writeError(w, http.StatusInternalServerError, "failed to register runtime: "+err.Error())
|
||
return
|
||
}
|
||
inserted = prow.Inserted
|
||
registered = db.AgentRuntime{
|
||
ID: prow.ID,
|
||
WorkspaceID: prow.WorkspaceID,
|
||
DaemonID: prow.DaemonID,
|
||
Name: prow.Name,
|
||
CustomName: prow.CustomName,
|
||
RuntimeMode: prow.RuntimeMode,
|
||
Provider: prow.Provider,
|
||
Status: prow.Status,
|
||
DeviceInfo: prow.DeviceInfo,
|
||
Metadata: prow.Metadata,
|
||
LastSeenAt: prow.LastSeenAt,
|
||
CreatedAt: prow.CreatedAt,
|
||
UpdatedAt: prow.UpdatedAt,
|
||
OwnerID: prow.OwnerID,
|
||
LegacyDaemonID: prow.LegacyDaemonID,
|
||
Visibility: prow.Visibility,
|
||
ProfileID: prow.ProfileID,
|
||
}
|
||
} else {
|
||
row, err := h.Queries.UpsertAgentRuntime(r.Context(), db.UpsertAgentRuntimeParams{
|
||
WorkspaceID: wsUUID,
|
||
DaemonID: strToText(req.DaemonID),
|
||
Name: name,
|
||
RuntimeMode: "local",
|
||
Provider: provider,
|
||
Status: status,
|
||
DeviceInfo: deviceInfo,
|
||
Metadata: metadata,
|
||
OwnerID: ownerID,
|
||
})
|
||
if err != nil {
|
||
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.RuntimeFailed(
|
||
uuidToString(ownerID),
|
||
req.WorkspaceID,
|
||
req.DaemonID,
|
||
provider,
|
||
"registration_failed",
|
||
"db_error",
|
||
true,
|
||
))
|
||
writeError(w, http.StatusInternalServerError, "failed to register runtime: "+err.Error())
|
||
return
|
||
}
|
||
inserted = row.Inserted
|
||
registered = db.AgentRuntime{
|
||
ID: row.ID,
|
||
WorkspaceID: row.WorkspaceID,
|
||
DaemonID: row.DaemonID,
|
||
Name: row.Name,
|
||
CustomName: row.CustomName,
|
||
RuntimeMode: row.RuntimeMode,
|
||
Provider: row.Provider,
|
||
Status: row.Status,
|
||
DeviceInfo: row.DeviceInfo,
|
||
Metadata: row.Metadata,
|
||
LastSeenAt: row.LastSeenAt,
|
||
CreatedAt: row.CreatedAt,
|
||
UpdatedAt: row.UpdatedAt,
|
||
OwnerID: row.OwnerID,
|
||
LegacyDaemonID: row.LegacyDaemonID,
|
||
Visibility: row.Visibility,
|
||
ProfileID: row.ProfileID,
|
||
}
|
||
}
|
||
|
||
// A brand-new runtime on an already-named machine inherits the machine's
|
||
// shared custom name so the machine title stays stable as providers come
|
||
// and go (MUL-4217). Shared with the failed-profile path below.
|
||
registered = h.inheritMachineCustomName(r.Context(), registered, inserted)
|
||
|
||
// Inserted is false for normal daemon reconnects/upserts, so
|
||
// runtime_ready is a first-ready-per-runtime-row signal.
|
||
if inserted {
|
||
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.RuntimeRegistered(
|
||
uuidToString(ownerID),
|
||
req.WorkspaceID,
|
||
uuidToString(registered.ID),
|
||
req.DaemonID,
|
||
provider,
|
||
runtime.Version,
|
||
req.CLIVersion,
|
||
))
|
||
if registered.Status == "online" {
|
||
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.RuntimeReady(
|
||
uuidToString(ownerID),
|
||
req.WorkspaceID,
|
||
uuidToString(registered.ID),
|
||
req.DaemonID,
|
||
provider,
|
||
0,
|
||
))
|
||
}
|
||
}
|
||
|
||
// Seamless migration from the previous hostname-derived identity. The
|
||
// daemon sends every legacy daemon_id it may have registered under
|
||
// (e.g. "host.local", "host", "host-staging"); for each match we
|
||
// reassign agents + tasks onto the new UUID-keyed row, then delete
|
||
// the stale row so there's only ever one runtime per machine.
|
||
//
|
||
// Only built-in runtimes participate: legacy rows predate custom
|
||
// profiles, so a profile-keyed instance never has a hostname-derived
|
||
// ancestor to merge, and mergeLegacyRuntimes scopes by provider alone
|
||
// (no profile_id), which could otherwise fold a built-in row into a
|
||
// custom one of the same provider.
|
||
if !isCustom {
|
||
h.mergeLegacyRuntimes(r, registered, provider, req.LegacyDaemonIDs)
|
||
}
|
||
|
||
resp = append(resp, runtimeToResponse(registered))
|
||
}
|
||
for _, failed := range req.FailedProfiles {
|
||
profileID := strings.TrimSpace(failed.ProfileID)
|
||
if profileID == "" {
|
||
continue
|
||
}
|
||
profileUUID, pok := parseUUIDOrBadRequest(w, profileID, "profile_id")
|
||
if !pok {
|
||
return
|
||
}
|
||
profile, perr := h.Queries.GetRuntimeProfileForWorkspace(r.Context(), db.GetRuntimeProfileForWorkspaceParams{
|
||
ID: profileUUID,
|
||
WorkspaceID: wsUUID,
|
||
})
|
||
if perr != nil || !profile.Enabled {
|
||
continue
|
||
}
|
||
name := profile.DisplayName
|
||
if req.DeviceName != "" {
|
||
name = fmt.Sprintf("%s (%s)", name, req.DeviceName)
|
||
}
|
||
deviceInfo := strings.TrimSpace(req.DeviceName)
|
||
reason := strings.TrimSpace(failed.Reason)
|
||
if reason == "" {
|
||
reason = "custom runtime command could not be resolved"
|
||
}
|
||
commandName := strings.TrimSpace(failed.CommandName)
|
||
if commandName == "" {
|
||
commandName = profile.CommandName
|
||
}
|
||
metadata, _ := json.Marshal(map[string]any{
|
||
"version": "",
|
||
"cli_version": req.CLIVersion,
|
||
"launched_by": req.LaunchedBy,
|
||
"runtime_profile_registration_error": true,
|
||
"runtime_profile_failure_reason": reason,
|
||
"command_name": commandName,
|
||
})
|
||
prow, err := h.Queries.UpsertAgentRuntimeWithProfile(r.Context(), db.UpsertAgentRuntimeWithProfileParams{
|
||
WorkspaceID: wsUUID,
|
||
DaemonID: strToText(req.DaemonID),
|
||
Name: name,
|
||
RuntimeMode: "local",
|
||
Provider: profile.ProtocolFamily,
|
||
Status: "offline",
|
||
DeviceInfo: deviceInfo,
|
||
Metadata: metadata,
|
||
OwnerID: ownerID,
|
||
ProfileID: profileUUID,
|
||
})
|
||
if err != nil {
|
||
slog.Warn("failed to record runtime profile registration failure",
|
||
"workspace_id", req.WorkspaceID, "daemon_id", req.DaemonID,
|
||
"profile_id", profileID, "error", err)
|
||
continue
|
||
}
|
||
// Keep the failed-profile row consistent with the machine's name so it
|
||
// doesn't drag the machine title back to the hostname (MUL-4217).
|
||
h.inheritMachineCustomName(r.Context(), db.AgentRuntime{
|
||
ID: prow.ID,
|
||
WorkspaceID: prow.WorkspaceID,
|
||
DaemonID: prow.DaemonID,
|
||
CustomName: prow.CustomName,
|
||
}, prow.Inserted)
|
||
}
|
||
|
||
slog.Info("daemon registered", "workspace_id", req.WorkspaceID, "daemon_id", req.DaemonID, "runtimes_count", len(resp))
|
||
|
||
h.publish(protocol.EventDaemonRegister, req.WorkspaceID, "system", "", map[string]any{
|
||
"runtimes": resp,
|
||
})
|
||
|
||
repoResp := workspaceReposResponse(req.WorkspaceID, ws.Repos, ws.Settings)
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"runtimes": resp,
|
||
"repos": repoResp.Repos,
|
||
"repos_version": repoResp.ReposVersion,
|
||
"settings": repoResp.Settings,
|
||
})
|
||
}
|
||
|
||
// mergeLegacyRuntimes folds every runtime row keyed on a prior hostname-derived
|
||
// daemon_id into the newly registered UUID-keyed row. For each legacy id the
|
||
// lookup is case-insensitive and returns *all* matching rows — case-only drift
|
||
// may have already minted duplicates historically (e.g. `Foo.local` AND
|
||
// `foo.local` coexisting), and we need to consolidate every one of them, not
|
||
// just the first. Per match we reassign agents and tasks, record the legacy
|
||
// id on the new row for audit, then delete the stale row.
|
||
//
|
||
// Scoping by (workspace_id, provider) is sufficient since provider is single-
|
||
// runtime-per-daemon; `unique (workspace_id, daemon_id, provider)` prevents
|
||
// any two *exact* matches but the `LOWER(...)` comparison crosses that bound
|
||
// precisely when case-duplicate rows exist — which is the bug we're fixing.
|
||
// We also dedupe across legacy ids so overlapping candidates (e.g. `foo` and
|
||
// `foo.local` both resolving to the same stored row) don't double-process.
|
||
func (h *Handler) mergeLegacyRuntimes(r *http.Request, registered db.AgentRuntime, provider string, legacyIDs []string) {
|
||
newID := uuidToString(registered.ID)
|
||
merged := make(map[string]struct{})
|
||
|
||
for _, legacyID := range legacyIDs {
|
||
legacyID = strings.TrimSpace(legacyID)
|
||
if legacyID == "" {
|
||
continue
|
||
}
|
||
|
||
matches, err := h.Queries.FindLegacyRuntimesByDaemonID(r.Context(), db.FindLegacyRuntimesByDaemonIDParams{
|
||
WorkspaceID: registered.WorkspaceID,
|
||
Provider: provider,
|
||
DaemonID: legacyID,
|
||
})
|
||
if err != nil {
|
||
slog.Warn("legacy runtime merge: lookup failed", "legacy_daemon_id", legacyID, "error", err)
|
||
continue
|
||
}
|
||
for _, old := range matches {
|
||
oldID := uuidToString(old.ID)
|
||
if oldID == newID {
|
||
continue
|
||
}
|
||
if _, seen := merged[oldID]; seen {
|
||
continue
|
||
}
|
||
merged[oldID] = struct{}{}
|
||
|
||
agents, err := h.Queries.ReassignAgentsToRuntime(r.Context(), db.ReassignAgentsToRuntimeParams{
|
||
NewRuntimeID: registered.ID,
|
||
OldRuntimeID: old.ID,
|
||
})
|
||
if err != nil {
|
||
slog.Warn("legacy runtime merge: reassign agents failed", "legacy_daemon_id", legacyID, "old_runtime_id", oldID, "new_runtime_id", newID, "error", err)
|
||
continue
|
||
}
|
||
tasks, err := h.Queries.ReassignTasksToRuntime(r.Context(), db.ReassignTasksToRuntimeParams{
|
||
NewRuntimeID: registered.ID,
|
||
OldRuntimeID: old.ID,
|
||
})
|
||
if err != nil {
|
||
slog.Warn("legacy runtime merge: reassign tasks failed", "legacy_daemon_id", legacyID, "old_runtime_id", oldID, "new_runtime_id", newID, "error", err)
|
||
continue
|
||
}
|
||
if err := h.Queries.RecordRuntimeLegacyDaemonID(r.Context(), db.RecordRuntimeLegacyDaemonIDParams{
|
||
ID: registered.ID,
|
||
LegacyDaemonID: strToText(legacyID),
|
||
}); err != nil {
|
||
slog.Warn("legacy runtime merge: record legacy daemon_id failed", "legacy_daemon_id", legacyID, "error", err)
|
||
}
|
||
if err := h.Queries.DeleteAgentRuntime(r.Context(), old.ID); err != nil {
|
||
slog.Warn("legacy runtime merge: delete old runtime failed", "old_runtime_id", oldID, "error", err)
|
||
continue
|
||
}
|
||
|
||
slog.Info("legacy runtime merged",
|
||
"legacy_daemon_id", legacyID,
|
||
"old_runtime_id", oldID,
|
||
"new_runtime_id", newID,
|
||
"provider", provider,
|
||
"agents_reassigned", agents,
|
||
"tasks_reassigned", tasks,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (h *Handler) GetDaemonWorkspaceRepos(w http.ResponseWriter, r *http.Request) {
|
||
workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceId"))
|
||
if !h.requireDaemonWorkspaceAccess(w, r, workspaceID) {
|
||
return
|
||
}
|
||
|
||
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(workspaceID))
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "workspace not found")
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, workspaceReposResponse(workspaceID, ws.Repos, ws.Settings))
|
||
}
|
||
|
||
// DaemonDeregister marks runtimes as offline when the daemon shuts down.
|
||
func (h *Handler) DaemonDeregister(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
RuntimeIDs []string `json:"runtime_ids"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
|
||
if len(req.RuntimeIDs) == 0 {
|
||
writeError(w, http.StatusBadRequest, "runtime_ids is required")
|
||
return
|
||
}
|
||
runtimeUUIDs, ok := parseUUIDSliceOrBadRequest(w, req.RuntimeIDs, "runtime_ids")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
// Track affected workspaces for WS notifications.
|
||
affectedWorkspaces := make(map[string]bool)
|
||
|
||
for i, rid := range req.RuntimeIDs {
|
||
// Look up the runtime and verify ownership.
|
||
rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUIDs[i])
|
||
if err != nil {
|
||
slog.Warn("deregister: runtime not found", "runtime_id", rid, "error", err)
|
||
continue
|
||
}
|
||
|
||
wsID := uuidToString(rt.WorkspaceID)
|
||
if !h.verifyDaemonWorkspaceAccess(r, wsID) {
|
||
slog.Warn("deregister: workspace mismatch", "runtime_id", rid)
|
||
continue
|
||
}
|
||
|
||
if err := h.Queries.SetAgentRuntimeOffline(r.Context(), rt.ID); err != nil {
|
||
slog.Warn("deregister: failed to set offline", "runtime_id", rid, "error", err)
|
||
continue
|
||
}
|
||
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.RuntimeOffline(
|
||
uuidToString(rt.OwnerID),
|
||
wsID,
|
||
uuidToString(rt.ID),
|
||
rt.DaemonID.String,
|
||
rt.Provider,
|
||
))
|
||
|
||
affectedWorkspaces[wsID] = true
|
||
}
|
||
|
||
// Notify frontend clients so they re-fetch runtime list.
|
||
for wsID := range affectedWorkspaces {
|
||
h.publish(protocol.EventDaemonRegister, wsID, "system", "", map[string]any{
|
||
"action": "deregister",
|
||
})
|
||
}
|
||
|
||
slog.Info("daemon deregistered", "runtime_ids", req.RuntimeIDs)
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
type DaemonHeartbeatRequest struct {
|
||
RuntimeID string `json:"runtime_id"`
|
||
SupportsBatchImport bool `json:"supports_batch_import,omitempty"`
|
||
}
|
||
|
||
// heartbeatHasPendingTimeout bounds the cheap HasPending probe on the
|
||
// heartbeat hot path. Probes are read-only (ZCARD in Redis) so a timeout is
|
||
// ack-safe: the worst case is "we didn't find out if anything was queued this
|
||
// tick" and the next heartbeat (default 15s later) will try again.
|
||
//
|
||
// PopPending is deliberately NOT bounded this way — its Redis implementation
|
||
// runs a Lua claim script whose ZREM + SET-running side effects cannot be
|
||
// cleanly un-run from the client side if the context expires mid-script. We
|
||
// therefore only invoke PopPending after HasPending confirms there is work
|
||
// to claim, so we never start a claim we might have to abort.
|
||
const heartbeatHasPendingTimeout = 1 * time.Second
|
||
|
||
// maxLocalSkillImportBatch is how many pending import requests the heartbeat
|
||
// handler pops per cycle. Higher values let the daemon process more imports
|
||
// in parallel but increase per-heartbeat latency.
|
||
//
|
||
// Timeout invariant: IMPORT_CONCURRENCY (views/.../runtime-local-skill-import-panel.tsx)
|
||
// × heartbeat period (~15s) must stay within runtimeLocalSkillPendingTimeout
|
||
// (runtime_local_skills.go), and IMPORT_POLL_TIMEOUT_MS (core/runtimes/local-skills.ts)
|
||
// must exceed pendingTimeout + runningTimeout.
|
||
const maxLocalSkillImportBatch = 10
|
||
|
||
// runtimeLivenessTTL is how long a Redis liveness record stays valid before
|
||
// expiring. The daemon refreshes it every heartbeat (~15s), so this just
|
||
// needs to be a few heartbeats long — the value (90s) tolerates ~6 missed
|
||
// beats before Redis declares the runtime dead.
|
||
//
|
||
// It is intentionally shorter than the sweeper's stale threshold (150s in
|
||
// cmd/server/runtime_sweeper.go). That ordering is safe and desirable:
|
||
// Redis can declare a runtime dead before the DB stale window opens, and
|
||
// the sweeper will simply ignore it until the DB column also crosses the
|
||
// threshold. The unsafe direction would be the opposite (Redis claiming
|
||
// "alive" past the DB stale window, masking a truly dead runtime when the
|
||
// sweeper consults Redis as the source of truth) — that cannot happen here.
|
||
const runtimeLivenessTTL = 90 * time.Second
|
||
|
||
// runtimeHeartbeatDBFlushInterval is the maximum staleness we tolerate on
|
||
// agent_runtime.last_seen_at while Redis is the active liveness source. When
|
||
// last_seen_at gets older than this, the heartbeat path schedules a DB write
|
||
// so (a) the UI's "last seen" display stays bounded and (b) the sweeper's
|
||
// DB-only fallback path (used when an IsAliveBatch call to Redis errors) does
|
||
// not false-positive on alive-but-Redis-only runtimes.
|
||
//
|
||
// Load-bearing invariant: this must be strictly less than the sweeper's
|
||
// stale threshold (150s in cmd/server/runtime_sweeper.go) MINUS one daemon
|
||
// heartbeat cycle (~15s) MINUS the BatchedHeartbeatScheduler tick interval
|
||
// (~30s). Worst-case DB age for an alive runtime is therefore bounded by
|
||
// flush + heartbeat + batchTick = 60 + 15 + 30 = 105s, leaving a 45s buffer
|
||
// below the 150s stale window. If you tune any of these constants, recompute
|
||
// the chain and keep at least a one-tick buffer.
|
||
//
|
||
// We intentionally keep the per-runtime flush throttle at 60s (rather than
|
||
// pushing it higher) so a crashed runtime is detected within ~150s instead
|
||
// of ~10 minutes. The bulk of the DB-pressure win comes from batched
|
||
// coalescing in HeartbeatScheduler — at 70 online runtimes that collapses
|
||
// ~17 single-row UPDATE/s into ~0.03 bulk UPDATE/s (one per batch tick),
|
||
// independent of how the per-runtime throttle is tuned.
|
||
const runtimeHeartbeatDBFlushInterval = 60 * time.Second
|
||
|
||
func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||
start := time.Now()
|
||
authPath := middleware.DaemonAuthPathFromContext(r.Context())
|
||
var (
|
||
outcome = "unauth"
|
||
runtimeID string
|
||
decodeMs, runtimeLookupMs, workspaceCheckMs int64
|
||
authMs, updateMs, probeModelMs, popModelMs, probeSkillsMs, popSkillsMs, probeImportMs, popImportMs int64
|
||
probeModelTimedOut, probeSkillsTimedOut, probeImportTimedOut bool
|
||
)
|
||
defer func() {
|
||
logHeartbeatEndpointSlow(runtimeID, outcome, authPath, start, decodeMs, runtimeLookupMs, workspaceCheckMs, authMs, updateMs, probeModelMs, popModelMs, probeSkillsMs, popSkillsMs, probeImportMs, popImportMs, probeModelTimedOut, probeSkillsTimedOut, probeImportTimedOut)
|
||
}()
|
||
|
||
decodeStart := time.Now()
|
||
var req DaemonHeartbeatRequest
|
||
decodeErr := json.NewDecoder(r.Body).Decode(&req)
|
||
decodeMs = time.Since(decodeStart).Milliseconds()
|
||
if decodeErr != nil {
|
||
outcome = "bad_body"
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
|
||
if req.RuntimeID == "" {
|
||
outcome = "missing_runtime_id"
|
||
writeError(w, http.StatusBadRequest, "runtime_id is required")
|
||
return
|
||
}
|
||
runtimeID = req.RuntimeID
|
||
|
||
// Inlined and instrumented version of requireDaemonRuntimeAccess so we
|
||
// can attribute the runtime-lookup and workspace-check sub-stages
|
||
// independently in slow-logs. Together with the auth_path label set by
|
||
// DaemonAuth middleware, this lets us tell whether prod heartbeat tail
|
||
// latency is in pgx pool acquisition (runtime_lookup_ms), in the PAT
|
||
// fallback workspace-membership query (workspace_check_ms), or upstream.
|
||
runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
|
||
if !ok {
|
||
outcome = "bad_runtime_id"
|
||
return
|
||
}
|
||
lookupStart := time.Now()
|
||
rt, lookupErr := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
|
||
runtimeLookupMs = time.Since(lookupStart).Milliseconds()
|
||
if lookupErr != nil {
|
||
// Only pgx.ErrNoRows means the runtime row is gone. Daemon reads this
|
||
// 404 as a signal to drop the stale runtime locally; treating a
|
||
// transient DB error the same way would force daemons to self-cleanup
|
||
// on a hiccup.
|
||
if isNotFound(lookupErr) {
|
||
outcome = "runtime_not_found"
|
||
writeError(w, http.StatusNotFound, "runtime not found")
|
||
return
|
||
}
|
||
outcome = "runtime_lookup_error"
|
||
slog.Warn("get agent runtime failed", "runtime_id", req.RuntimeID, "error", lookupErr)
|
||
writeError(w, http.StatusInternalServerError, "failed to load runtime")
|
||
return
|
||
}
|
||
wsCheckStart := time.Now()
|
||
wsOK := h.requireDaemonWorkspaceAccess(w, r, uuidToString(rt.WorkspaceID))
|
||
workspaceCheckMs = time.Since(wsCheckStart).Milliseconds()
|
||
if !wsOK {
|
||
outcome = "workspace_denied"
|
||
return
|
||
}
|
||
authMs = time.Since(start).Milliseconds()
|
||
|
||
ack, m, err := h.processHeartbeat(r.Context(), rt, req.SupportsBatchImport)
|
||
updateMs = m.UpdateMs
|
||
probeModelMs = m.ProbeModelMs
|
||
popModelMs = m.PopModelMs
|
||
probeSkillsMs = m.ProbeSkillsMs
|
||
popSkillsMs = m.PopSkillsMs
|
||
probeImportMs = m.ProbeImportMs
|
||
popImportMs = m.PopImportMs
|
||
probeModelTimedOut = m.ProbeModelTimedOut
|
||
probeSkillsTimedOut = m.ProbeSkillsTimedOut
|
||
probeImportTimedOut = m.ProbeImportTimedOut
|
||
if err != nil {
|
||
outcome = "error_update"
|
||
writeError(w, http.StatusInternalServerError, "heartbeat failed")
|
||
return
|
||
}
|
||
|
||
outcome = "ok"
|
||
// Preserve the existing HTTP response shape: the runtime_id field is new
|
||
// in the WS path and would be redundant noise on the HTTP path where the
|
||
// caller already knows which runtime it asked about.
|
||
resp := map[string]any{"status": ack.Status}
|
||
if ack.PendingUpdate != nil {
|
||
resp["pending_update"] = ack.PendingUpdate
|
||
}
|
||
if ack.PendingModelList != nil {
|
||
resp["pending_model_list"] = ack.PendingModelList
|
||
}
|
||
if ack.PendingLocalSkills != nil {
|
||
resp["pending_local_skills"] = ack.PendingLocalSkills
|
||
}
|
||
if ack.PendingLocalSkillImport != nil {
|
||
resp["pending_local_skill_import"] = ack.PendingLocalSkillImport
|
||
}
|
||
if len(ack.PendingLocalSkillImports) > 0 {
|
||
resp["pending_local_skill_imports"] = ack.PendingLocalSkillImports
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// HandleDaemonWSHeartbeat is the daemonws.HeartbeatHandler entry point: it
|
||
// resolves the runtime, verifies the connection's workspace owns it, and
|
||
// returns the ack payload. It is the WebSocket-side mirror of DaemonHeartbeat.
|
||
//
|
||
// Workspace authorization is re-checked on every heartbeat instead of trusted
|
||
// from the upgrade-time check because runtime ownership can change (e.g. a
|
||
// runtime is reassigned to another workspace mid-connection).
|
||
//
|
||
// When the runtime row is missing (pgx.ErrNoRows), the function returns a
|
||
// successful ack with Status=HeartbeatStatusRuntimeGone and RuntimeGone=true
|
||
// instead of an error. That keeps the hub from logging every beat at Warn,
|
||
// and tells the daemon to drop the stale runtime and re-register. Other DB
|
||
// errors still propagate as errors so they keep their existing Warn logging
|
||
// and the daemon does not mistake a hiccup for a deletion.
|
||
func (h *Handler) HandleDaemonWSHeartbeat(ctx context.Context, identity daemonws.ClientIdentity, runtimeID string, supportsBatchImport bool) (*protocol.DaemonHeartbeatAckPayload, error) {
|
||
runtimeUUID, err := util.ParseUUID(runtimeID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("invalid runtime_id: %w", err)
|
||
}
|
||
rt, err := h.Queries.GetAgentRuntime(ctx, runtimeUUID)
|
||
if err != nil {
|
||
if isNotFound(err) {
|
||
return &protocol.DaemonHeartbeatAckPayload{
|
||
RuntimeID: runtimeID,
|
||
Status: protocol.HeartbeatStatusRuntimeGone,
|
||
RuntimeGone: true,
|
||
}, nil
|
||
}
|
||
return nil, fmt.Errorf("get agent runtime: %w", err)
|
||
}
|
||
if !identity.AllowsWorkspace(uuidToString(rt.WorkspaceID)) {
|
||
return nil, fmt.Errorf("runtime not in connection workspace")
|
||
}
|
||
ack, _, err := h.processHeartbeat(ctx, rt, supportsBatchImport)
|
||
return ack, err
|
||
}
|
||
|
||
// recordHeartbeat marks the runtime as alive. When LivenessStore is available
|
||
// (Redis configured and reachable) it writes a TTL'd liveness key and skips
|
||
// the DB row write on most beats — the DB is only updated on the
|
||
// offline→online transition or once per runtimeHeartbeatDBFlushInterval to
|
||
// keep last_seen_at fresh enough for the UI and the DB-fallback sweeper.
|
||
//
|
||
// When LivenessStore is unavailable (no Redis configured) or any Touch call
|
||
// errors, recordHeartbeat falls back to writing the DB on every beat — that
|
||
// is the original behavior and keeps the sweeper's DB-only path correct.
|
||
//
|
||
// The actual DB write is delegated to h.HeartbeatScheduler so production can
|
||
// coalesce many runtimes' bumps into one bulk UPDATE per tick. See
|
||
// heartbeat_scheduler.go for the two implementations.
|
||
func (h *Handler) recordHeartbeat(ctx context.Context, rt db.AgentRuntime) error {
|
||
now := time.Now()
|
||
|
||
// Decide whether the DB row needs a write *before* touching Redis, so a
|
||
// Touch failure can simply force needDBWrite=true without re-evaluating
|
||
// the structural reasons.
|
||
needDBWrite := !h.LivenessStore.Available() ||
|
||
rt.Status != "online" ||
|
||
!rt.LastSeenAt.Valid ||
|
||
now.Sub(rt.LastSeenAt.Time) >= runtimeHeartbeatDBFlushInterval
|
||
|
||
if h.LivenessStore.Available() {
|
||
if err := h.LivenessStore.Touch(ctx, uuidToString(rt.ID), runtimeLivenessTTL); err != nil {
|
||
// Redis hiccup: degrade transparently to the DB-only path for
|
||
// this beat. The sweeper falls back to its DB threshold the
|
||
// same way when IsAliveBatch fails, so end-to-end correctness
|
||
// is preserved.
|
||
slog.Warn("liveness touch failed; falling back to DB heartbeat",
|
||
"runtime_id", uuidToString(rt.ID), "error", err)
|
||
needDBWrite = true
|
||
}
|
||
}
|
||
|
||
if !needDBWrite {
|
||
return nil
|
||
}
|
||
|
||
// Either bumps last_seen_at on an already-online row (Touch + race
|
||
// fallback) or flips status from offline to online. The scheduler
|
||
// chooses sync vs batched per case; see HeartbeatScheduler doc.
|
||
return h.HeartbeatScheduler.Schedule(ctx, rt)
|
||
}
|
||
|
||
// heartbeatMetrics carries per-stage timings out of processHeartbeat so the
|
||
// HTTP slow-log can stay structured. The WS path discards them.
|
||
type heartbeatMetrics struct {
|
||
UpdateMs, ProbeModelMs, PopModelMs, ProbeSkillsMs, PopSkillsMs, ProbeImportMs, PopImportMs int64
|
||
ProbeModelTimedOut, ProbeSkillsTimedOut, ProbeImportTimedOut bool
|
||
}
|
||
|
||
// processHeartbeat does the work shared by HTTP POST /api/daemon/heartbeat and
|
||
// the WebSocket daemon:heartbeat path: records liveness and pulls any pending
|
||
// actions queued for the runtime. Auth and request decoding live in the
|
||
// caller because they differ between transports.
|
||
func (h *Handler) processHeartbeat(ctx context.Context, rt db.AgentRuntime, supportsBatchImport bool) (*protocol.DaemonHeartbeatAckPayload, heartbeatMetrics, error) {
|
||
var m heartbeatMetrics
|
||
runtimeID := uuidToString(rt.ID)
|
||
|
||
updateStart := time.Now()
|
||
if err := h.recordHeartbeat(ctx, rt); err != nil {
|
||
m.UpdateMs = time.Since(updateStart).Milliseconds()
|
||
return nil, m, err
|
||
}
|
||
m.UpdateMs = time.Since(updateStart).Milliseconds()
|
||
|
||
slog.Debug("daemon heartbeat", "runtime_id", runtimeID)
|
||
|
||
ack := &protocol.DaemonHeartbeatAckPayload{
|
||
RuntimeID: runtimeID,
|
||
Status: "ok",
|
||
}
|
||
|
||
probeUpdateCtx, cancelProbeUpdate := context.WithTimeout(ctx, heartbeatHasPendingTimeout)
|
||
hasUpdate, probeUpdateErr := h.UpdateStore.HasPending(probeUpdateCtx, runtimeID)
|
||
cancelProbeUpdate()
|
||
switch {
|
||
case probeUpdateErr == nil && hasUpdate:
|
||
pending, popUpdateErr := h.UpdateStore.PopPending(ctx, runtimeID)
|
||
if popUpdateErr != nil {
|
||
slog.Warn("update PopPending failed", "error", popUpdateErr, "runtime_id", runtimeID)
|
||
} else if pending != nil {
|
||
ack.PendingUpdate = &protocol.DaemonHeartbeatPendingUpdate{
|
||
ID: pending.ID,
|
||
TargetVersion: pending.TargetVersion,
|
||
}
|
||
}
|
||
case probeUpdateErr != nil:
|
||
if errors.Is(probeUpdateErr, context.DeadlineExceeded) || errors.Is(probeUpdateErr, context.Canceled) {
|
||
slog.Warn("update HasPending timed out", "runtime_id", runtimeID)
|
||
} else {
|
||
slog.Warn("update HasPending failed", "error", probeUpdateErr, "runtime_id", runtimeID)
|
||
}
|
||
}
|
||
|
||
// Probe then claim the model list queue. Same pattern as the local-skill
|
||
// queues below — a slow shared store cannot stall the heartbeat on
|
||
// empty-queue ticks, but the claim itself runs unbounded because its
|
||
// Lua side effects cannot be safely aborted mid-script.
|
||
probeModelStart := time.Now()
|
||
probeModelCtx, cancelProbeModel := context.WithTimeout(ctx, heartbeatHasPendingTimeout)
|
||
hasModel, probeModelErr := h.ModelListStore.HasPending(probeModelCtx, runtimeID)
|
||
cancelProbeModel()
|
||
m.ProbeModelMs = time.Since(probeModelStart).Milliseconds()
|
||
switch {
|
||
case probeModelErr == nil && hasModel:
|
||
popStart := time.Now()
|
||
pendingModel, popErr := h.ModelListStore.PopPending(ctx, runtimeID)
|
||
m.PopModelMs = time.Since(popStart).Milliseconds()
|
||
if popErr != nil {
|
||
slog.Warn("model list PopPending failed", "error", popErr, "runtime_id", runtimeID)
|
||
} else if pendingModel != nil {
|
||
ack.PendingModelList = &protocol.DaemonHeartbeatPendingModelList{ID: pendingModel.ID}
|
||
}
|
||
case probeModelErr != nil:
|
||
if errors.Is(probeModelErr, context.DeadlineExceeded) || errors.Is(probeModelErr, context.Canceled) {
|
||
m.ProbeModelTimedOut = true
|
||
slog.Warn("model list HasPending timed out", "runtime_id", runtimeID, "elapsed_ms", m.ProbeModelMs)
|
||
} else {
|
||
slog.Warn("model list HasPending failed", "error", probeModelErr, "runtime_id", runtimeID)
|
||
}
|
||
}
|
||
|
||
// Probe then claim the local-skill list queue. The probe is bounded so a
|
||
// slow shared store cannot stall the heartbeat on empty-queue ticks; the
|
||
// claim runs unbounded (it inherits only ctx) because its Lua side
|
||
// effects cannot be safely aborted mid-script.
|
||
probeSkillsStart := time.Now()
|
||
probeSkillsCtx, cancelProbeSkills := context.WithTimeout(ctx, heartbeatHasPendingTimeout)
|
||
hasSkills, probeErr := h.LocalSkillListStore.HasPending(probeSkillsCtx, runtimeID)
|
||
cancelProbeSkills()
|
||
m.ProbeSkillsMs = time.Since(probeSkillsStart).Milliseconds()
|
||
switch {
|
||
case probeErr == nil && hasSkills:
|
||
popStart := time.Now()
|
||
pendingSkills, popErr := h.LocalSkillListStore.PopPending(ctx, runtimeID)
|
||
m.PopSkillsMs = time.Since(popStart).Milliseconds()
|
||
if popErr != nil {
|
||
slog.Warn("local skill list PopPending failed", "error", popErr, "runtime_id", runtimeID)
|
||
} else if pendingSkills != nil {
|
||
ack.PendingLocalSkills = &protocol.DaemonHeartbeatPendingLocalSkills{ID: pendingSkills.ID}
|
||
}
|
||
case probeErr != nil:
|
||
if errors.Is(probeErr, context.DeadlineExceeded) || errors.Is(probeErr, context.Canceled) {
|
||
m.ProbeSkillsTimedOut = true
|
||
slog.Warn("local skill list HasPending timed out", "runtime_id", runtimeID, "elapsed_ms", m.ProbeSkillsMs)
|
||
} else {
|
||
slog.Warn("local skill list HasPending failed", "error", probeErr, "runtime_id", runtimeID)
|
||
}
|
||
}
|
||
|
||
probeImportStart := time.Now()
|
||
probeImportCtx, cancelProbeImport := context.WithTimeout(ctx, heartbeatHasPendingTimeout)
|
||
hasImport, probeErr := h.LocalSkillImportStore.HasPending(probeImportCtx, runtimeID)
|
||
cancelProbeImport()
|
||
m.ProbeImportMs = time.Since(probeImportStart).Milliseconds()
|
||
switch {
|
||
case probeErr == nil && hasImport:
|
||
popStart := time.Now()
|
||
if supportsBatchImport {
|
||
pendingImports, popErr := h.LocalSkillImportStore.PopPendingBatch(ctx, runtimeID, maxLocalSkillImportBatch)
|
||
m.PopImportMs = time.Since(popStart).Milliseconds()
|
||
if popErr != nil {
|
||
slog.Warn("local skill import PopPendingBatch failed", "error", popErr, "runtime_id", runtimeID, "claimed", len(pendingImports))
|
||
}
|
||
// Always dispatch whatever was claimed — even on partial
|
||
// failure the claimed requests have already transitioned to
|
||
// running in the store. Dropping them here would leave them
|
||
// stranded until the running timeout.
|
||
if len(pendingImports) > 0 {
|
||
// Backwards compat: singular field carries the first item so
|
||
// old daemons that don't know the plural field still get one.
|
||
ack.PendingLocalSkillImport = &protocol.DaemonHeartbeatPendingLocalSkillImport{
|
||
ID: pendingImports[0].ID,
|
||
SkillKey: pendingImports[0].SkillKey,
|
||
}
|
||
batch := make([]protocol.DaemonHeartbeatPendingLocalSkillImport, 0, len(pendingImports))
|
||
for _, p := range pendingImports {
|
||
batch = append(batch, protocol.DaemonHeartbeatPendingLocalSkillImport{
|
||
ID: p.ID,
|
||
SkillKey: p.SkillKey,
|
||
})
|
||
}
|
||
ack.PendingLocalSkillImports = batch
|
||
}
|
||
} else {
|
||
pendingImport, popErr := h.LocalSkillImportStore.PopPending(ctx, runtimeID)
|
||
m.PopImportMs = time.Since(popStart).Milliseconds()
|
||
if popErr != nil {
|
||
slog.Warn("local skill import PopPending failed", "error", popErr, "runtime_id", runtimeID)
|
||
} else if pendingImport != nil {
|
||
ack.PendingLocalSkillImport = &protocol.DaemonHeartbeatPendingLocalSkillImport{
|
||
ID: pendingImport.ID,
|
||
SkillKey: pendingImport.SkillKey,
|
||
}
|
||
}
|
||
}
|
||
case probeErr != nil:
|
||
if errors.Is(probeErr, context.DeadlineExceeded) || errors.Is(probeErr, context.Canceled) {
|
||
m.ProbeImportTimedOut = true
|
||
slog.Warn("local skill import HasPending timed out", "runtime_id", runtimeID, "elapsed_ms", m.ProbeImportMs)
|
||
} else {
|
||
slog.Warn("local skill import HasPending failed", "error", probeErr, "runtime_id", runtimeID)
|
||
}
|
||
}
|
||
|
||
return ack, m, nil
|
||
}
|
||
|
||
// logHeartbeatEndpointSlow emits one structured log when /api/daemon/heartbeat
|
||
// exceeds 500ms, splitting auth / update / probe / pop phases for both queues
|
||
// so the prod tail can be attributed without flooding logs at normal rates.
|
||
// auth_ms is further decomposed into decode_ms, runtime_lookup_ms, and
|
||
// workspace_check_ms; auth_path labels which token kind authenticated the
|
||
// request ("daemon_token", "pat", or "jwt"). Mirrors logClaimEndpointSlow.
|
||
func logHeartbeatEndpointSlow(runtimeID, outcome, authPath string, start time.Time, decodeMs, runtimeLookupMs, workspaceCheckMs, authMs, updateMs, probeModelMs, popModelMs, probeSkillsMs, popSkillsMs, probeImportMs, popImportMs int64, probeModelTimedOut, probeSkillsTimedOut, probeImportTimedOut bool) {
|
||
totalMs := time.Since(start).Milliseconds()
|
||
if totalMs < 500 && !probeModelTimedOut && !probeSkillsTimedOut && !probeImportTimedOut {
|
||
return
|
||
}
|
||
slog.Info("heartbeat_endpoint slow",
|
||
"runtime_id", runtimeID,
|
||
"outcome", outcome,
|
||
"auth_path", authPath,
|
||
"total_ms", totalMs,
|
||
"auth_ms", authMs,
|
||
"decode_ms", decodeMs,
|
||
"runtime_lookup_ms", runtimeLookupMs,
|
||
"workspace_check_ms", workspaceCheckMs,
|
||
"update_ms", updateMs,
|
||
"probe_model_ms", probeModelMs,
|
||
"pop_model_ms", popModelMs,
|
||
"probe_skills_ms", probeSkillsMs,
|
||
"pop_skills_ms", popSkillsMs,
|
||
"probe_import_ms", probeImportMs,
|
||
"pop_import_ms", popImportMs,
|
||
"probe_model_timed_out", probeModelTimedOut,
|
||
"probe_skills_timed_out", probeSkillsTimedOut,
|
||
"probe_import_timed_out", probeImportTimedOut,
|
||
)
|
||
}
|
||
|
||
// logClaimEndpointSlow emits one structured log when the /tasks/claim endpoint
|
||
// exceeds 500ms, splitting auth / claim / response-build phases so the prod
|
||
// tail can be diagnosed without flooding logs at normal poll rates.
|
||
func logClaimEndpointSlow(runtimeID, outcome string, start time.Time, authMs, claimMs, buildMs int64, payloadBytes, agentSkillCount, builtinSkillCount, skillPayloadBytes int) {
|
||
totalMs := time.Since(start).Milliseconds()
|
||
if totalMs < 500 {
|
||
return
|
||
}
|
||
slog.Info("claim_endpoint slow",
|
||
"runtime_id", runtimeID,
|
||
"outcome", outcome,
|
||
"total_ms", totalMs,
|
||
"auth_ms", authMs,
|
||
"claim_ms", claimMs,
|
||
"build_ms", buildMs,
|
||
"payload_bytes", payloadBytes,
|
||
"agent_skill_count", agentSkillCount,
|
||
"builtin_skill_count", builtinSkillCount,
|
||
"skill_payload_bytes", skillPayloadBytes,
|
||
)
|
||
}
|
||
|
||
// requestHasClientCapability reports whether the caller advertised a capability
|
||
// in X-Client-Capabilities. Daemons and app clients share the header.
|
||
func requestHasClientCapability(r *http.Request, capability string) bool {
|
||
for _, part := range strings.Split(r.Header.Get("X-Client-Capabilities"), ",") {
|
||
if strings.TrimSpace(part) == capability {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func parseRuntimeConnectedAppsForClaim(raw []byte, taskID pgtype.UUID) []runtimeapps.ConnectedApp {
|
||
raw = bytes.TrimSpace(raw)
|
||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||
return nil
|
||
}
|
||
var apps []runtimeapps.ConnectedApp
|
||
if err := json.Unmarshal(raw, &apps); err != nil {
|
||
slog.Warn("daemon claim: unmarshal runtime_connected_apps failed",
|
||
"task_id", uuidToString(taskID),
|
||
"error", err,
|
||
)
|
||
return nil
|
||
}
|
||
return apps
|
||
}
|
||
|
||
// repairStaleCommentPlanIfNeeded handles the edit/delete race where a claimed
|
||
// task's trigger_comment_id was cleared but coalesced_comment_ids survive: such
|
||
// a task must never be dispatched as a generic assignment — its user-scoped MCP
|
||
// overlay still belongs to the deleted author, and the prompt would read issue
|
||
// history exposing that stale user's capabilities. When it applies, the task is
|
||
// cancelled and its surviving comments are replayed through normal routing
|
||
// (which recomputes originator + connected-app context).
|
||
//
|
||
// Returns handled=true when the task must NOT be dispatched — either a clean
|
||
// repair (failure==nil) or a hard failure (failure!=nil, carrying the
|
||
// status/message/outcome the per-runtime endpoint renders). handled=false means
|
||
// proceed with a normal claim. Shared by the per-runtime and batch claim
|
||
// handlers so the batch path can't silently drop surviving comments (MUL-4257).
|
||
func (h *Handler) repairStaleCommentPlanIfNeeded(ctx context.Context, task *db.AgentTaskQueue, runtimeWorkspaceID string) (handled bool, failure *claimBuildFailure) {
|
||
if task.TriggerCommentID.Valid || len(task.CoalescedCommentIds) == 0 {
|
||
return false, nil
|
||
}
|
||
if !task.IssueID.Valid {
|
||
return true, &claimBuildFailure{outcome: "error_stale_comment_plan", status: http.StatusInternalServerError, message: "comment task has no issue"}
|
||
}
|
||
issue, loadErr := h.Queries.GetIssue(ctx, task.IssueID)
|
||
if loadErr != nil {
|
||
return true, &claimBuildFailure{outcome: "error_stale_comment_plan", status: http.StatusInternalServerError, message: "failed to repair stale comment task"}
|
||
}
|
||
if uuidToString(issue.WorkspaceID) != runtimeWorkspaceID {
|
||
if _, cancelErr := h.TaskService.CancelTask(ctx, task.ID); cancelErr != nil {
|
||
slog.Error("task claim: cancel stale cross-workspace task failed",
|
||
"task_id", uuidToString(task.ID), "error", cancelErr)
|
||
}
|
||
return true, &claimBuildFailure{outcome: "error_workspace", status: http.StatusInternalServerError, message: "task workspace isolation check failed"}
|
||
}
|
||
cancelled, cancelErr := h.TaskService.CancelTask(ctx, task.ID)
|
||
if cancelErr != nil {
|
||
return true, &claimBuildFailure{outcome: "error_stale_comment_plan", status: http.StatusInternalServerError, message: "failed to repair stale comment task"}
|
||
}
|
||
h.retriggerCancelledTaskSurvivors(ctx, issue, []db.AgentTaskQueue{*cancelled}, pgtype.UUID{})
|
||
return true, nil
|
||
}
|
||
|
||
// claimBatchMaxTasksCap bounds how many tasks a single machine-level batch
|
||
// claim may return, so one request can neither build an unbounded payload nor
|
||
// hold the DB for an unbounded number of per-agent claim transactions. The
|
||
// daemon never asks for more than its free execution-slot count anyway.
|
||
const claimBatchMaxTasksCap = 32
|
||
|
||
// ClaimTasksByRuntime is the machine-level (MUL-4257) batch claim endpoint. A
|
||
// daemon posts every runtime_id it hosts plus its free execution-slot count and
|
||
// receives up to max_tasks already-claimed tasks in ONE round trip — each
|
||
// carrying its runtime_id so the daemon routes it to the matching runtime
|
||
// locally. This collapses the per-runtime idle-poll fan-out (one HTTP request
|
||
// plus one promote/reclaim/list cycle per runtime) into a single request backed
|
||
// by = ANY merged queries.
|
||
//
|
||
// Each returned task goes through the same FinalizeTaskClaim as the per-runtime
|
||
// endpoint, so the task-scoped token AND the comment-delivery receipt
|
||
// (delivered_comment_ids) are persisted atomically; a finalization failure
|
||
// requeues that exact claim and omits it from the batch. Unknown/unauthorized
|
||
// runtime_ids are skipped silently (a daemon may send a just-deleted runtime;
|
||
// it self-heals via the heartbeat path).
|
||
func (h *Handler) ClaimTasksByRuntime(w http.ResponseWriter, r *http.Request) {
|
||
start := time.Now()
|
||
var req struct {
|
||
DaemonID string `json:"daemon_id"`
|
||
RuntimeIDs []string `json:"runtime_ids"`
|
||
MaxTasks int `json:"max_tasks"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
|
||
// Machine-level ownership (MUL-4257 review): the batch claim is scoped to a
|
||
// single daemon. daemon_id is required so the server can reject any
|
||
// runtime_id that belongs to a different machine (guards against a stale /
|
||
// crossed runtime set claiming another daemon's tasks — which would land
|
||
// local_directory / machine-local work on the wrong host). For an mdt_
|
||
// token the body daemon_id must equal the token's daemon_id, so a
|
||
// workspace-scoped token can't spoof a peer.
|
||
if req.DaemonID == "" {
|
||
writeError(w, http.StatusBadRequest, "daemon_id is required")
|
||
return
|
||
}
|
||
if ctxDaemonID := middleware.DaemonIDFromContext(r.Context()); ctxDaemonID != "" && ctxDaemonID != req.DaemonID {
|
||
writeError(w, http.StatusForbidden, "daemon_id does not match token")
|
||
return
|
||
}
|
||
|
||
// max_tasks semantics (MUL-4257 review): a negative count is malformed; zero
|
||
// is a valid "no free slots" poll that must claim nothing — never coerce to
|
||
// 1, which would dispatch a task the daemon cannot run and strand it until
|
||
// stale reclaim. Positive counts are capped so one request can't build an
|
||
// unbounded payload.
|
||
if req.MaxTasks < 0 {
|
||
writeError(w, http.StatusBadRequest, "max_tasks must not be negative")
|
||
return
|
||
}
|
||
if req.MaxTasks == 0 {
|
||
writeMeasuredJSON(w, http.StatusOK, map[string]any{"tasks": []AgentTaskResponse{}})
|
||
return
|
||
}
|
||
maxTasks := req.MaxTasks
|
||
if maxTasks > claimBatchMaxTasksCap {
|
||
maxTasks = claimBatchMaxTasksCap
|
||
}
|
||
|
||
// Parse + de-dup requested ids with the NON-panicking parser (MUL-4257
|
||
// review): the handler-local parseUUID panics on malformed input, which
|
||
// would turn a single bad id into a 500. Invalid ids are skipped, matching
|
||
// this endpoint's "unknown id skipped" semantics. Key by the canonical uuid
|
||
// string so the post-claim lookup by task.RuntimeID always matches.
|
||
idByKey := make(map[string]pgtype.UUID, len(req.RuntimeIDs))
|
||
for _, rid := range req.RuntimeIDs {
|
||
ruid, err := util.ParseUUID(rid)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
idByKey[util.UUIDToString(ruid)] = ruid
|
||
}
|
||
if len(idByKey) == 0 {
|
||
writeMeasuredJSON(w, http.StatusOK, map[string]any{"tasks": []AgentTaskResponse{}})
|
||
return
|
||
}
|
||
ids := make([]pgtype.UUID, 0, len(idByKey))
|
||
for _, id := range idByKey {
|
||
ids = append(ids, id)
|
||
}
|
||
|
||
// Resolve all requested runtimes in one query (instead of a point lookup
|
||
// per runtime), then authorize each; skip (don't fail) unknown/unauthorized
|
||
// ids so a single stale runtime can't sink the whole batch.
|
||
runtimes, err := h.Queries.GetAgentRuntimes(r.Context(), ids)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to load runtimes")
|
||
return
|
||
}
|
||
runtimeByID := make(map[string]db.AgentRuntime, len(runtimes))
|
||
authorized := make([]pgtype.UUID, 0, len(runtimes))
|
||
for _, rt := range runtimes {
|
||
if !h.verifyDaemonWorkspaceAccess(r, uuidToString(rt.WorkspaceID)) {
|
||
continue
|
||
}
|
||
// Group-ownership check (mirrors the WS path, daemon_ws.go): a runtime
|
||
// bound to a different daemon must not be claimed by this one. Runtimes
|
||
// with a NULL daemon_id (e.g. cloud runtimes) are not machine-pinned, so
|
||
// they stay claimable — same tolerance as the WS handler.
|
||
if rt.DaemonID.Valid && rt.DaemonID.String != req.DaemonID {
|
||
continue
|
||
}
|
||
runtimeByID[uuidToString(rt.ID)] = rt
|
||
authorized = append(authorized, rt.ID)
|
||
}
|
||
if len(authorized) == 0 {
|
||
writeMeasuredJSON(w, http.StatusOK, map[string]any{"tasks": []AgentTaskResponse{}})
|
||
return
|
||
}
|
||
|
||
claimed, err := h.TaskService.ClaimTasksForRuntimes(r.Context(), authorized, maxTasks)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to claim tasks: "+err.Error())
|
||
return
|
||
}
|
||
|
||
out := make([]AgentTaskResponse, 0, len(claimed))
|
||
for i := range claimed {
|
||
task := claimed[i]
|
||
rt, ok := runtimeByID[uuidToString(task.RuntimeID)]
|
||
if !ok {
|
||
// Service guards claims to the authorized set; a miss here would be
|
||
// a stray cross-daemon claim. Leave it for the owning daemon's
|
||
// reclaim path rather than shipping it to the wrong machine.
|
||
continue
|
||
}
|
||
rtWorkspaceID := uuidToString(rt.WorkspaceID)
|
||
// Stale comment-plan repair must run for the batch path too: otherwise a
|
||
// task whose trigger was deleted (only coalesced survive) would be
|
||
// finalized+dispatched with no comment input, silently dropping the
|
||
// surviving user comment. On repair (or hard failure) the task is
|
||
// cancelled / left for reclaim and omitted from the batch.
|
||
if handled, _ := h.repairStaleCommentPlanIfNeeded(r.Context(), &task, rtWorkspaceID); handled {
|
||
continue
|
||
}
|
||
resp, deliveredCommentIDs, _, _, failure := h.buildClaimedTaskResponse(r, &task, rt, uuidToString(task.RuntimeID), rtWorkspaceID)
|
||
if failure != nil {
|
||
// Builder rejected this task (workspace isolation / chat-input);
|
||
// it has already cancelled the task where the failure requires it.
|
||
// Skip it — non-cancelling failures leave the task dispatched for
|
||
// the reclaim path.
|
||
continue
|
||
}
|
||
if !rt.OwnerID.Valid {
|
||
slog.Error("batch claim: runtime owner missing; cancelling task to avoid unscoped agent credentials",
|
||
"task_id", uuidToString(task.ID), "runtime_id", uuidToString(task.RuntimeID))
|
||
if _, cerr := h.TaskService.CancelTask(r.Context(), task.ID); cerr != nil {
|
||
slog.Error("batch claim: cancel after missing runtime owner failed",
|
||
"task_id", uuidToString(task.ID), "error", cerr)
|
||
}
|
||
continue
|
||
}
|
||
tokenStr, terr := auth.GenerateAgentTaskToken()
|
||
if terr != nil {
|
||
slog.Error("batch claim: generate task token failed; requeueing claim",
|
||
"task_id", uuidToString(task.ID), "error", terr)
|
||
if _, rerr := h.TaskService.RequeueTaskAfterClaimFailure(r.Context(), task); rerr != nil {
|
||
slog.Error("batch claim: requeue after token-gen failure failed",
|
||
"task_id", uuidToString(task.ID), "error", rerr)
|
||
}
|
||
continue
|
||
}
|
||
// Route through the SAME finalization as the per-runtime endpoint so the
|
||
// token and the comment-delivery receipt (delivered_comment_ids for
|
||
// comment/coalesced-comment tasks) are persisted atomically; on failure
|
||
// the exact claim is requeued and omitted from this batch.
|
||
commentBackedTask := task.TriggerCommentID.Valid || len(task.CoalescedCommentIds) > 0
|
||
receipt, ferr := h.TaskService.FinalizeTaskClaim(r.Context(), task, db.CreateTaskTokenParams{
|
||
TokenHash: auth.HashToken(tokenStr),
|
||
TaskID: task.ID,
|
||
AgentID: task.AgentID,
|
||
WorkspaceID: parseUUID(resp.WorkspaceID),
|
||
UserID: rt.OwnerID,
|
||
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true},
|
||
}, deliveredCommentIDs, commentBackedTask)
|
||
if ferr != nil {
|
||
slog.Error("batch claim: finalize task claim failed; requeueing claim",
|
||
"task_id", uuidToString(task.ID), "error", ferr)
|
||
if _, rerr := h.TaskService.RequeueTaskAfterClaimFailure(r.Context(), task); rerr != nil {
|
||
slog.Error("batch claim: requeue after finalize failure failed",
|
||
"task_id", uuidToString(task.ID), "error", rerr)
|
||
}
|
||
continue
|
||
}
|
||
resp.AuthToken = tokenStr
|
||
resp.DeliveredCommentIDs = uuidStringsOrEmpty(receipt)
|
||
out = append(out, resp)
|
||
}
|
||
|
||
if len(out) > 0 {
|
||
slog.Info("tasks claimed by runtime batch",
|
||
"runtimes", len(authorized), "requested_max", maxTasks, "claimed", len(out),
|
||
"total_ms", time.Since(start).Milliseconds())
|
||
}
|
||
writeMeasuredJSON(w, http.StatusOK, map[string]any{"tasks": out})
|
||
}
|
||
|
||
// claimBuildFailure captures a pre-response failure from
|
||
// buildClaimedTaskResponse (workspace isolation, chat-input load/empty, ...) so
|
||
// the per-runtime handler can render the exact status/message/outcome and the
|
||
// batch handler can skip the task. Any task cancellation is already performed
|
||
// inside the builder before it returns one.
|
||
type claimBuildFailure struct {
|
||
outcome string
|
||
status int
|
||
message string
|
||
}
|
||
|
||
// buildClaimedTaskResponse assembles the full daemon claim payload for a
|
||
// single already-claimed task and computes the exact comment ids embedded in
|
||
// it (deliveredCommentIDs). Shared by the per-runtime handler
|
||
// (ClaimTaskByRuntime) and the machine-level batch handler
|
||
// (ClaimTasksByRuntime, MUL-4257) so both build byte-identical payloads and
|
||
// feed the same delivery receipt into FinalizeTaskClaim. A non-nil failure
|
||
// means the task must not be dispatched; the builder has already cancelled it
|
||
// where the failure semantics require it.
|
||
func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQueue, runtime db.AgentRuntime, runtimeID, runtimeWorkspaceID string) (resp AgentTaskResponse, deliveredCommentIDs []pgtype.UUID, agentSkillCount, builtinSkillCount int, failure *claimBuildFailure) {
|
||
// Build response with fresh agent data (name + skills + custom_env + custom_args).
|
||
resp = taskToResponse(*task, runtimeWorkspaceID)
|
||
supportsCoalescedComments := requestHasClientCapability(r, protocol.DaemonCapabilityCoalescedCommentsV1)
|
||
// Empty-but-non-nil so pgx persists '{}' rather than NULL for tasks without
|
||
// comment input. Comment tasks replace this with the ids actually embedded
|
||
// in the capability-aware response built below.
|
||
deliveredCommentIDs = []pgtype.UUID{}
|
||
composioMCPEnabled := h.composioMCPAppsEnabled(r.Context())
|
||
if composioMCPEnabled {
|
||
resp.ConnectedApps = parseRuntimeConnectedAppsForClaim(task.RuntimeConnectedApps, task.ID)
|
||
}
|
||
if agent, err := h.Queries.GetAgent(r.Context(), task.AgentID); err == nil {
|
||
useSkillRefs := requestHasClientCapability(r, protocol.DaemonCapabilitySkillBundlesV1)
|
||
var customEnv map[string]string
|
||
if agent.CustomEnv != nil {
|
||
if err := json.Unmarshal(agent.CustomEnv, &customEnv); err != nil {
|
||
slog.Warn("failed to unmarshal agent custom_env", "agent_id", uuidToString(agent.ID), "error", err)
|
||
}
|
||
}
|
||
var customArgs []string
|
||
if agent.CustomArgs != nil {
|
||
if err := json.Unmarshal(agent.CustomArgs, &customArgs); err != nil {
|
||
slog.Warn("failed to unmarshal agent custom_args", "agent_id", uuidToString(agent.ID), "error", err)
|
||
}
|
||
}
|
||
var mcpConfig json.RawMessage
|
||
if agent.McpConfig != nil {
|
||
mcpConfig = json.RawMessage(agent.McpConfig)
|
||
}
|
||
// Layer the per-task overlay (set at enqueue from the initiator
|
||
// user's active integrations — currently Composio) on top of the
|
||
// agent's saved mcp_config. Overlay wins on server-name collisions
|
||
// because it carries the live user-scoped session URL. Errors are
|
||
// logged but never fail the claim: a broken overlay must not prevent
|
||
// the agent from running with its base config.
|
||
if composioMCPEnabled && len(task.RuntimeMcpOverlay) > 0 {
|
||
if merged, err := mergeMCPOverlay(mcpConfig, json.RawMessage(task.RuntimeMcpOverlay)); err != nil {
|
||
slog.Warn("daemon claim: merge runtime_mcp_overlay failed; falling back to agent mcp_config", "task_id", uuidToString(task.ID), "error", err)
|
||
} else {
|
||
mcpConfig = merged
|
||
}
|
||
}
|
||
// runtime_config is stored as JSONB and may legitimately be the
|
||
// empty object `{}` for agents that haven't opted into any
|
||
// provider-specific tuning. Forward only non-empty payloads so the
|
||
// daemon's per-provider decoders treat absent-or-empty identically.
|
||
var runtimeConfig json.RawMessage
|
||
if rc := bytes.TrimSpace(agent.RuntimeConfig); len(rc) > 0 && !bytes.Equal(rc, []byte("{}")) && !bytes.Equal(rc, []byte("null")) {
|
||
runtimeConfig = json.RawMessage(agent.RuntimeConfig)
|
||
}
|
||
resp.Agent = &TaskAgentData{
|
||
ID: uuidToString(agent.ID),
|
||
Name: agent.Name,
|
||
Instructions: agent.Instructions,
|
||
CustomEnv: customEnv,
|
||
CustomArgs: customArgs,
|
||
McpConfig: mcpConfig,
|
||
Model: agent.Model.String,
|
||
ThinkingLevel: agent.ThinkingLevel.String,
|
||
RuntimeConfig: runtimeConfig,
|
||
}
|
||
if useSkillRefs {
|
||
_, skillRefs := h.TaskService.LoadAgentSkillBundles(r.Context(), task.AgentID)
|
||
agentSkillCount = len(skillRefs)
|
||
resp.Agent.SkillRefs = skillRefs
|
||
} else {
|
||
skills := h.TaskService.LoadAgentSkills(r.Context(), task.AgentID)
|
||
agentSkillCount = len(skills)
|
||
builtinSkills := h.TaskService.BuiltinSkills()
|
||
builtinSkillCount = len(builtinSkills)
|
||
skills = append(skills, builtinSkills...)
|
||
resp.Agent.Skills = skills
|
||
}
|
||
}
|
||
|
||
// Resolve the runtime owner's profile description so the daemon can
|
||
// inject "## Requesting User" into the brief. Empty fields short-circuit
|
||
// the heading entirely on the daemon side; cloud / system runtimes with
|
||
// no owner stay anonymous. Failure here must not block claim — the agent
|
||
// can still run without the user-context section.
|
||
if runtime.OwnerID.Valid {
|
||
if owner, err := h.Queries.GetUser(r.Context(), runtime.OwnerID); err == nil {
|
||
resp.RequestingUserName = owner.Name
|
||
resp.RequestingUserProfileDescription = owner.ProfileDescription
|
||
} else {
|
||
slog.Debug("failed to load runtime owner for brief injection",
|
||
"runtime_id", runtimeID,
|
||
"owner_id", uuidToString(runtime.OwnerID),
|
||
"error", err,
|
||
)
|
||
}
|
||
}
|
||
|
||
// Stored task initiator: chat tasks persist the real message sender at
|
||
// enqueue time (web: request user; Lark: inbound sender — NOT the chat
|
||
// session creator, which for Lark groups is the installer). When set, it is
|
||
// the authoritative initiator for this run; resolve the live name/email so
|
||
// the daemon can render `## Task Initiator`. Comment-triggered tasks instead
|
||
// resolve their initiator from the triggering comment's author below; the
|
||
// two paths are mutually exclusive (a task is either chat or issue-bound).
|
||
// See MUL-2645.
|
||
if task.InitiatorUserID.Valid {
|
||
resp.InitiatorType = "member"
|
||
resp.InitiatorID = uuidToString(task.InitiatorUserID)
|
||
if u, err := h.Queries.GetUser(r.Context(), task.InitiatorUserID); err == nil {
|
||
resp.InitiatorName = u.Name
|
||
resp.InitiatorEmail = u.Email
|
||
}
|
||
}
|
||
|
||
// Include workspace ID and repos so the daemon can set up worktrees.
|
||
//
|
||
// Repo precedence: project-bound github_repo resources override workspace
|
||
// repos when present. Mixing both would just confuse the agent — if a
|
||
// project explicitly attached its repos, those are the authoritative set
|
||
// for issues inside that project. When the project has no github_repo
|
||
// resources (or no project at all), we fall back to the workspace repos.
|
||
if task.IssueID.Valid {
|
||
if issue, err := h.Queries.GetIssue(r.Context(), task.IssueID); err == nil {
|
||
resp.WorkspaceID = uuidToString(issue.WorkspaceID)
|
||
resp.ThreadName = issue.Title
|
||
|
||
// Squad-leader briefing injection: keyed off the task being a
|
||
// leader-task (is_leader_task) carrying a squad_id — NOT off the
|
||
// issue being assigned to a squad. The task flag is stamped at
|
||
// enqueue time and is true for every ISSUE-BOUND path that routes
|
||
// work to a squad leader: direct assign-to-squad, comment
|
||
// @squad-mention (even when the issue itself is assigned to a
|
||
// plain agent — the MUL-3724 case), sub-issue done callback,
|
||
// autopilot squad-assignee, and retry-clone inheritance. The old
|
||
// issue.AssigneeType=="squad" gate missed the comment-mention
|
||
// path, so the leader booted with zero squad context and
|
||
// degraded into doing the work itself instead of orchestrating.
|
||
//
|
||
// NOTE: quick-create tasks do NOT reach this block — they have a
|
||
// NULL issue_id (so the enclosing `task.IssueID.Valid` is false)
|
||
// and do NOT carry is_leader_task / squad_id columns. They route
|
||
// their squad through the task CONTEXT JSON (QuickCreateContext.
|
||
// SquadID) and get their briefing from the separate quick-create
|
||
// branch further below (search `qc.SquadID`). Do not "unify" the
|
||
// two by deleting that branch: it also sets resp.SquadID /
|
||
// resp.SquadName so the new issue defaults to the squad assignee,
|
||
// and there is no issue row to hang this column-based path on.
|
||
//
|
||
// We resolve the squad directly from task.SquadID rather than
|
||
// reverse-looking-up "which squad is this agent the leader of",
|
||
// which is ambiguous when one agent leads multiple squads. The
|
||
// uuidToString(squad.LeaderID) == resp.Agent.ID re-check is kept
|
||
// as a defensive gate: if the squad's leader was swapped after the
|
||
// task was enqueued, we never feed a stale briefing to a
|
||
// non-leader. It also doubles as the dangling-squad_id guard: a
|
||
// squad hard-deleted after enqueue makes GetSquadInWorkspace
|
||
// return no row (err != nil) — we skip injection silently, which
|
||
// is exactly the same observable result as "condition not
|
||
// matched". Claim still succeeds; no stale briefing is emitted.
|
||
// (No FK on squad_id — see migration 127.) We append (not replace)
|
||
// so per-agent instructions stay authoritative; the squad briefing
|
||
// stacks on top as task-specific squad context.
|
||
if resp.Agent != nil && task.IsLeaderTask && task.SquadID.Valid {
|
||
if squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
|
||
ID: task.SquadID,
|
||
WorkspaceID: issue.WorkspaceID,
|
||
}); err == nil && uuidToString(squad.LeaderID) == resp.Agent.ID {
|
||
briefing := buildSquadLeaderBriefing(r.Context(), h.Queries, squad)
|
||
if strings.TrimSpace(resp.Agent.Instructions) == "" {
|
||
resp.Agent.Instructions = briefing
|
||
} else {
|
||
resp.Agent.Instructions = resp.Agent.Instructions + "\n\n" + briefing
|
||
}
|
||
slog.Debug("injected squad leader briefing",
|
||
"squad_id", uuidToString(squad.ID),
|
||
"squad_name", squad.Name,
|
||
"leader_agent_id", resp.Agent.ID,
|
||
)
|
||
}
|
||
}
|
||
|
||
var projectRepos []RepoData
|
||
if issue.ProjectID.Valid {
|
||
resp.ProjectID = uuidToString(issue.ProjectID)
|
||
if proj, err := h.Queries.GetProject(r.Context(), issue.ProjectID); err == nil {
|
||
resp.ProjectTitle = proj.Title
|
||
resp.ProjectDescription = proj.Description.String
|
||
}
|
||
if rows := h.listProjectResourcesForProject(r.Context(), issue.ProjectID); len(rows) > 0 {
|
||
out := make([]ProjectResourceData, 0, len(rows))
|
||
for _, row := range rows {
|
||
label := ""
|
||
if row.Label.Valid {
|
||
label = row.Label.String
|
||
}
|
||
ref := json.RawMessage(row.ResourceRef)
|
||
if len(ref) == 0 {
|
||
ref = json.RawMessage("{}")
|
||
}
|
||
out = append(out, ProjectResourceData{
|
||
ID: uuidToString(row.ID),
|
||
ResourceType: row.ResourceType,
|
||
ResourceRef: ref,
|
||
Label: label,
|
||
})
|
||
// Lift github_repo resources into the daemon's repo list
|
||
// so `multica repo checkout` and the meta-skill render
|
||
// them as the issue's repos.
|
||
if row.ResourceType == "github_repo" {
|
||
var payload struct {
|
||
URL string `json:"url"`
|
||
Ref string `json:"ref,omitempty"`
|
||
}
|
||
if json.Unmarshal(row.ResourceRef, &payload) == nil && payload.URL != "" {
|
||
projectRepos = append(projectRepos, RepoData{URL: payload.URL, Ref: strings.TrimSpace(payload.Ref)})
|
||
}
|
||
}
|
||
}
|
||
resp.ProjectResources = out
|
||
}
|
||
}
|
||
|
||
if len(projectRepos) > 0 {
|
||
resp.Repos = projectRepos
|
||
} else if ws, err := h.Queries.GetWorkspace(r.Context(), issue.WorkspaceID); err == nil && ws.Repos != nil {
|
||
var repos []RepoData
|
||
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
|
||
resp.Repos = repos
|
||
}
|
||
}
|
||
}
|
||
|
||
// Load every planned input as one chronological, de-duplicated set.
|
||
// The trigger is included here so the delivery receipt can only contain
|
||
// comments whose body we successfully embedded. Missing/deleted rows are
|
||
// intentionally absent and remain eligible for reconciliation. A stable
|
||
// payload budget always keeps the primary trigger, then admits an oldest-
|
||
// first prefix of additional comments; overflow is reconciled later.
|
||
// Workspace-scoped load (MUL-4252) so a foreign comment UUID resolves to
|
||
// "missing" instead of leaking another tenant's text into the prompt.
|
||
plannedCommentIDs := append([]pgtype.UUID{}, task.CoalescedCommentIds...)
|
||
if task.TriggerCommentID.Valid {
|
||
plannedCommentIDs = append(plannedCommentIDs, task.TriggerCommentID)
|
||
}
|
||
loadedComments := h.buildCoalescedCommentData(r.Context(), runtime.WorkspaceID, plannedCommentIDs)
|
||
triggerCommentID := uuidToString(task.TriggerCommentID)
|
||
var deliveredComments []CoalescedCommentData
|
||
triggerLoaded := false
|
||
for _, comment := range loadedComments {
|
||
if comment.ID == triggerCommentID {
|
||
triggerLoaded = true
|
||
break
|
||
}
|
||
}
|
||
if task.TriggerCommentID.Valid && triggerLoaded {
|
||
deliveredComments = selectCommentDelivery(
|
||
loadedComments,
|
||
triggerCommentID,
|
||
!supportsCoalescedComments,
|
||
maxClaimCommentPayloadBytes,
|
||
)
|
||
}
|
||
// If the persisted trigger body cannot be loaded, fail closed on comment
|
||
// coverage for this claim. The trigger snapshot CAS below also rejects a
|
||
// concurrent edit/delete that changes the FK after this read.
|
||
deliveredCommentIDs = commentDataIDs(deliveredComments)
|
||
// taskToResponse exposes the enqueue plan to UI task-list callers. A
|
||
// daemon claim must instead advertise only the structured ids actually
|
||
// present in this payload, especially when the delivery budget truncates.
|
||
resp.CoalescedCommentIDs = nil
|
||
for _, comment := range deliveredComments {
|
||
if comment.ID == triggerCommentID {
|
||
// Populate the actual payload from the same successful read that
|
||
// earned the receipt. The richer GetComment lookup below resolves
|
||
// initiator ids and count hints, but a transient second-read failure
|
||
// must never acknowledge a body that was not embedded.
|
||
resp.TriggerCommentContent = comment.Content
|
||
resp.TriggerThreadID = comment.ThreadID
|
||
resp.TriggerAuthorType = comment.AuthorType
|
||
resp.TriggerAuthorName = comment.AuthorName
|
||
continue
|
||
}
|
||
resp.CoalescedCommentIDs = append(resp.CoalescedCommentIDs, comment.ID)
|
||
resp.CoalescedComments = append(resp.CoalescedComments, comment)
|
||
}
|
||
|
||
// Fetch the triggering comment content so the daemon can embed it
|
||
// directly in the agent prompt (prevents the agent from ignoring comments
|
||
// when stale output files exist in a reused workdir). Also surface the
|
||
// comment author's kind and display name so the agent knows whether it
|
||
// was triggered by a human or by another agent — a signal used by the
|
||
// harness instructions to avoid mention loops between agents.
|
||
effectiveTriggerUUID := task.TriggerCommentID
|
||
if effectiveTriggerUUID.Valid {
|
||
// Scope by the runtime's workspace so a task row carrying a foreign
|
||
// comment UUID can never pull another workspace's comment text into
|
||
// this agent's prompt. The task's issue workspace is asserted equal
|
||
// to runtime.WorkspaceID below, so this is the right tenant (MUL-4252).
|
||
if comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
|
||
ID: effectiveTriggerUUID,
|
||
WorkspaceID: runtime.WorkspaceID,
|
||
}); err == nil {
|
||
resp.TriggerCommentContent = comment.Content
|
||
resp.TriggerThreadID = uuidToString(comment.ID)
|
||
if comment.ParentID.Valid {
|
||
resp.TriggerThreadID = uuidToString(comment.ParentID)
|
||
}
|
||
resp.TriggerAuthorType = comment.AuthorType
|
||
// The triggering comment's author is the task initiator — the
|
||
// real requester behind this run. Surface it (type + id + name,
|
||
// plus email for members) so a workspace-visible agent can
|
||
// attribute the request to the right person instead of to the
|
||
// runtime owner. Same lookups as the display name above; we just
|
||
// also capture the id and email. See MUL-2645.
|
||
resp.InitiatorType = comment.AuthorType
|
||
if comment.AuthorID.Valid {
|
||
resp.InitiatorID = uuidToString(comment.AuthorID)
|
||
}
|
||
switch comment.AuthorType {
|
||
case "agent":
|
||
if comment.AuthorID.Valid {
|
||
if a, err := h.Queries.GetAgent(r.Context(), comment.AuthorID); err == nil {
|
||
resp.TriggerAuthorName = a.Name
|
||
resp.InitiatorName = a.Name
|
||
}
|
||
}
|
||
case "member":
|
||
// For member-authored comments, AuthorID is a user UUID
|
||
// (see handler.resolveActor) — look up the user's display name.
|
||
if comment.AuthorID.Valid {
|
||
if u, err := h.Queries.GetUser(r.Context(), comment.AuthorID); err == nil {
|
||
resp.TriggerAuthorName = u.Name
|
||
resp.InitiatorName = u.Name
|
||
resp.InitiatorEmail = u.Email
|
||
}
|
||
}
|
||
}
|
||
// Count comments that arrived issue-wide since this agent's last
|
||
// run, so the daemon can tell it the full catch-up volume up front
|
||
// (the prompt then steers it to read the triggering thread first).
|
||
// Anchor = the prior task's started_at (never completed_at: a long
|
||
// run would miss comments posted while it ran). Cold start (no prior
|
||
// task) → no anchor → no hint. Excludes the agent's own comments and
|
||
// the triggering comment itself because that body is already
|
||
// injected into the prompt. Best-effort: any DB error or zero count
|
||
// leaves the hint suppressed.
|
||
if startedAt, err := h.Queries.GetLastTaskStartedAtForIssueAndAgent(r.Context(), db.GetLastTaskStartedAtForIssueAndAgentParams{
|
||
AgentID: task.AgentID,
|
||
IssueID: comment.IssueID,
|
||
}); err == nil && startedAt.Valid {
|
||
if cnt, err := h.Queries.CountNewCommentsSince(r.Context(), db.CountNewCommentsSinceParams{
|
||
AnchorID: effectiveTriggerUUID,
|
||
IssueID: comment.IssueID,
|
||
WorkspaceID: comment.WorkspaceID,
|
||
Since: startedAt,
|
||
AuthorID: task.AgentID,
|
||
}); err == nil && cnt > 0 {
|
||
resp.NewCommentCount = int(cnt)
|
||
resp.NewCommentsSince = startedAt.Time.UTC().Format(time.RFC3339)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !supportsCoalescedComments {
|
||
// Legacy daemons ignore the structured coalesced fields. Fold every
|
||
// successfully loaded comment into the one trigger field they already
|
||
// understand, then hide the structured fields to avoid duplicate prompt
|
||
// sections in intermediate daemons that understand them but do not yet
|
||
// advertise the capability.
|
||
if len(resp.CoalescedComments) > 0 || (resp.TriggerCommentContent == "" && len(deliveredComments) > 0) {
|
||
resp.TriggerCommentContent = formatLegacyCommentBundle(deliveredComments)
|
||
}
|
||
resp.CoalescedCommentIDs = nil
|
||
resp.CoalescedComments = nil
|
||
} else if resp.TriggerCommentContent == "" && len(deliveredComments) > 0 {
|
||
// A deleted newest trigger must not suppress the structured earlier
|
||
// comments: buildCommentPrompt renders them inside its trigger-content
|
||
// branch. The missing id itself is not acknowledged in the receipt.
|
||
resp.TriggerCommentContent = "The newest triggering comment is no longer available. Address every earlier comment included below."
|
||
}
|
||
|
||
// Look up the prior session for this (agent, issue) pair so the daemon
|
||
// can resume the Claude Code conversation context.
|
||
//
|
||
// Skip all prior state when the task was flagged as a manual rerun:
|
||
// the user just judged the prior output bad, so the daemon must start a
|
||
// fresh agent session in a fresh workdir instead of resuming anything
|
||
// from the same conversation that produced that output.
|
||
if !task.ForceFreshSession {
|
||
if prior, err := h.Queries.GetLastTaskSession(r.Context(), db.GetLastTaskSessionParams{
|
||
AgentID: task.AgentID,
|
||
IssueID: task.IssueID,
|
||
}); err == nil && prior.SessionID.Valid {
|
||
// Resume the prior session when it ran on the same runtime —
|
||
// including comment-triggered follow-ups, so the agent keeps the
|
||
// issue's conversation context across turns. The "Focus on THIS
|
||
// comment" guard in prompt.go defends against inheriting the prior
|
||
// turn's "Done." marker, and GetLastTaskSession already excludes
|
||
// poisoned sessions.
|
||
if prior.RuntimeID == task.RuntimeID {
|
||
resp.PriorSessionID = prior.SessionID.String
|
||
}
|
||
if prior.WorkDir.Valid {
|
||
resp.PriorWorkDir = prior.WorkDir.String
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Chat task: populate workspace/session info from the chat_session table.
|
||
if task.ChatSessionID.Valid {
|
||
if cs, err := h.Queries.GetChatSession(r.Context(), task.ChatSessionID); err == nil {
|
||
resp.WorkspaceID = uuidToString(cs.WorkspaceID)
|
||
resp.ChatSessionID = uuidToString(cs.ID)
|
||
resp.ThreadName = cs.Title
|
||
// An is_agent_intro session carries no user message: the agent opens
|
||
// the conversation by introducing itself. Flag it so the daemon builds
|
||
// a self-introduction prompt rather than a "reply to their message"
|
||
// prompt (MUL-4230). The is_agent_intro column stays true for the
|
||
// session's whole life, so gate the intro prompt on the session still
|
||
// having zero human messages — otherwise every follow-up turn after the
|
||
// creator replies would re-run the "introduce yourself" prompt and the
|
||
// agent keeps repeating the same introduction (MUL-4259).
|
||
if cs.IsAgentIntro {
|
||
if hasUser, herr := h.Queries.ChatSessionHasUserMessage(r.Context(), cs.ID); herr != nil {
|
||
slog.Warn("chat intro gate: has-user-message check failed",
|
||
"chat_session_id", uuidToString(cs.ID), "error", herr)
|
||
} else {
|
||
resp.ChatIntro = !hasUser
|
||
}
|
||
}
|
||
// Flag a channel-backed session so the daemon makes the agent aware
|
||
// it is operating inside Slack — read this conversation's history
|
||
// from the channel via `multica chat history` / `multica chat thread`,
|
||
// not from Multica (MUL-3871). Empty for a web-only chat session.
|
||
// ChatInThread tells the agent which command to start with: the
|
||
// latest trigger was a thread reply iff its reply-target thread
|
||
// (last_thread_id) differs from its own message id (a top-level
|
||
// @mention records its own ts as both).
|
||
if binding, berr := h.Queries.GetChannelChatSessionBindingBySession(r.Context(), db.GetChannelChatSessionBindingBySessionParams{
|
||
ChatSessionID: cs.ID,
|
||
ChannelType: string(slack.TypeSlack),
|
||
}); berr == nil {
|
||
resp.ChatChannelType = string(slack.TypeSlack)
|
||
resp.ChatInThread = binding.LastThreadID.Valid && binding.LastThreadID.String != "" &&
|
||
binding.LastThreadID.String != binding.LastMessageID.String
|
||
}
|
||
if ws, err := h.Queries.GetWorkspace(r.Context(), cs.WorkspaceID); err == nil && ws.Repos != nil {
|
||
var repos []RepoData
|
||
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
|
||
resp.Repos = repos
|
||
}
|
||
}
|
||
if !task.ForceFreshSession {
|
||
// Resume chat sessions only when the stored pointer was produced
|
||
// by the same runtime as the claiming task. When the chat_session
|
||
// pointer is missing (legacy NULL runtime_id), stale (last task
|
||
// failed before reporting completion), or runtime-mismatched, fall
|
||
// back to the most recent task row that recorded a session_id —
|
||
// otherwise a single failed turn would silently drop the entire
|
||
// conversation memory on the next message. The fallback also
|
||
// requires runtime to match.
|
||
if cs.SessionID.Valid && cs.RuntimeID.Valid && cs.RuntimeID == task.RuntimeID {
|
||
resp.PriorSessionID = cs.SessionID.String
|
||
}
|
||
if cs.WorkDir.Valid {
|
||
resp.PriorWorkDir = cs.WorkDir.String
|
||
}
|
||
if prior, err := h.Queries.GetLastChatTaskSession(r.Context(), cs.ID); err == nil && prior.SessionID.Valid {
|
||
if resp.PriorSessionID == "" && prior.RuntimeID == task.RuntimeID {
|
||
resp.PriorSessionID = prior.SessionID.String
|
||
}
|
||
if prior.WorkDir.Valid && resp.PriorWorkDir == "" {
|
||
resp.PriorWorkDir = prior.WorkDir.String
|
||
}
|
||
}
|
||
}
|
||
// Resolve the user-message input batch for this run. A task-owned
|
||
// direct-chat task (chat_input_task_id set, MUL-4351) reads exactly
|
||
// the user messages tagged with its own input owner, so a message
|
||
// that arrived after this turn was sealed can never be absorbed here.
|
||
// Legacy and channel (Slack/Lark) tasks carry a NULL owner and keep
|
||
// the trailing-message selector — the run of user messages after the
|
||
// last assistant row, which also covers a debounced burst (MUL-2968:
|
||
// "看上海天气" then "还有青岛" must both be delivered) — so a rolling
|
||
// deploy never replays their history. Attachments are collected per
|
||
// included message so the agent can `multica attachment download <id>`
|
||
// (the inline markdown URL is signed + 30-min expiring on the CDN).
|
||
var unanswered []db.ChatMessage
|
||
var inputLoadErr error
|
||
if task.ChatInputTaskID.Valid {
|
||
unanswered, inputLoadErr = h.Queries.ListChatInputMessages(r.Context(), task.ChatInputTaskID)
|
||
} else if msgs, err := h.Queries.ListChatMessages(r.Context(), cs.ID); err == nil {
|
||
unanswered = trailingUserMessages(msgs)
|
||
} else {
|
||
inputLoadErr = err
|
||
}
|
||
// A read failure must NOT masquerade as "zero input". Preserve the
|
||
// just-dispatched task (the stale-dispatched reclaim redelivers it)
|
||
// and reject the claim with 5xx, rather than cancelling a valid direct
|
||
// task on a transient DB error (MUL-4351 review).
|
||
if inputLoadErr != nil {
|
||
slog.Error("chat claim: load chat input messages failed; preserving task for redelivery",
|
||
"task_id", uuidToString(task.ID),
|
||
"chat_session_id", uuidToString(cs.ID),
|
||
"error", inputLoadErr)
|
||
return resp, deliveredCommentIDs, agentSkillCount, builtinSkillCount, &claimBuildFailure{
|
||
outcome: "error_chat_input_load",
|
||
status: http.StatusInternalServerError,
|
||
message: "failed to load chat input",
|
||
}
|
||
}
|
||
|
||
parts := make([]string, 0, len(unanswered))
|
||
for _, m := range unanswered {
|
||
if strings.TrimSpace(m.Content) != "" {
|
||
parts = append(parts, m.Content)
|
||
}
|
||
if atts, attErr := h.Queries.ListAttachmentsByChatMessage(r.Context(), db.ListAttachmentsByChatMessageParams{
|
||
ChatMessageID: m.ID,
|
||
WorkspaceID: parseUUID(resp.WorkspaceID),
|
||
}); attErr == nil && len(atts) > 0 {
|
||
for _, a := range atts {
|
||
resp.ChatMessageAttachments = append(resp.ChatMessageAttachments, ChatAttachmentMeta{
|
||
ID: uuidToString(a.ID),
|
||
Filename: a.Filename,
|
||
ContentType: a.ContentType,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
resp.ChatMessage = strings.Join(parts, "\n\n")
|
||
|
||
// Fail closed: a task-owned direct task that resolves to no user text
|
||
// (and is not the agent's proactive intro) must never dispatch an
|
||
// empty prompt. The send path creates the owning user message in the
|
||
// same transaction as the task, so this only fires on genuinely
|
||
// corrupt state — cancel the just-dispatched task and reject the claim
|
||
// rather than run the agent with nothing to answer (MUL-4351).
|
||
if task.ChatInputTaskID.Valid && !resp.ChatIntro && strings.TrimSpace(resp.ChatMessage) == "" {
|
||
slog.Error("chat claim: task-owned direct task has no user input; cancelling",
|
||
"task_id", uuidToString(task.ID),
|
||
"chat_session_id", uuidToString(cs.ID),
|
||
"chat_input_task_id", uuidToString(task.ChatInputTaskID),
|
||
)
|
||
if _, cerr := h.TaskService.CancelTask(r.Context(), task.ID); cerr != nil {
|
||
slog.Error("chat claim: cancel after empty input failed",
|
||
"task_id", uuidToString(task.ID), "error", cerr)
|
||
}
|
||
return resp, deliveredCommentIDs, agentSkillCount, builtinSkillCount, &claimBuildFailure{
|
||
outcome: "error_empty_chat_input",
|
||
status: http.StatusInternalServerError,
|
||
message: "chat task has no user input",
|
||
}
|
||
}
|
||
|
||
if strings.TrimSpace(resp.ThreadName) == "" && resp.ChatMessage != "" {
|
||
resp.ThreadName = resp.ChatMessage
|
||
}
|
||
}
|
||
}
|
||
|
||
// Autopilot run_only task: resolve workspace from autopilot_run →
|
||
// autopilot, and include the autopilot instructions because there is no
|
||
// issue for the agent to fetch.
|
||
if task.AutopilotRunID.Valid {
|
||
if run, err := h.Queries.GetAutopilotRun(r.Context(), task.AutopilotRunID); err == nil {
|
||
resp.AutopilotID = uuidToString(run.AutopilotID)
|
||
resp.AutopilotSource = run.Source
|
||
if run.TriggerPayload != nil {
|
||
resp.AutopilotTriggerPayload = json.RawMessage(run.TriggerPayload)
|
||
}
|
||
if ap, err := h.Queries.GetAutopilot(r.Context(), run.AutopilotID); err == nil {
|
||
resp.AutopilotTitle = ap.Title
|
||
resp.ThreadName = ap.Title
|
||
if ap.Description.Valid {
|
||
resp.AutopilotDescription = ap.Description.String
|
||
}
|
||
if resp.WorkspaceID == "" {
|
||
resp.WorkspaceID = uuidToString(ap.WorkspaceID)
|
||
}
|
||
if len(resp.Repos) == 0 {
|
||
if ws, err := h.Queries.GetWorkspace(r.Context(), ap.WorkspaceID); err == nil && ws.Repos != nil {
|
||
var repos []RepoData
|
||
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
|
||
resp.Repos = repos
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handoff note (MUL-3375) is populated by taskToResponse (the shared mapper
|
||
// resp came from above), so the daemon's prompt + issue_context.md render the
|
||
// assignment-handoff branch. Empty for all other task kinds.
|
||
|
||
// Quick-create task: no issue / chat / autopilot link — workspace and
|
||
// prompt come from the task's context JSONB. Resolve workspace from
|
||
// there so the isolation check below has something to compare.
|
||
hasQuickCreate := false
|
||
if task.Context != nil && !task.IssueID.Valid && !task.ChatSessionID.Valid && !task.AutopilotRunID.Valid {
|
||
var qc service.QuickCreateContext
|
||
if json.Unmarshal(task.Context, &qc) == nil && qc.Type == service.QuickCreateContextType {
|
||
hasQuickCreate = true
|
||
resp.QuickCreatePrompt = qc.Prompt
|
||
resp.QuickCreateAttachmentIDs = append([]string(nil), qc.AttachmentIDs...)
|
||
resp.ThreadName = qc.Prompt
|
||
resp.WorkspaceID = qc.WorkspaceID
|
||
|
||
// When the user picked a project in the modal, surface its title
|
||
// and resources to the daemon so the agent has the same context
|
||
// it would for an issue-bound task: the prompt template can name
|
||
// the project, and `multica repo checkout` sees the project's
|
||
// github_repo resources instead of the workspace fallback.
|
||
var projectRepos []RepoData
|
||
if qc.ProjectID != "" {
|
||
projectUUID, err := util.ParseUUID(qc.ProjectID)
|
||
if err == nil {
|
||
resp.ProjectID = qc.ProjectID
|
||
if proj, err := h.Queries.GetProject(r.Context(), projectUUID); err == nil {
|
||
resp.ProjectTitle = proj.Title
|
||
resp.ProjectDescription = proj.Description.String
|
||
}
|
||
if rows := h.listProjectResourcesForProject(r.Context(), projectUUID); len(rows) > 0 {
|
||
out := make([]ProjectResourceData, 0, len(rows))
|
||
for _, row := range rows {
|
||
label := ""
|
||
if row.Label.Valid {
|
||
label = row.Label.String
|
||
}
|
||
ref := json.RawMessage(row.ResourceRef)
|
||
if len(ref) == 0 {
|
||
ref = json.RawMessage("{}")
|
||
}
|
||
out = append(out, ProjectResourceData{
|
||
ID: uuidToString(row.ID),
|
||
ResourceType: row.ResourceType,
|
||
ResourceRef: ref,
|
||
Label: label,
|
||
})
|
||
if row.ResourceType == "github_repo" {
|
||
var payload struct {
|
||
URL string `json:"url"`
|
||
Ref string `json:"ref,omitempty"`
|
||
}
|
||
if json.Unmarshal(row.ResourceRef, &payload) == nil && payload.URL != "" {
|
||
projectRepos = append(projectRepos, RepoData{URL: payload.URL, Ref: strings.TrimSpace(payload.Ref)})
|
||
}
|
||
}
|
||
}
|
||
resp.ProjectResources = out
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(projectRepos) > 0 {
|
||
resp.Repos = projectRepos
|
||
} else if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(qc.WorkspaceID)); err == nil && ws.Repos != nil {
|
||
var repos []RepoData
|
||
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
|
||
resp.Repos = repos
|
||
}
|
||
}
|
||
|
||
// Parent-issue resolution for quick-create tasks opened from
|
||
// "Add sub issue". The handler already verified workspace
|
||
// membership at submit time; here we re-fetch to pull the
|
||
// human-readable identifier (e.g. MUL-123) the agent will
|
||
// reference in the prompt. If the parent was deleted between
|
||
// submit and claim we surface the UUID anyway — the agent
|
||
// still passes `--parent <uuid>` and the server-side create
|
||
// will fail loud, which is a better outcome than silently
|
||
// dropping the sub-issue intent.
|
||
if qc.ParentIssueID != "" {
|
||
resp.ParentIssueID = qc.ParentIssueID
|
||
if parentUUID, err := util.ParseUUID(qc.ParentIssueID); err == nil {
|
||
if wsUUID, wsErr := util.ParseUUID(qc.WorkspaceID); wsErr == nil {
|
||
parent, perr := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
||
ID: parentUUID,
|
||
WorkspaceID: wsUUID,
|
||
})
|
||
if perr == nil && parent.ID.Valid {
|
||
if ws, werr := h.Queries.GetWorkspace(r.Context(), wsUUID); werr == nil {
|
||
resp.ParentIssueIdentifier = ws.IssuePrefix + "-" + strconv.Itoa(int(parent.Number))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Squad-leader briefing injection for quick-create tasks. When
|
||
// the user picked a squad in the modal, the task runs on the
|
||
// squad's leader agent (resolved by the handler). Surface the
|
||
// same Operating Protocol + Roster + user Instructions that
|
||
// issue-bound squad tasks see, so the leader can decide to
|
||
// delegate before opening the issue.
|
||
if resp.Agent != nil && qc.SquadID != "" {
|
||
wsUUID, wsErr := util.ParseUUID(qc.WorkspaceID)
|
||
squadUUID, sqErr := util.ParseUUID(qc.SquadID)
|
||
if wsErr == nil && sqErr == nil {
|
||
if squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
|
||
ID: squadUUID,
|
||
WorkspaceID: wsUUID,
|
||
}); err == nil && uuidToString(squad.LeaderID) == resp.Agent.ID {
|
||
briefing := buildSquadLeaderBriefing(r.Context(), h.Queries, squad)
|
||
if strings.TrimSpace(resp.Agent.Instructions) == "" {
|
||
resp.Agent.Instructions = briefing
|
||
} else {
|
||
resp.Agent.Instructions = resp.Agent.Instructions + "\n\n" + briefing
|
||
}
|
||
// Surface the squad identity to the daemon so the
|
||
// quick-create prompt defaults the new issue's
|
||
// assignee to the squad, not the leader agent.
|
||
resp.SquadID = uuidToString(squad.ID)
|
||
resp.SquadName = squad.Name
|
||
slog.Debug("injected squad leader briefing for quick-create",
|
||
"squad_id", uuidToString(squad.ID),
|
||
"squad_name", squad.Name,
|
||
"leader_agent_id", resp.Agent.ID,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Workspace isolation check: the daemon uses this response's workspace_id
|
||
// as the only authority for MULTICA_WORKSPACE_ID in the agent env. An
|
||
// empty value would make the CLI silently fall back to the user-global
|
||
// config and talk to whatever workspace the user happened to last
|
||
// configure; a value that doesn't match the runtime's workspace means
|
||
// upstream routed a foreign-workspace task here. Both cases must hard-
|
||
// fail AND cancel the just-dispatched task so the queue / agent status
|
||
// don't sit stuck until the stale-task sweeper fires minutes later.
|
||
if resp.WorkspaceID == "" || resp.WorkspaceID != runtimeWorkspaceID {
|
||
slog.Error("task claim: workspace isolation check failed, cancelling task",
|
||
"task_id", uuidToString(task.ID),
|
||
"runtime_id", runtimeID,
|
||
"runtime_workspace", runtimeWorkspaceID,
|
||
"resolved_workspace", resp.WorkspaceID,
|
||
"has_issue", task.IssueID.Valid,
|
||
"has_chat", task.ChatSessionID.Valid,
|
||
"has_autopilot_run", task.AutopilotRunID.Valid,
|
||
"has_quick_create", hasQuickCreate,
|
||
)
|
||
if _, cerr := h.TaskService.CancelTask(r.Context(), task.ID); cerr != nil {
|
||
slog.Error("task claim: cancel after workspace check failed",
|
||
"task_id", uuidToString(task.ID), "error", cerr)
|
||
}
|
||
return resp, deliveredCommentIDs, agentSkillCount, builtinSkillCount, &claimBuildFailure{
|
||
outcome: "error_workspace",
|
||
status: http.StatusInternalServerError,
|
||
message: "task workspace isolation check failed",
|
||
}
|
||
}
|
||
|
||
// Workspace-level Context (workspace.context DB column) — the per-workspace
|
||
// system prompt that workspace owners set in Settings → General. Inject it
|
||
// into the brief regardless of task kind (issue / chat / autopilot /
|
||
// quick-create) so every agent running in the workspace sees the same
|
||
// shared context. Empty string when the owner hasn't set one; the daemon
|
||
// skips rendering the heading in that case.
|
||
if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(resp.WorkspaceID)); err == nil {
|
||
if ws.Context.Valid {
|
||
resp.WorkspaceContext = ws.Context.String
|
||
}
|
||
} else {
|
||
slog.Warn("task claim: failed to load workspace for context injection",
|
||
"task_id", uuidToString(task.ID),
|
||
"workspace_id", resp.WorkspaceID,
|
||
"error", err,
|
||
)
|
||
}
|
||
|
||
return resp, deliveredCommentIDs, agentSkillCount, builtinSkillCount, nil
|
||
}
|
||
|
||
// ClaimTaskByRuntime atomically claims the next queued task for a runtime.
|
||
// The response includes the agent's name and skills, fetched fresh from the DB.
|
||
func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||
runtimeID := chi.URLParam(r, "runtimeId")
|
||
start := time.Now()
|
||
|
||
var (
|
||
outcome = "unauth"
|
||
authMs, claimMs, buildMs int64
|
||
payloadBytes int
|
||
agentSkillCount int
|
||
builtinSkillCount int
|
||
skillPayloadBytes int
|
||
buildStart time.Time
|
||
)
|
||
defer func() {
|
||
// Emit at function exit so error / unauth paths also carry timing.
|
||
// build_ms is computed from buildStart only when we entered the
|
||
// response-build phase (otherwise stays 0).
|
||
if !buildStart.IsZero() {
|
||
buildMs = time.Since(buildStart).Milliseconds()
|
||
}
|
||
logClaimEndpointSlow(runtimeID, outcome, start, authMs, claimMs, buildMs, payloadBytes, agentSkillCount, builtinSkillCount, skillPayloadBytes)
|
||
}()
|
||
|
||
// Verify the caller owns this runtime's workspace. The runtime's
|
||
// workspace_id is the authoritative value a claimed task must match
|
||
// below — a task whose resolved workspace doesn't equal this runtime's
|
||
// workspace is rejected even if it was enqueued against this
|
||
// runtime_id (defense-in-depth against upstream routing bugs).
|
||
runtime, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID)
|
||
if !ok {
|
||
return
|
||
}
|
||
runtimeWorkspaceID := uuidToString(runtime.WorkspaceID)
|
||
authMs = time.Since(start).Milliseconds()
|
||
|
||
claimStart := time.Now()
|
||
task, err := h.TaskService.ClaimTaskForRuntime(r.Context(), parseUUID(runtimeID))
|
||
claimMs = time.Since(claimStart).Milliseconds()
|
||
if err != nil {
|
||
outcome = "error_claim"
|
||
writeError(w, http.StatusInternalServerError, "failed to claim task: "+err.Error())
|
||
return
|
||
}
|
||
|
||
if task == nil {
|
||
slog.Debug("no task to claim", "runtime_id", runtimeID)
|
||
payloadBytes, _ = writeMeasuredJSON(w, http.StatusOK, map[string]any{"task": nil})
|
||
outcome = "no_task"
|
||
return
|
||
}
|
||
if !task.TriggerCommentID.Valid && len(task.CoalescedCommentIds) > 0 {
|
||
handled, failure := h.repairStaleCommentPlanIfNeeded(r.Context(), task, runtimeWorkspaceID)
|
||
if handled {
|
||
if failure != nil {
|
||
outcome = failure.outcome
|
||
writeError(w, failure.status, failure.message)
|
||
return
|
||
}
|
||
outcome = "repaired_stale_comment_plan"
|
||
payloadBytes, _ = writeMeasuredJSON(w, http.StatusOK, map[string]any{"task": nil})
|
||
return
|
||
}
|
||
}
|
||
|
||
outcome = "claimed"
|
||
buildStart = time.Now()
|
||
|
||
resp, deliveredCommentIDs, agentSkillCount, builtinSkillCount, failure := h.buildClaimedTaskResponse(r, task, runtime, runtimeID, runtimeWorkspaceID)
|
||
if failure != nil {
|
||
outcome = failure.outcome
|
||
writeError(w, failure.status, failure.message)
|
||
return
|
||
}
|
||
commentBackedTask := task.TriggerCommentID.Valid || len(task.CoalescedCommentIds) > 0
|
||
requeueFailedClaim := func(reason string) {
|
||
if _, err := h.TaskService.RequeueTaskAfterClaimFailure(r.Context(), *task); err != nil {
|
||
slog.Error("task claim: failed to requeue after finalization error",
|
||
"task_id", uuidToString(task.ID),
|
||
"reason", reason,
|
||
"error", err,
|
||
)
|
||
}
|
||
}
|
||
// Mint a task-scoped `mat_` token bound to (agent, task, workspace,
|
||
// owner). The daemon will inject this as MULTICA_TOKEN into the agent
|
||
// process instead of its own credential, so any API call the agent
|
||
// makes — even one that strips X-Agent-ID / X-Task-ID headers — is
|
||
// recognized server-side as actor=agent, closing the lateral-movement
|
||
// path on owner-only endpoints (e.g. `/api/agents/{id}/env`). Runtime
|
||
// owner is required because task tokens are still bound to an owning user;
|
||
// without one, fail the claim explicitly instead of letting the daemon
|
||
// fall back to a member/owner credential. MUL-3292.
|
||
// Token expires after the queue/runtime upper bound (24h) so it survives
|
||
// long-running tasks but cannot outlive a forgotten one.
|
||
if !runtime.OwnerID.Valid {
|
||
outcome = "error_token"
|
||
slog.Error("task claim: runtime owner missing; cancelling task to avoid unscoped agent credentials",
|
||
"task_id", uuidToString(task.ID),
|
||
"runtime_id", runtimeID,
|
||
"workspace_id", runtimeWorkspaceID,
|
||
)
|
||
if _, cerr := h.TaskService.CancelTask(r.Context(), task.ID); cerr != nil {
|
||
slog.Error("task claim: cancel after missing runtime owner failed",
|
||
"task_id", uuidToString(task.ID), "error", cerr)
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "runtime owner required to mint task token")
|
||
return
|
||
}
|
||
tokenStr, terr := auth.GenerateAgentTaskToken()
|
||
if terr != nil {
|
||
outcome = "error_token"
|
||
slog.Error("task claim: failed to generate agent task token",
|
||
"task_id", uuidToString(task.ID), "error", terr)
|
||
requeueFailedClaim("token_generation")
|
||
writeError(w, http.StatusInternalServerError, "failed to mint task token")
|
||
return
|
||
}
|
||
receipt, ferr := h.TaskService.FinalizeTaskClaim(r.Context(), *task, db.CreateTaskTokenParams{
|
||
TokenHash: auth.HashToken(tokenStr),
|
||
TaskID: task.ID,
|
||
AgentID: task.AgentID,
|
||
WorkspaceID: parseUUID(resp.WorkspaceID),
|
||
UserID: runtime.OwnerID,
|
||
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true},
|
||
}, deliveredCommentIDs, commentBackedTask)
|
||
if ferr != nil {
|
||
outcome = "error_claim_finalize"
|
||
slog.Error("task claim: failed to finalize token and comment delivery receipt",
|
||
"task_id", uuidToString(task.ID), "error", ferr)
|
||
// FinalizeTaskClaim is transactional, so its newly generated token is
|
||
// rolled back with the receipt. Never delete by task here: a stale
|
||
// reclaim can race the original daemon starting, and broad revocation
|
||
// would invalidate that already-authorized execution.
|
||
requeueFailedClaim("token_and_delivery_receipt")
|
||
writeError(w, http.StatusInternalServerError, "failed to finalize task claim")
|
||
return
|
||
}
|
||
resp.AuthToken = tokenStr
|
||
task.DeliveredCommentIds = receipt
|
||
resp.DeliveredCommentIDs = uuidStringsOrEmpty(receipt)
|
||
|
||
slog.Info("task claimed by runtime", "task_id", uuidToString(task.ID), "runtime_id", runtimeID, "agent_id", uuidToString(task.AgentID), "prior_session", resp.PriorSessionID)
|
||
if resp.Agent != nil && len(resp.Agent.Skills) > 0 {
|
||
if skillPayload, err := json.Marshal(resp.Agent.Skills); err == nil {
|
||
skillPayloadBytes = len(skillPayload)
|
||
}
|
||
} else if resp.Agent != nil && len(resp.Agent.SkillRefs) > 0 {
|
||
if skillPayload, err := json.Marshal(resp.Agent.SkillRefs); err == nil {
|
||
skillPayloadBytes = len(skillPayload)
|
||
}
|
||
}
|
||
payloadBytes, _ = writeMeasuredJSON(w, http.StatusOK, map[string]any{"task": resp})
|
||
}
|
||
|
||
type resolveSkillBundlesRequest struct {
|
||
Skills []resolveSkillBundleRef `json:"skills"`
|
||
}
|
||
|
||
type resolveSkillBundleRef struct {
|
||
ID string `json:"id"`
|
||
Source string `json:"source"`
|
||
Hash string `json:"hash"`
|
||
}
|
||
|
||
// ResolveTaskSkillBundles returns full skill content for refs from a slim
|
||
// claim. The daemon calls this after claim and before execenv.Prepare so
|
||
// runtimes still see complete local skill files at startup.
|
||
//
|
||
// If a requested hash no longer matches the agent's current skill bundle, the
|
||
// endpoint returns the current bundle and hash. Stage 1 does not snapshot skill
|
||
// content at claim time; the daemon validates the returned bundle before
|
||
// writing it to cache and materializing it.
|
||
func (h *Handler) ResolveTaskSkillBundles(w http.ResponseWriter, r *http.Request) {
|
||
runtimeID := chi.URLParam(r, "runtimeId")
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
runtime, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID)
|
||
if !ok {
|
||
return
|
||
}
|
||
task, taskWorkspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if taskWorkspaceID != uuidToString(runtime.WorkspaceID) || uuidToString(task.RuntimeID) != runtimeID {
|
||
writeError(w, http.StatusNotFound, "task not found")
|
||
return
|
||
}
|
||
if task.Status != "dispatched" && task.Status != "waiting_local_directory" {
|
||
writeError(w, http.StatusConflict, "task is not preparing")
|
||
return
|
||
}
|
||
|
||
var req resolveSkillBundlesRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if len(req.Skills) == 0 {
|
||
writeJSON(w, http.StatusOK, map[string]any{"bundles": []service.AgentSkillData{}})
|
||
return
|
||
}
|
||
|
||
bundles, _ := h.TaskService.LoadAgentSkillBundles(r.Context(), task.AgentID)
|
||
allowed := make(map[string]service.AgentSkillData, len(bundles))
|
||
for _, bundle := range bundles {
|
||
allowed[bundle.Source+"\x00"+bundle.ID] = bundle
|
||
}
|
||
|
||
resolved := make([]service.AgentSkillData, 0, len(req.Skills))
|
||
for _, ref := range req.Skills {
|
||
if ref.ID == "" || ref.Source == "" || ref.Hash == "" {
|
||
writeError(w, http.StatusBadRequest, "invalid skill ref")
|
||
return
|
||
}
|
||
bundle, ok := allowed[ref.Source+"\x00"+ref.ID]
|
||
if !ok {
|
||
writeError(w, http.StatusNotFound, "skill bundle not found")
|
||
return
|
||
}
|
||
resolved = append(resolved, bundle)
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{"bundles": resolved})
|
||
}
|
||
|
||
// trailingUserMessages returns the run of user messages after the last
|
||
// assistant message in a chronologically-ordered chat history — the set the
|
||
// agent has NOT yet replied to. The agent resumes its prior session and only
|
||
// learns of new input through the claim response's chat_message, so a single
|
||
// run that covers a debounced burst (MUL-2968) must deliver every one of
|
||
// these, not just the latest. Every completed or failed run writes an
|
||
// assistant row, so the anchor advances one turn at a time; the result is the
|
||
// whole slice on the first turn and exactly the new message(s) thereafter.
|
||
func trailingUserMessages(msgs []db.ChatMessage) []db.ChatMessage {
|
||
start := 0
|
||
for i := len(msgs) - 1; i >= 0; i-- {
|
||
if msgs[i].Role != "user" {
|
||
start = i + 1
|
||
break
|
||
}
|
||
}
|
||
return msgs[start:]
|
||
}
|
||
|
||
// ListPendingTasksByRuntime returns queued/dispatched tasks for a runtime.
|
||
func (h *Handler) ListPendingTasksByRuntime(w http.ResponseWriter, r *http.Request) {
|
||
runtimeID := chi.URLParam(r, "runtimeId")
|
||
|
||
// Verify the caller owns this runtime's workspace.
|
||
runtime, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID)
|
||
if !ok {
|
||
return
|
||
}
|
||
workspaceID := uuidToString(runtime.WorkspaceID)
|
||
|
||
tasks, err := h.Queries.ListPendingTasksByRuntime(r.Context(), parseUUID(runtimeID))
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to list pending tasks")
|
||
return
|
||
}
|
||
|
||
resp := make([]AgentTaskResponse, len(tasks))
|
||
for i, t := range tasks {
|
||
resp[i] = taskToResponse(t, workspaceID)
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Task Lifecycle (called by daemon)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// ExtendTaskPrepareLease keeps a dispatched task protected while the daemon is
|
||
// resolving startup inputs and preparing the execution environment.
|
||
func (h *Handler) ExtendTaskPrepareLease(w http.ResponseWriter, r *http.Request) {
|
||
runtimeID := chi.URLParam(r, "runtimeId")
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
runtime, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID)
|
||
if !ok {
|
||
return
|
||
}
|
||
task, taskWorkspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if taskWorkspaceID != uuidToString(runtime.WorkspaceID) || uuidToString(task.RuntimeID) != runtimeID {
|
||
writeError(w, http.StatusNotFound, "task not found")
|
||
return
|
||
}
|
||
|
||
updated, err := h.TaskService.ExtendTaskPrepareLease(r.Context(), parseUUID(taskID), parseUUID(runtimeID))
|
||
if err != nil {
|
||
slog.Warn("extend task prepare lease failed", "task_id", taskID, "runtime_id", runtimeID, "error", err)
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, taskToResponse(*updated, taskWorkspaceID))
|
||
}
|
||
|
||
// StartTask marks a dispatched task as running.
|
||
func (h *Handler) StartTask(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
// Verify the caller owns this task's workspace.
|
||
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
task, err := h.TaskService.StartTask(r.Context(), parseUUID(taskID))
|
||
if err != nil {
|
||
slog.Warn("start task failed", "task_id", taskID, "error", err)
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
slog.Info("task started", "task_id", taskID, "agent_id", uuidToString(task.AgentID))
|
||
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
|
||
}
|
||
|
||
// TaskWaitLocalDirectoryRequest is the body the daemon POSTs when it parks
|
||
// a freshly-dispatched task on a busy local_directory path.
|
||
type TaskWaitLocalDirectoryRequest struct {
|
||
// Reason is a short hint surfaced by the UI alongside the status —
|
||
// typically "<path>" or "<path> (holder: <task short id>)". Small
|
||
// enough to fit on the issue card. Empty is accepted; the column is
|
||
// nullable on the server.
|
||
Reason string `json:"reason"`
|
||
}
|
||
|
||
// MarkTaskWaitingLocalDirectory transitions a dispatched task to
|
||
// waiting_local_directory. Called by the daemon when, after claiming a task
|
||
// whose project carries a local_directory resource, it discovers another
|
||
// in-flight task already holds the path's mutex.
|
||
func (h *Handler) MarkTaskWaitingLocalDirectory(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
var req TaskWaitLocalDirectoryRequest
|
||
if r.ContentLength != 0 {
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
}
|
||
|
||
task, err := h.TaskService.MarkTaskWaitingLocalDirectory(r.Context(), parseUUID(taskID), req.Reason)
|
||
if err != nil {
|
||
slog.Warn("mark task waiting_local_directory failed", "task_id", taskID, "error", err)
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
|
||
}
|
||
|
||
// ReportTaskProgress broadcasts a progress update.
|
||
type TaskProgressRequest struct {
|
||
Summary string `json:"summary"`
|
||
Step int `json:"step"`
|
||
Total int `json:"total"`
|
||
}
|
||
|
||
func (h *Handler) ReportTaskProgress(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
var req TaskProgressRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
|
||
// Verify ownership and resolve workspace ID.
|
||
task, ok := h.requireDaemonTaskAccess(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
workspaceID := ""
|
||
if task.IssueID.Valid {
|
||
if issue, err := h.Queries.GetIssue(r.Context(), task.IssueID); err == nil {
|
||
workspaceID = uuidToString(issue.WorkspaceID)
|
||
}
|
||
}
|
||
|
||
h.TaskService.ReportProgress(r.Context(), taskID, workspaceID, req.Summary, req.Step, req.Total)
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
// CompleteTask marks a running task as completed.
|
||
type TaskCompleteRequest struct {
|
||
PRURL string `json:"pr_url"`
|
||
Output string `json:"output"`
|
||
SessionID string `json:"session_id"` // Claude session ID for future resumption
|
||
WorkDir string `json:"work_dir"` // working directory used during execution
|
||
}
|
||
|
||
func (h *Handler) CompleteTask(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
// Verify the caller owns this task's workspace.
|
||
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
var req TaskCompleteRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
|
||
result, _ := json.Marshal(req)
|
||
task, err := h.TaskService.CompleteTask(r.Context(), parseUUID(taskID), result, req.SessionID, req.WorkDir)
|
||
if err != nil {
|
||
// A CompleteTask error is an infrastructure failure (transaction /
|
||
// assistant-outcome write), not a bad request: an already-finalized
|
||
// callback is treated as idempotent success and returns no error. Return
|
||
// 5xx so the daemon retries the terminal callback and the completion —
|
||
// including the single chat outcome row — lands exactly once (MUL-4351).
|
||
slog.Warn("complete task failed", "task_id", taskID, "error", err)
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
h.emitIssueExecutedOnFirstCompletion(r, task)
|
||
|
||
// MUL-4195: guarantee at-least-once processing. If a member posted a
|
||
// deliberate comment while this run was executing (or one was merged into
|
||
// it after its context was built), schedule a single follow-up so the
|
||
// input is never silently dropped. Loop-safe: member-authored only, capped
|
||
// by the existing per-(issue, agent) dedup, and terminating because the
|
||
// triggering comment always predates the follow-up run's started_at.
|
||
h.reconcileCommentsOnCompletion(r.Context(), task)
|
||
// The terminal transaction and completion reconciliation are committed.
|
||
// Wake the owning runtime now so queued work that was blocked by this
|
||
// task's agent capacity or serialization key is re-claimed immediately.
|
||
h.TaskService.NotifyTaskFinished(*task)
|
||
|
||
// Best-effort revoke of any agent task token minted at claim time.
|
||
// The token would naturally expire at the 24h watermark and is also
|
||
// cascaded on agent_task deletion, but eagerly deleting it on
|
||
// completion shrinks the window where a compromised agent process
|
||
// can keep making API calls after its task finishes. Failure here is
|
||
// non-fatal; the expiry / cascade are the durable guards.
|
||
if err := h.Queries.DeleteTaskTokensByTask(r.Context(), task.ID); err != nil {
|
||
slog.Warn("complete task: failed to revoke task tokens", "task_id", uuidToString(task.ID), "error", err)
|
||
}
|
||
|
||
slog.Info("task completed", "task_id", taskID, "agent_id", uuidToString(task.AgentID))
|
||
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
|
||
}
|
||
|
||
// emitIssueExecutedOnFirstCompletion atomically flips issue.first_executed_at
|
||
// and fires the issue_executed analytics event iff this is the first task on
|
||
// the issue to reach terminal done. Retries / re-assignments / comment-
|
||
// triggered follow-ups hit the WHERE first_executed_at IS NULL clause and
|
||
// no-op, so the funnel counts unique issues, not tasks.
|
||
func (h *Handler) emitIssueExecutedOnFirstCompletion(r *http.Request, task *db.AgentTaskQueue) {
|
||
if task == nil {
|
||
return
|
||
}
|
||
marked, err := h.Queries.MarkIssueFirstExecuted(r.Context(), task.IssueID)
|
||
if err != nil {
|
||
if !isNotFound(err) {
|
||
slog.Warn("analytics: mark issue first-executed failed", "issue_id", uuidToString(task.IssueID), "error", err)
|
||
}
|
||
return
|
||
}
|
||
var durationMS int64
|
||
if task.StartedAt.Valid && task.CompletedAt.Valid {
|
||
durationMS = task.CompletedAt.Time.Sub(task.StartedAt.Time).Milliseconds()
|
||
}
|
||
taskContext := h.TaskService.AnalyticsContextForTask(r.Context(), *task)
|
||
// distinct_id prefers the human creator so agent-driven events flow into
|
||
// the issue-author's person profile (same place signup and
|
||
// workspace_created land). Agent-created issues keep the agent id with a
|
||
// prefix so PostHog doesn't merge them into a user by accident.
|
||
distinct := uuidToString(marked.CreatorID)
|
||
if marked.CreatorType == "agent" {
|
||
distinct = "agent:" + distinct
|
||
}
|
||
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.IssueExecuted(
|
||
distinct,
|
||
uuidToString(marked.WorkspaceID),
|
||
uuidToString(marked.ID),
|
||
uuidToString(task.ID),
|
||
uuidToString(task.AgentID),
|
||
taskContext.Source,
|
||
taskContext.RuntimeMode,
|
||
taskContext.Provider,
|
||
durationMS,
|
||
))
|
||
}
|
||
|
||
// reconcileCommentsOnCompletion closes the at-least-once gap for member
|
||
// comments a completing run did NOT deliver (MUL-4195).
|
||
//
|
||
// The merge path (mergeCommentIntoPendingTask) folds a comment into a task only
|
||
// while it is still PRE-CLAIM (queued/deferred). Those planned ids are not
|
||
// proof of delivery: older daemons ignore structured coalesced fields, and a
|
||
// referenced comment can be deleted before claim. Claim therefore records the
|
||
// ids it actually embedded in delivered_comment_ids. Anything posted during
|
||
// this run's lifetime that is NOT in that receipt must earn a follow-up.
|
||
//
|
||
// Anchor = created_at + delivered-set exclusion, NOT a dispatch/start timestamp
|
||
// (MUL-4195 review round-3 must-fix). A timestamp anchor cannot tell a
|
||
// delivered comment from an undelivered one, and there is a race it structurally
|
||
// misses: a comment created while the task was still queued, but whose merge
|
||
// lost the race to the daemon claiming the task (queued→dispatched) — the merge
|
||
// then finds no pre-claim row (ErrNoRows), the enqueue path defers to reconcile,
|
||
// yet the comment's created_at is BEFORE dispatched_at, so a dispatched_at
|
||
// anchor would skip it and it would vanish. Anchoring on the task's own
|
||
// created_at reaches back over the whole run, and excluding the delivered set
|
||
// is what prevents re-firing comments the run actually received. Together
|
||
// they catch the pre-dispatch merge-race comment, the
|
||
// dispatch→start comment, and the during-run comment, while never double-firing
|
||
// a delivered one.
|
||
//
|
||
// Scope + loop safety:
|
||
// - MEMBER comments qualify as before, with their full routing. AGENT comments
|
||
// now also qualify, but ONLY through an explicit @agent/@squad mention
|
||
// (keepExplicitMentionTriggers). Every non-mention agent route — the
|
||
// assigned-squad-leader fallback, thread-parent / conversation continuation
|
||
// — is intentionally excluded, so a plain agent reply / acknowledgement
|
||
// earns no follow-up here regardless of issue assignment. That is the
|
||
// anti-loop boundary the old member-only filter protected.
|
||
// This closes MUL-4304: an explicit agent→agent @mention that landed while
|
||
// the target already had a DISPATCHED task is dropped by the create-time
|
||
// enqueue path — merge only folds a comment into a QUEUED task, so a
|
||
// dispatched target hits the merge-miss + active-task `continue` and is
|
||
// deferred here — and was then never replayed because agent comments were
|
||
// excluded. (A target with only a RUNNING/queued task does not hit that
|
||
// drop: queued merges in, running-only takes the normal fresh-enqueue path.)
|
||
// - Only comments routing to THE AGENT THAT JUST RAN earn a follow-up here;
|
||
// an `@other-agent` comment is left to that agent's own creation-time
|
||
// trigger, so a completion never re-wakes an unrelated agent.
|
||
// - Every undelivered qualifying comment is replayed through the normal
|
||
// enqueue path in chronological order, so they coalesce into a SINGLE
|
||
// follow-up task (the first enqueues it, the rest merge in). Bounded to one
|
||
// run, and terminating: the follow-up's own created_at is later than all of
|
||
// these comments and its delivered set will contain them, so its completion
|
||
// finds nothing to re-schedule.
|
||
func (h *Handler) reconcileCommentsOnCompletion(ctx context.Context, task *db.AgentTaskQueue) {
|
||
if task == nil || !task.IssueID.Valid || !task.AgentID.Valid || !task.CreatedAt.Valid {
|
||
return
|
||
}
|
||
plannedCommentIDs := append([]pgtype.UUID{}, task.CoalescedCommentIds...)
|
||
if task.TriggerCommentID.Valid {
|
||
plannedCommentIDs = append(plannedCommentIDs, task.TriggerCommentID)
|
||
}
|
||
comments, err := h.Queries.ListReconcilableCommentsForIssueSince(ctx, db.ListReconcilableCommentsForIssueSinceParams{
|
||
IssueID: task.IssueID,
|
||
Since: task.CreatedAt,
|
||
PlannedCommentIds: plannedCommentIDs,
|
||
})
|
||
if err != nil {
|
||
slog.Warn("reconcile comments on completion: list comments failed",
|
||
"issue_id", uuidToString(task.IssueID), "task_id", uuidToString(task.ID), "error", err)
|
||
return
|
||
}
|
||
if len(comments) == 0 {
|
||
return
|
||
}
|
||
// The delivered set is the claim-time receipt, not the enqueue-time plan.
|
||
// Legacy tasks backfill only the primary trigger, deliberately replaying
|
||
// coalesced inputs that their daemon may have ignored.
|
||
delivered := make(map[string]struct{}, len(task.DeliveredCommentIds))
|
||
for _, id := range task.DeliveredCommentIds {
|
||
if id.Valid {
|
||
delivered[uuidToString(id)] = struct{}{}
|
||
}
|
||
}
|
||
issue, err := h.Queries.GetIssue(ctx, task.IssueID)
|
||
if err != nil {
|
||
slog.Warn("reconcile comments on completion: load issue failed",
|
||
"issue_id", uuidToString(task.IssueID), "error", err)
|
||
return
|
||
}
|
||
agentID := uuidToString(task.AgentID)
|
||
scheduled := 0
|
||
for i := range comments {
|
||
c := comments[i]
|
||
if _, ok := delivered[uuidToString(c.ID)]; ok {
|
||
// Already delivered to this run (trigger or pre-claim coalesced).
|
||
continue
|
||
}
|
||
if isNoteComment(c.Content) {
|
||
continue
|
||
}
|
||
var parentComment *db.Comment
|
||
if c.ParentID.Valid {
|
||
// Scope to the issue's workspace; a comment's parent is always in the
|
||
// same workspace, so this only fails closed against a stray foreign
|
||
// UUID rather than changing behavior (MUL-4252).
|
||
if parent, err := h.Queries.GetCommentInWorkspace(ctx, db.GetCommentInWorkspaceParams{
|
||
ID: c.ParentID,
|
||
WorkspaceID: issue.WorkspaceID,
|
||
}); err == nil {
|
||
parentComment = &parent
|
||
}
|
||
}
|
||
// Compute what this comment would trigger, then keep ONLY the agent
|
||
// that just completed — never the full fan-out (that would re-wake
|
||
// unrelated `@other-agent` targets).
|
||
//
|
||
// The comment is routed under its OWN author_type. A member is its own
|
||
// originator. For an agent author, the originator is the human at the
|
||
// top of that agent's trigger chain (resolved from the comment's source
|
||
// task); canInvokeAgent judges an agent→agent (A2A) mention by that
|
||
// originator, not the immediate agent principal (MUL-3963).
|
||
actorType := c.AuthorType
|
||
actorID := uuidToString(c.AuthorID)
|
||
originatorUserID := actorID
|
||
if actorType != "member" {
|
||
originatorUserID = uuidToString(h.TaskService.ResolveOriginatorFromTriggerComment(ctx, issue.WorkspaceID, c.ID))
|
||
}
|
||
triggers, _ := h.computeCommentAgentTriggers(ctx, issue, c.Content, parentComment, actorType, actorID, commentTriggerComputeOptions{
|
||
ExcludeTriggerCommentID: c.ID,
|
||
OriginatorUserID: originatorUserID,
|
||
})
|
||
// For an AGENT author, compensate ONLY explicit @agent/@squad mentions.
|
||
// computeCommentAgentTriggers can also return the assigned-squad-leader
|
||
// fallback (Source = issue-assignee) for a plain worker-agent reply on a
|
||
// squad-assigned issue; that conversational routing is intentionally NOT
|
||
// replayed here. Restricting to the explicit-mention sources keeps the
|
||
// invariant unconditional — a plain agent reply / acknowledgement earns
|
||
// no follow-up regardless of issue assignment — which is the anti-loop
|
||
// boundary the old member-only filter protected (MUL-4304). Member
|
||
// comments are unaffected: they keep their full routing.
|
||
if actorType != "member" {
|
||
triggers = keepExplicitMentionTriggers(triggers)
|
||
}
|
||
scoped := make([]commentAgentTrigger, 0, 1)
|
||
for _, trigger := range triggers {
|
||
if uuidToString(trigger.Agent.ID) == agentID {
|
||
scoped = append(scoped, trigger)
|
||
}
|
||
}
|
||
if len(scoped) == 0 {
|
||
continue
|
||
}
|
||
// The first qualifying comment enqueues the follow-up task; later ones
|
||
// find it AlreadyPending and merge in, so all undelivered comments end
|
||
// up covered by a single bounded run.
|
||
h.enqueueCommentAgentTriggers(ctx, issue, c.ID, scoped)
|
||
scheduled++
|
||
}
|
||
if scheduled > 0 {
|
||
slog.Info("reconcile comments on completion: scheduled follow-up",
|
||
"issue_id", uuidToString(task.IssueID),
|
||
"completed_task_id", uuidToString(task.ID),
|
||
"agent_id", agentID,
|
||
"undelivered_comments", scheduled)
|
||
}
|
||
}
|
||
|
||
// keepExplicitMentionTriggers filters a computed trigger set down to the ones
|
||
// produced by an EXPLICIT @agent / @squad mention (MUL-4304). It is applied to
|
||
// agent-authored comments during completion reconcile so that only a
|
||
// deliberately-targeted mention earns a replay — the assigned-squad-leader
|
||
// fallback, thread-parent / conversation continuation, and issue-assignee
|
||
// routing (all non-mention sources) are intentionally excluded, so a plain
|
||
// agent reply or acknowledgement never earns a follow-up here. Member comments
|
||
// are never passed through this filter; they keep their full routing.
|
||
func keepExplicitMentionTriggers(triggers []commentAgentTrigger) []commentAgentTrigger {
|
||
if len(triggers) == 0 {
|
||
return triggers
|
||
}
|
||
filtered := make([]commentAgentTrigger, 0, len(triggers))
|
||
for _, trigger := range triggers {
|
||
switch trigger.Source {
|
||
case commentTriggerSourceMentionAgent, commentTriggerSourceMentionSquadLeader:
|
||
filtered = append(filtered, trigger)
|
||
}
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
// buildCoalescedCommentData loads the full detail of each comment that was
|
||
// folded into a not-yet-started run (MUL-4195) so the claim response can embed
|
||
// them and the prompt can address each without assuming they share the
|
||
// triggering thread (review should-fix #3). Thread id follows the same rule as
|
||
// the triggering comment (parent id when the comment is a reply, else the
|
||
// comment's own id). Missing comments (deleted / wrong workspace) are skipped
|
||
// rather than failing the claim. The set is bounded by how many comments a user
|
||
// fires before a run starts, so the per-comment lookups stay cheap.
|
||
func (h *Handler) buildCoalescedCommentData(ctx context.Context, workspaceID pgtype.UUID, ids []pgtype.UUID) []CoalescedCommentData {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]CoalescedCommentData, 0, len(ids))
|
||
seen := make(map[string]struct{}, len(ids))
|
||
for _, id := range ids {
|
||
if !id.Valid {
|
||
continue
|
||
}
|
||
idString := uuidToString(id)
|
||
if _, ok := seen[idString]; ok {
|
||
continue
|
||
}
|
||
seen[idString] = struct{}{}
|
||
// Workspace-scoped so a foreign comment UUID resolves to "missing"
|
||
// (skipped) instead of leaking another tenant's text into the prompt
|
||
// (MUL-4252). Matches this function's documented skip-on-missing rule.
|
||
comment, err := h.Queries.GetCommentInWorkspace(ctx, db.GetCommentInWorkspaceParams{
|
||
ID: id,
|
||
WorkspaceID: workspaceID,
|
||
})
|
||
if err != nil {
|
||
continue
|
||
}
|
||
data := CoalescedCommentData{
|
||
ID: uuidToString(comment.ID),
|
||
ThreadID: uuidToString(comment.ID),
|
||
AuthorType: comment.AuthorType,
|
||
Content: comment.Content,
|
||
CreatedAt: timestampToString(comment.CreatedAt),
|
||
}
|
||
if comment.ParentID.Valid {
|
||
data.ThreadID = uuidToString(comment.ParentID)
|
||
}
|
||
if comment.AuthorID.Valid {
|
||
switch comment.AuthorType {
|
||
case "agent":
|
||
if a, err := h.Queries.GetAgent(ctx, comment.AuthorID); err == nil {
|
||
data.AuthorName = a.Name
|
||
}
|
||
case "member":
|
||
if u, err := h.Queries.GetUser(ctx, comment.AuthorID); err == nil {
|
||
data.AuthorName = u.Name
|
||
}
|
||
}
|
||
}
|
||
out = append(out, data)
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
left, leftErr := time.Parse(time.RFC3339Nano, out[i].CreatedAt)
|
||
right, rightErr := time.Parse(time.RFC3339Nano, out[j].CreatedAt)
|
||
if leftErr == nil && rightErr == nil && !left.Equal(right) {
|
||
return left.Before(right)
|
||
}
|
||
if out[i].CreatedAt != out[j].CreatedAt {
|
||
return out[i].CreatedAt < out[j].CreatedAt
|
||
}
|
||
return out[i].ID < out[j].ID
|
||
})
|
||
return out
|
||
}
|
||
|
||
func commentDataIDs(comments []CoalescedCommentData) []pgtype.UUID {
|
||
if len(comments) == 0 {
|
||
return []pgtype.UUID{}
|
||
}
|
||
ids := make([]pgtype.UUID, 0, len(comments))
|
||
for _, comment := range comments {
|
||
ids = append(ids, parseUUID(comment.ID))
|
||
}
|
||
return ids
|
||
}
|
||
|
||
const maxClaimCommentPayloadBytes = 512 << 10 // 512 KiB of comment input per claim
|
||
|
||
// selectCommentDelivery applies a deterministic budget to comment input. The
|
||
// primary trigger is mandatory when it still exists, even when that single
|
||
// comment exceeds the budget; one comment must never become unclaimable. Extra
|
||
// comments are admitted as an oldest-first prefix, so overflow remains a stable
|
||
// suffix for completion reconciliation and cannot starve across retries.
|
||
func selectCommentDelivery(comments []CoalescedCommentData, triggerID string, legacy bool, limit int) []CoalescedCommentData {
|
||
if len(comments) == 0 {
|
||
return nil
|
||
}
|
||
mandatoryID := triggerID
|
||
mandatoryFound := false
|
||
for _, comment := range comments {
|
||
if comment.ID == mandatoryID {
|
||
mandatoryFound = true
|
||
break
|
||
}
|
||
}
|
||
if !mandatoryFound {
|
||
// The planned trigger may have been deleted. Keep the newest available
|
||
// comment so the claim still makes progress and reconcile can pick up the
|
||
// remainder.
|
||
mandatoryID = comments[len(comments)-1].ID
|
||
}
|
||
|
||
selected := map[string]struct{}{mandatoryID: {}}
|
||
used := commentDeliveryBaseSize(legacy) + commentDeliveryEntrySize(commentByID(comments, mandatoryID), legacy)
|
||
for _, comment := range comments {
|
||
if comment.ID == mandatoryID {
|
||
continue
|
||
}
|
||
cost := commentDeliveryEntrySize(comment, legacy)
|
||
if limit > 0 && used+cost > limit {
|
||
break
|
||
}
|
||
selected[comment.ID] = struct{}{}
|
||
used += cost
|
||
}
|
||
|
||
out := make([]CoalescedCommentData, 0, len(selected))
|
||
for _, comment := range comments {
|
||
if _, ok := selected[comment.ID]; ok {
|
||
out = append(out, comment)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func commentByID(comments []CoalescedCommentData, id string) CoalescedCommentData {
|
||
for _, comment := range comments {
|
||
if comment.ID == id {
|
||
return comment
|
||
}
|
||
}
|
||
return CoalescedCommentData{}
|
||
}
|
||
|
||
func commentDeliveryBaseSize(legacy bool) int {
|
||
if legacy {
|
||
return escapedJSONStringContentSize(legacyCommentBundleHeader)
|
||
}
|
||
return 2 // JSON array brackets
|
||
}
|
||
|
||
func commentDeliveryEntrySize(comment CoalescedCommentData, legacy bool) int {
|
||
if legacy {
|
||
return escapedJSONStringContentSize(formatLegacyCommentEntry(comment))
|
||
}
|
||
encoded, err := json.Marshal(comment)
|
||
if err != nil {
|
||
return maxClaimCommentPayloadBytes + 1
|
||
}
|
||
return len(encoded) + 1 // comma between JSON array entries
|
||
}
|
||
|
||
func escapedJSONStringContentSize(value string) int {
|
||
encoded, err := json.Marshal(value)
|
||
if err != nil || len(encoded) < 2 {
|
||
return maxClaimCommentPayloadBytes + 1
|
||
}
|
||
// json.Marshal adds the surrounding string quotes. The remaining bytes
|
||
// match the escaping cost paid when the legacy bundle is nested in the
|
||
// claim response.
|
||
return len(encoded) - 2
|
||
}
|
||
|
||
const legacyCommentBundleHeader = "This run covers multiple distinct issue comments. Address every comment below in chronological order; do not treat this bundle as one rewritten comment.\n"
|
||
|
||
// formatLegacyCommentBundle carries every planned comment through the one
|
||
// field understood by daemons that predate coalesced-comments-v1. Delimiters,
|
||
// ids and thread ids keep distinct instructions attributable and fetchable.
|
||
func formatLegacyCommentBundle(comments []CoalescedCommentData) string {
|
||
if len(comments) == 0 {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString(legacyCommentBundleHeader)
|
||
for _, comment := range comments {
|
||
b.WriteString(formatLegacyCommentEntry(comment))
|
||
}
|
||
return strings.TrimSpace(b.String())
|
||
}
|
||
|
||
func formatLegacyCommentEntry(comment CoalescedCommentData) string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "\n--- comment %s", comment.ID)
|
||
if comment.ThreadID != "" {
|
||
fmt.Fprintf(&b, " [thread %s]", comment.ThreadID)
|
||
}
|
||
if comment.AuthorType != "" || comment.AuthorName != "" {
|
||
fmt.Fprintf(&b, " [author %s", comment.AuthorType)
|
||
if comment.AuthorName != "" {
|
||
fmt.Fprintf(&b, ": %s", comment.AuthorName)
|
||
}
|
||
b.WriteString("]")
|
||
}
|
||
if comment.CreatedAt != "" {
|
||
fmt.Fprintf(&b, " [created %s]", comment.CreatedAt)
|
||
}
|
||
b.WriteString(" ---\n")
|
||
b.WriteString(comment.Content)
|
||
fmt.Fprintf(&b, "\n--- end comment %s ---\n", comment.ID)
|
||
return b.String()
|
||
}
|
||
|
||
// ReportTaskUsage stores per-task token usage. Called independently of
|
||
// complete/fail so usage is captured even when tasks fail or are blocked.
|
||
type TaskUsagePayload struct {
|
||
Provider string `json:"provider"`
|
||
Model string `json:"model"`
|
||
InputTokens int64 `json:"input_tokens"`
|
||
OutputTokens int64 `json:"output_tokens"`
|
||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
||
}
|
||
|
||
func (h *Handler) ReportTaskUsage(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
// Verify the caller owns this task's workspace.
|
||
task, ok := h.requireDaemonTaskAccess(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Usage []TaskUsagePayload `json:"usage"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
|
||
// Provider is lowercased on write so client-side pricing lookups tolerate
|
||
// case drift. An empty provider (an older daemon that omits the field) is
|
||
// stamped from the task's runtime, so generic model ids like `auto` still
|
||
// resolve to a provider instead of landing as '' and pricing $0.
|
||
var runtimeProvider string
|
||
runtimeProviderLoaded := false
|
||
for _, u := range req.Usage {
|
||
provider := normalizeProvider(u.Provider)
|
||
if provider == "" {
|
||
if !runtimeProviderLoaded {
|
||
if rt, err := h.Queries.GetAgentRuntime(r.Context(), task.RuntimeID); err == nil {
|
||
runtimeProvider = normalizeProvider(rt.Provider)
|
||
} else {
|
||
slog.Warn("load runtime provider for usage backfill failed",
|
||
"task_id", taskID, "runtime_id", uuidToString(task.RuntimeID), "error", err)
|
||
}
|
||
runtimeProviderLoaded = true
|
||
}
|
||
provider = runtimeProvider
|
||
}
|
||
if err := h.Queries.UpsertTaskUsage(r.Context(), db.UpsertTaskUsageParams{
|
||
TaskID: parseUUID(taskID),
|
||
Provider: provider,
|
||
Model: u.Model,
|
||
InputTokens: u.InputTokens,
|
||
OutputTokens: u.OutputTokens,
|
||
CacheReadTokens: u.CacheReadTokens,
|
||
CacheWriteTokens: u.CacheWriteTokens,
|
||
}); err != nil {
|
||
slog.Warn("upsert task usage failed", "task_id", taskID, "model", u.Model, "error", err)
|
||
continue
|
||
}
|
||
h.TaskService.CaptureTaskUsage(r.Context(), task, provider, u.Model, u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens)
|
||
|
||
// Surface prompt-cache effectiveness per run so cache hit rates are
|
||
// observable in logs, not just queryable from runtime_usage. The ratio
|
||
// is cached input over total input-side tokens; a persistently low
|
||
// value flags a prompt prefix that is not being reused across runs
|
||
// (e.g. volatile values poisoning the cacheable prefix). MUL-3887.
|
||
if totalInput := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens; totalInput > 0 {
|
||
slog.Info("task prompt-cache usage",
|
||
"task_id", taskID,
|
||
"provider", provider,
|
||
"model", u.Model,
|
||
"input_tokens", u.InputTokens,
|
||
"output_tokens", u.OutputTokens,
|
||
"cache_read_tokens", u.CacheReadTokens,
|
||
"cache_write_tokens", u.CacheWriteTokens,
|
||
"cache_read_ratio", float64(u.CacheReadTokens)/float64(totalInput),
|
||
)
|
||
}
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
// GetTaskStatus returns the current status of a task.
|
||
// Used by the daemon to detect terminal/interruption signals (cancelled,
|
||
// failed, completed) while a task is executing mid-flight.
|
||
func (h *Handler) GetTaskStatus(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
// Verify the caller owns this task's workspace.
|
||
task, ok := h.requireDaemonTaskAccess(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": task.Status})
|
||
}
|
||
|
||
// FailTask marks a running task as failed.
|
||
type TaskFailRequest struct {
|
||
Error string `json:"error"`
|
||
SessionID string `json:"session_id,omitempty"`
|
||
WorkDir string `json:"work_dir,omitempty"`
|
||
FailureReason string `json:"failure_reason,omitempty"`
|
||
}
|
||
|
||
func (h *Handler) FailTask(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
// Verify the caller owns this task's workspace.
|
||
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
var req TaskFailRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
|
||
task, err := h.TaskService.FailTask(r.Context(), parseUUID(taskID), req.Error, req.SessionID, req.WorkDir, req.FailureReason)
|
||
if err != nil {
|
||
slog.Warn("fail task failed", "task_id", taskID, "error", err)
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
h.TaskService.NotifyTaskFinished(*task)
|
||
|
||
// Best-effort revoke of the mat_ task token minted at claim. Same
|
||
// rationale as CompleteTask — eager deletion shrinks the post-
|
||
// terminal window. The 24h expiry / cascade are the durable guards.
|
||
if err := h.Queries.DeleteTaskTokensByTask(r.Context(), task.ID); err != nil {
|
||
slog.Warn("fail task: failed to revoke task tokens", "task_id", uuidToString(task.ID), "error", err)
|
||
}
|
||
|
||
slog.Info("task failed", "task_id", taskID, "agent_id", uuidToString(task.AgentID), "task_error", req.Error, "failure_reason", req.FailureReason)
|
||
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Task Messages (live agent output)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type TaskMessageRequest struct {
|
||
Seq int `json:"seq"`
|
||
Type string `json:"type"`
|
||
Tool string `json:"tool,omitempty"`
|
||
Content string `json:"content,omitempty"`
|
||
Input map[string]any `json:"input,omitempty"`
|
||
Output string `json:"output,omitempty"`
|
||
}
|
||
|
||
type TaskMessageBatchRequest struct {
|
||
Messages []TaskMessageRequest `json:"messages"`
|
||
}
|
||
|
||
// ReportTaskMessages receives a batch of agent execution messages from the daemon.
|
||
func (h *Handler) ReportTaskMessages(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
var req TaskMessageBatchRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if len(req.Messages) == 0 {
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
return
|
||
}
|
||
|
||
// Verify the caller owns this task's workspace.
|
||
task, ok := h.requireDaemonTaskAccess(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
workspaceID := ""
|
||
if task.IssueID.Valid {
|
||
if issue, err := h.Queries.GetIssue(r.Context(), task.IssueID); err == nil {
|
||
workspaceID = uuidToString(issue.WorkspaceID)
|
||
}
|
||
}
|
||
if workspaceID == "" && task.ChatSessionID.Valid {
|
||
if cs, err := h.Queries.GetChatSession(r.Context(), task.ChatSessionID); err == nil {
|
||
workspaceID = uuidToString(cs.WorkspaceID)
|
||
}
|
||
}
|
||
|
||
for _, msg := range req.Messages {
|
||
// Redact sensitive information before persisting or broadcasting.
|
||
msg.Content = redact.Text(msg.Content)
|
||
msg.Output = redact.Text(msg.Output)
|
||
msg.Input = redact.InputMap(msg.Input)
|
||
|
||
var inputJSON []byte
|
||
if msg.Input != nil {
|
||
inputJSON, _ = json.Marshal(msg.Input)
|
||
}
|
||
created, createErr := h.Queries.CreateTaskMessage(r.Context(), db.CreateTaskMessageParams{
|
||
TaskID: parseUUID(taskID),
|
||
Seq: int32(msg.Seq),
|
||
Type: msg.Type,
|
||
Tool: pgtype.Text{String: msg.Tool, Valid: msg.Tool != ""},
|
||
Content: pgtype.Text{String: msg.Content, Valid: msg.Content != ""},
|
||
Input: inputJSON,
|
||
Output: pgtype.Text{String: msg.Output, Valid: msg.Output != ""},
|
||
})
|
||
if createErr != nil {
|
||
slog.Error("failed to create task message", "task_id", taskID, "seq", msg.Seq, "error", createErr)
|
||
writeError(w, http.StatusInternalServerError, "failed to persist task message")
|
||
return
|
||
}
|
||
|
||
if workspaceID != "" {
|
||
h.publishTask(protocol.EventTaskMessage, workspaceID, "system", "", taskID,
|
||
taskMessageToPayload(created, taskID, uuidToString(task.IssueID)))
|
||
}
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
// AckTaskCancelled receives the daemon's acknowledgement that it observed a
|
||
// task cancellation and finished flushing the transcript. Settles the chat
|
||
// finalization that CancelTaskWithResult deferred for a started-but-empty
|
||
// transcript (#5219); idempotent when nothing was deferred.
|
||
func (h *Handler) AckTaskCancelled(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
task, ok := h.requireDaemonTaskAccess(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
h.TaskService.FinalizeDeferredCancelledChat(r.Context(), task.ID)
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
func taskMessageToPayload(m db.TaskMessage, taskID, issueID string) protocol.TaskMessagePayload {
|
||
var input map[string]any
|
||
if m.Input != nil {
|
||
json.Unmarshal(m.Input, &input)
|
||
}
|
||
createdAt := ""
|
||
if m.CreatedAt.Valid {
|
||
createdAt = m.CreatedAt.Time.UTC().Format(time.RFC3339Nano)
|
||
}
|
||
return protocol.TaskMessagePayload{
|
||
TaskID: taskID,
|
||
IssueID: issueID,
|
||
Seq: int(m.Seq),
|
||
Type: m.Type,
|
||
Tool: m.Tool.String,
|
||
Content: m.Content.String,
|
||
Input: input,
|
||
Output: m.Output.String,
|
||
CreatedAt: createdAt,
|
||
}
|
||
}
|
||
|
||
// ListTaskMessages returns the persisted messages for a task (for catch-up after reconnect).
|
||
func (h *Handler) ListTaskMessages(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
|
||
// Verify the caller owns this task's workspace.
|
||
task, ok := h.requireDaemonTaskAccess(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
var (
|
||
messages []db.TaskMessage
|
||
err error
|
||
)
|
||
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
|
||
sinceSeq, parseErr := strconv.Atoi(sinceStr)
|
||
if parseErr != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid since parameter")
|
||
return
|
||
}
|
||
messages, err = h.Queries.ListTaskMessagesSince(r.Context(), db.ListTaskMessagesSinceParams{
|
||
TaskID: parseUUID(taskID),
|
||
Seq: int32(sinceSeq),
|
||
})
|
||
} else {
|
||
messages, err = h.Queries.ListTaskMessages(r.Context(), parseUUID(taskID))
|
||
}
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to list task messages")
|
||
return
|
||
}
|
||
|
||
issueID := uuidToString(task.IssueID)
|
||
|
||
resp := make([]protocol.TaskMessagePayload, len(messages))
|
||
for i, m := range messages {
|
||
resp[i] = taskMessageToPayload(m, taskID, issueID)
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// GetActiveTaskForIssue returns all currently active tasks for an issue.
|
||
// Returns { tasks: [...] } array (may be empty).
|
||
func (h *Handler) GetActiveTaskForIssue(w http.ResponseWriter, r *http.Request) {
|
||
issueID := chi.URLParam(r, "id")
|
||
issue, ok := h.loadIssueForUser(w, r, issueID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
tasks, err := h.Queries.ListActiveTasksByIssue(r.Context(), issue.ID)
|
||
if err != nil {
|
||
tasks = nil
|
||
}
|
||
|
||
workspaceID := uuidToString(issue.WorkspaceID)
|
||
resp := make([]AgentTaskResponse, len(tasks))
|
||
for i, t := range tasks {
|
||
resp[i] = taskToResponse(t, workspaceID)
|
||
}
|
||
// Same issue-facing attribution surface as ListTasksByIssue — hydrate names.
|
||
h.hydrateTaskAttributions(r.Context(), attributionsOf(resp))
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{"tasks": resp})
|
||
}
|
||
|
||
// CancelTask cancels a running or queued task by ID.
|
||
// Verifies both that the URL-parameter issue belongs to the caller's workspace
|
||
// and that the task belongs to that same issue — a task UUID from a different
|
||
// issue (in any workspace) must not be cancellable through this route.
|
||
func (h *Handler) CancelTask(w http.ResponseWriter, r *http.Request) {
|
||
issueID := chi.URLParam(r, "id")
|
||
issue, ok := h.loadIssueForUser(w, r, issueID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
taskID := chi.URLParam(r, "taskId")
|
||
existing, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskID))
|
||
if err != nil || uuidToString(existing.IssueID) != uuidToString(issue.ID) {
|
||
writeError(w, http.StatusNotFound, "task not found")
|
||
return
|
||
}
|
||
|
||
task, err := h.TaskService.CancelTask(r.Context(), existing.ID)
|
||
if err != nil {
|
||
slog.Warn("cancel task failed", "task_id", taskID, "error", err)
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
slog.Info("task cancelled by user", "task_id", taskID, "issue_id", uuidToString(task.IssueID))
|
||
resp := taskToResponse(*task, uuidToString(issue.WorkspaceID))
|
||
// Keep this issue-scoped surface consistent with the list endpoints so a
|
||
// cancelled row keeps its resolved "on behalf of" name in the UI.
|
||
h.hydrateTaskAttributions(r.Context(), []*TaskAttribution{resp.Attribution})
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// ListTasksByIssue returns all tasks (any status) for an issue — used for execution history.
|
||
func (h *Handler) ListTasksByIssue(w http.ResponseWriter, r *http.Request) {
|
||
issueID := chi.URLParam(r, "id")
|
||
issue, ok := h.loadIssueForUser(w, r, issueID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
tasks, err := h.Queries.ListTasksByIssue(r.Context(), issue.ID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to list tasks")
|
||
return
|
||
}
|
||
|
||
workspaceID := uuidToString(issue.WorkspaceID)
|
||
resp := make([]AgentTaskResponse, len(tasks))
|
||
for i, t := range tasks {
|
||
resp[i] = taskToResponse(t, workspaceID)
|
||
}
|
||
// Execution-log rows render the "on behalf of <member>" badge, so this
|
||
// issue-facing surface must resolve initiator/originator names (departed-safe,
|
||
// one batch) — otherwise the badge falls back to "someone" on issue detail.
|
||
h.hydrateTaskAttributions(r.Context(), attributionsOf(resp))
|
||
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// ListTaskMessagesByUser returns task messages for a task.
|
||
// Used by the frontend under regular user auth (not daemon auth).
|
||
// Verifies the task belongs to the caller's workspace.
|
||
func (h *Handler) ListTaskMessagesByUser(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task_id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "task not found")
|
||
return
|
||
}
|
||
|
||
// Verify the task belongs to the caller's workspace.
|
||
wsID := h.TaskService.ResolveTaskWorkspaceID(r.Context(), task)
|
||
if wsID == "" || wsID != middleware.WorkspaceIDFromContext(r.Context()) {
|
||
writeError(w, http.StatusNotFound, "task not found")
|
||
return
|
||
}
|
||
|
||
var (
|
||
messages []db.TaskMessage
|
||
queryErr error
|
||
)
|
||
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
|
||
sinceSeq, parseErr := strconv.Atoi(sinceStr)
|
||
if parseErr != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid since parameter")
|
||
return
|
||
}
|
||
messages, queryErr = h.Queries.ListTaskMessagesSince(r.Context(), db.ListTaskMessagesSinceParams{
|
||
TaskID: taskUUID,
|
||
Seq: int32(sinceSeq),
|
||
})
|
||
} else {
|
||
messages, queryErr = h.Queries.ListTaskMessages(r.Context(), taskUUID)
|
||
}
|
||
if queryErr != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to list task messages")
|
||
return
|
||
}
|
||
|
||
issueID := uuidToString(task.IssueID)
|
||
|
||
resp := make([]protocol.TaskMessagePayload, len(messages))
|
||
for i, m := range messages {
|
||
resp[i] = taskMessageToPayload(m, taskID, issueID)
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// GetIssueUsage returns aggregated token usage for all tasks belonging to an issue.
|
||
func (h *Handler) GetIssueUsage(w http.ResponseWriter, r *http.Request) {
|
||
issueID := chi.URLParam(r, "id")
|
||
issue, ok := h.loadIssueForUser(w, r, issueID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
row, err := h.Queries.GetIssueUsageSummary(r.Context(), issue.ID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to get issue usage")
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"total_input_tokens": row.TotalInputTokens,
|
||
"total_output_tokens": row.TotalOutputTokens,
|
||
"total_cache_read_tokens": row.TotalCacheReadTokens,
|
||
"total_cache_write_tokens": row.TotalCacheWriteTokens,
|
||
"task_count": row.TaskCount,
|
||
})
|
||
}
|
||
|
||
// GetIssueGCCheck returns minimal issue info needed by the daemon GC loop.
|
||
// Gated on workspace access so a daemon token scoped to workspace A cannot
|
||
// read issue metadata from workspace B via UUID enumeration.
|
||
func (h *Handler) GetIssueGCCheck(w http.ResponseWriter, r *http.Request) {
|
||
issueID := chi.URLParam(r, "issueId")
|
||
issueUUID, ok := parseUUIDOrBadRequest(w, issueID, "issue_id")
|
||
if !ok {
|
||
return
|
||
}
|
||
issue, err := h.Queries.GetIssue(r.Context(), issueUUID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "issue not found")
|
||
return
|
||
}
|
||
if !h.requireDaemonWorkspaceAccess(w, r, uuidToString(issue.WorkspaceID)) {
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"status": issue.Status,
|
||
"updated_at": issue.UpdatedAt.Time,
|
||
})
|
||
}
|
||
|
||
// GetChatSessionGCCheck returns the status and updated_at of a chat session
|
||
// for the daemon GC loop. A 404 here means the session was hard-deleted
|
||
// (DeleteChatSession in chat.go runs a real DELETE), which the daemon treats
|
||
// as an immediate-clean signal — the user's explicit delete is the strongest
|
||
// reclaim authorization we can get.
|
||
//
|
||
// Same anti-enumeration shape as GetIssueGCCheck: workspace mismatch returns
|
||
// the same 404 so a scoped daemon token can't probe other workspaces.
|
||
func (h *Handler) GetChatSessionGCCheck(w http.ResponseWriter, r *http.Request) {
|
||
sessionID := chi.URLParam(r, "sessionId")
|
||
sessionUUID, ok := parseUUIDOrBadRequest(w, sessionID, "session_id")
|
||
if !ok {
|
||
return
|
||
}
|
||
session, err := h.Queries.GetChatSession(r.Context(), sessionUUID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "chat session not found")
|
||
return
|
||
}
|
||
if !h.requireDaemonWorkspaceAccess(w, r, uuidToString(session.WorkspaceID)) {
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"status": session.Status,
|
||
"updated_at": session.UpdatedAt.Time,
|
||
})
|
||
}
|
||
|
||
// GetAutopilotRunGCCheck returns the status and completed_at of an autopilot
|
||
// run for the daemon GC loop. The daemon decides purely on terminal status:
|
||
// an autopilot run's workdir is never reused, so a terminal run is reclaimed on
|
||
// sight while non-terminal status is a skip signal — completed_at is returned
|
||
// for the API contract and diagnostics, not as a TTL anchor.
|
||
//
|
||
// Workspace ownership is resolved via the parent autopilot row.
|
||
func (h *Handler) GetAutopilotRunGCCheck(w http.ResponseWriter, r *http.Request) {
|
||
runID := chi.URLParam(r, "runId")
|
||
runUUID, ok := parseUUIDOrBadRequest(w, runID, "run_id")
|
||
if !ok {
|
||
return
|
||
}
|
||
run, err := h.Queries.GetAutopilotRun(r.Context(), runUUID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "autopilot run not found")
|
||
return
|
||
}
|
||
autopilot, err := h.Queries.GetAutopilot(r.Context(), run.AutopilotID)
|
||
if err != nil {
|
||
// Parent autopilot is gone — treat as not found rather than 500
|
||
// so the daemon can fall through to its orphan-by-mtime path.
|
||
writeError(w, http.StatusNotFound, "autopilot run not found")
|
||
return
|
||
}
|
||
if !h.requireDaemonWorkspaceAccess(w, r, uuidToString(autopilot.WorkspaceID)) {
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"status": run.Status,
|
||
"completed_at": run.CompletedAt.Time,
|
||
})
|
||
}
|
||
|
||
// GetTaskGCCheck returns the agent_task_queue status for quick-create cleanup.
|
||
// Quick-create tasks have no parent record (no issue_id at WriteGCMeta time,
|
||
// no chat session, no autopilot run) so the daemon keys GC directly on the
|
||
// task row itself.
|
||
func (h *Handler) GetTaskGCCheck(w http.ResponseWriter, r *http.Request) {
|
||
taskID := chi.URLParam(r, "taskId")
|
||
task, ok := h.requireDaemonTaskAccess(w, r, taskID)
|
||
if !ok {
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"status": task.Status,
|
||
"completed_at": task.CompletedAt.Time,
|
||
})
|
||
}
|