mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 18:36:45 +08:00
* perf(workspace): make teardown task discovery index-driven
Workspace teardown found a workspace's tasks with a three-way
`OR agent_id IN (…) OR issue_id IN (…) OR runtime_id IN (…)`, three times
per delete, and swept task_token with the same shape. PostgreSQL costs
that as a Seq Scan on agent_task_queue — the largest table in the
database (5.08M rows / 8.3 GB in production) — so deleting even a tiny
workspace paid for the whole table, inside the transaction that holds the
workspace row lock and global advisory lock 4246.
Resolve the task set one owner key at a time instead, in the handler, and
reuse that snapshot for every task-keyed step. Measured on a 1.2M-row
synthetic copy of the production shape (937 agents / 109 issues / 12
runtimes): the OR form and a 937-element `= ANY(array)` form both Seq
Scan; single-key equality is a Bitmap Index Scan. A UNION of parent-key
joins is worse than the OR (three hash joins, each seq scanning the
table), which is why that earlier suggestion was dropped.
All three ownership paths are preserved: nothing enforces that a task's
agent, runtime and issue share a workspace, so dropping a path would turn
a performance fix into a tenant-cleanup bug.
task_token needs no loop. Its workspace_id has been NOT NULL since
migration 108, so the workspace key alone covers this workspace's rows,
and the two extra OR arms only ever matched tokens owned by another
workspace whose task or agent lives here — both NOT NULL FKs with
ON DELETE CASCADE, so the later task/agent deletes remove exactly those.
Two indexes make the new shapes index-eligible:
- 273 agent_task_queue(runtime_id): every existing runtime_id index is
partial, so all-status consumers had none. This also covers the FK's
own ON DELETE CASCADE probe, which runs per deleted agent_runtime row
on the ordinary delete path.
- 274 task_token(workspace_id): the column has never been indexed.
Measured block usage for the same workspace, before → after:
agent_task_queue discovery 25,665 → 14,485 blocks; task_token sweep
4,086 → 3 blocks. The point is not the ratio at this size but that the
cost now scales with the workspace being deleted instead of with the
global table.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): fence, bound and un-cascade the teardown task sweep
Addresses all four blocking findings from the MUL-5999 review.
1. Concurrency (correctness / tenant isolation). The task set is no longer
a lock-free snapshot reused across steps. Teardown now locks every row
through which a task can belong to the workspace — its agents, issues
and runtimes — FOR UPDATE, and holds them for the rest of the
transaction. Inserting an agent_task_queue row takes FOR KEY SHARE on
each referenced owner, which conflicts with FOR UPDATE, so a concurrent
enqueue (or a reassignment onto one of these runtimes) can no longer
slip a task in behind the sweep. Locking the tasks alone could not do
that: a row that does not exist yet cannot be locked. This is the same
technique the handler already uses on the workspace row to keep
CreateChatSession out of the delete window.
The per-batch page query also takes FOR UPDATE, so under READ COMMITTED
PostgreSQL re-checks ownership after each lock: a task a concurrent
commit already moved elsewhere is skipped rather than deleted on a
stale claim, and a task we locked cannot move before we delete it.
Deadlock (40P01) joins lock_timeout (55P03) as a retryable 503, because
the owner locks can be taken in the opposite order by a concurrent task
update.
2. Cleanup ownership (no cascade reliance). task_token keeps all three
explicit paths, split into separate index-driven statements instead of
one OR: workspace_id in DeleteWorkspaceLeafData, task_id in
DeleteTaskBatch, agent_id in DeleteTaskTokensByAgent. Nothing in the
teardown graph now depends on ON DELETE CASCADE to finish the job.
3. Bounded memory. The whole-workspace task array is gone. The sweep pages
through one owner key at a time with LIMIT 1000 and deletes exactly the
page it read, so process memory, the array parameter and each DELETE's
row count are bounded by the batch size rather than by a workspace's
lifetime task count. The task-keyed arms moved out of the leaf-data
statement into DeleteTaskBatch, which runs per page, and the separate
whole-workspace task delete is gone.
4. Migration retryability. The concurrent-index cleanup hooks are now
derived from a version -> index-name map that covers 257, 261 and
273-277, so an interrupted CREATE INDEX CONCURRENTLY cannot be mistaken
for success on retry.
Three more indexes, each an unindexed FK referencing column that the
per-batch statements need and that the FK's own ON DELETE action already
scanned per row on ordinary deletes: task_token(agent_id),
chat_draft_restore(task_id), autopilot_run(task_id).
Plans re-verified on a 1.2M-row synthetic copy of the prod shape with the
child tables populated: page query is a Bitmap Index Scan under LockRows;
task_usage, chat_draft_restore, task_token and autopilot_run arms are all
index scans.
Tests: the ownership-path test now asserts the sweep is self-sufficient
inside the transaction, before any owner row is deleted, so a cascade
cannot stand in for teardown. New tests cover the enqueue / reassignment
fence (blocked writers, unrelated writer unaffected), multi-page sweeps
and the page bound, plus a migration-retry repair test and a check that
every cleanup hook names the index its migration builds.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): keyset-page the teardown sweep and fail closed on leaks
Addresses the second review round on MUL-5999.
1. Bounded scan, not just bounded result. The page query was
`owner = $1 ORDER BY id LIMIT n` against indexes that cannot produce id
order, so the executor had to read the owner's whole task set before
applying LIMIT — quadratic in pages for a busy owner, which is the case
this issue is about. Measured on a 550k-row probe: the old shape spent
11,630 buffers per page (planner chose a full pkey index scan; on other
statistics it chose an explicit Sort), the keyset shape spends ~2,000
and a later page costs the same as the first.
Pages are now `owner = $1 AND id > $cursor ORDER BY id LIMIT n` against
composite indexes: 273 becomes (runtime_id, id), 278 adds (agent_id, id),
279 adds (issue_id, id) and 280 drops the single-column issue_id index it
supersedes so the hot table does not carry both.
2. Owner enumeration is bounded too. The three LockWorkspaceTaskOwner*
statements no longer return ids at all — they are a set-based write fence
and now cost the API process nothing. Owners are walked separately in
keyset pages of 500 (281-283 add the (workspace_id, id) indexes those
pages need), so a workspace with a huge agent or issue set never has its
whole owner list held in memory.
The keyset column is id rather than a natural key on purpose: a cursor
column must be immutable, and renaming an owner mid-teardown would
otherwise let it sort behind the cursor and escape the sweep.
3. The fence is no longer the only guarantee. Blocking a concurrent enqueue
still relies on an INSERT taking FOR KEY SHARE on the owner rows it
references, which is a property of the legacy FKs rather than a protocol
every enqueue path opts into. Two things now backstop it:
- every owner is re-swept until a pass finds nothing, and teardown fails
closed (rolling the whole transaction back) if tasks keep arriving,
instead of committing a partially cleaned tenant;
- a schema-contract test asserts the three owner FKs still exist, so a
migration that removes them turns the missing fence into a red build
rather than a silent hole.
An explicit deleting-state protocol shared with all seven enqueue paths
remains the right long-term fix and is out of scope here.
End-to-end teardown, real handler, identical synthetic data, main vs this
branch (1.2M-row agent_task_queue, 200k task_token, 300k task_usage):
small workspace, 5k tasks: 29,247 ms -> 1,013 ms (4.38M -> 0.27M blocks)
busy workspace, 60k tasks: 243,863 ms -> 1,724 ms (5.76M -> 2.23M blocks)
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): fence task writes in-transaction, independent of the FKs
Addresses the third review round on MUL-5999.
1. The re-scan did not close the window, and the reviewer is right that it
cannot: a pass that comes back empty returns success, and a task
inserted after that final scan but before COMMIT is not caught by any
number of passes. The only thing that closed that window was the
FOR KEY SHARE an INSERT takes on the agent / issue / runtime rows it
references — a side effect of foreign keys that workspace_delete.sql
itself documents as an expand-phase net a later contract removes.
Migration 284 replaces it with an explicit fence: a BEFORE INSERT OR
UPDATE OF (agent_id, issue_id, runtime_id) trigger on agent_task_queue
that takes FOR KEY SHARE on the workspace row behind every owner the row
references, and raises if one is gone. That conflicts with the FOR UPDATE
LockWorkspaceForDelete already holds for the whole teardown transaction,
so a task write against a workspace being torn down blocks until teardown
finishes and then either proceeds (rolled back) or fails closed
(committed).
A trigger rather than a per-call-site check because the fence has to be
in the same transaction as the write: there are seven INSERTs and several
ownership-changing UPDATEs across agent.sql, autopilot.sql, chat.sql and
runtime.sql, several of them single autocommit statements, so a separate
"take the fence" statement from the caller would be its own transaction
and protect nothing. In-transaction by construction, covers every current
path, and cannot be forgotten by a path added later.
TestTaskWriteFence_BlocksOnWorkspaceLockAlone isolates the fence from the
FKs: the holder locks ONLY the workspace row — no agent, issue or runtime
row — so no FK on agent_task_queue has anything to block on, since none of
them references workspace. Inserts through all three ownership paths and a
reassignment all block; an unrelated workspace's insert and a status-only
update still pass. Dropping the trigger fails that test.
Cost, measured with 5,000 bare inserts: 147 ms without the fence,
238 ms with it — about 18 us per inserted row for up to three indexed
lookups and a shared row lock. It does not fire on the hot claim /
dispatch / complete path, which updates status and timestamps rather than
ownership.
The verification passes stay as a self-check, no longer described as the
concurrency guarantee.
2. Migration 280 is withdrawn. Its down path rebuilt the superseded index
with CREATE INDEX CONCURRENTLY, and the runner only runs INVALID-index
cleanup hooks in the up direction, so an interrupted rollback would leave
an invalid index that a later `down` skips — and rolling back 279 too
would then leave no usable issue_id index at all. The redundant
single-column index stays for now; dropping it needs its own migration
with a direction-aware cleanup. This also settles the stale comment in
task_usage.sql, which still names the old index and remains correct.
3. Keyset cursors no longer start from the all-zero uuid, which is itself a
valid uuid and would have made a row with that id invisible forever.
Every walk now has an explicit first-page query with no cursor predicate.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): move the task-write fence into the write statements
Addresses the fourth review round on MUL-5999.
1. The trigger is gone. Migration 284 now defines
lock_task_owner_workspaces(agent_id, issue_id, runtime_id), a predicate
that every statement writing agent_task_queue ownership calls in its own
WHERE clause:
INSERT INTO agent_task_queue (...)
SELECT ... WHERE lock_task_owner_workspaces($1, $3, $2)
Nine statements now carry it: the seven task INSERTs (agent.sql x5,
autopilot.sql, chat.sql), LinkTaskToIssue, and ReassignTasksToRuntime —
the last through a MATERIALIZED CTE so a bulk UPDATE evaluates the
VOLATILE predicate once instead of per row. Clearing runtime_id is
exempt: it cannot move a task into another workspace.
Calling it from the writing statement keeps the lock in the writer's own
transaction, which a separate caller-issued statement could not do for
the autocommit enqueue paths. TestTaskOwnershipWritesCallTheFence is the
trade for dropping the trigger: it scans the query files and fails the
build if a statement writes ownership without the fence.
2. The fence no longer defers to the foreign keys. The trigger version
counted only owner references that still resolved, so a deleted owner
made itself invisible: with all three gone it returned early, and with
one of three gone the count still matched. The predicate now compares
resolved owners against the number of owner arguments supplied and
returns false on any mismatch, so a stale write is rejected on its own
terms. TestLockTaskOwnerWorkspaces_RejectsUnresolvableOwner covers each
shape, including the one-of-three case.
3. Deterministic lock order. The workspace rows are locked with
ORDER BY w.id, so mirrored cross-workspace writes cannot take the same
pair in opposite orders. Honest note on the test: 25 mirrored concurrent
pairs never deadlock either way, because the current plan happens to
return the rows in a stable order — the ORDER BY is what makes that a
guarantee rather than an accident of the plan, and the test is a
regression test for the symptom.
4. The fail-closed test now tests what its name says. The previous one only
inserted a neighbour-owned task and could not have failed. The
replacement runs the actual race: a writer calls the real
CreateAgentTask, blocks on the workspace lock, teardown deletes the
workspace and commits, and the writer must come back with pgx.ErrNoRows
— the fence's own verdict. An FK doing the work instead surfaces as
SQLSTATE 23503, which the assertion rejects explicitly.
Cost: 5,000 predicate calls in 53.7 ms, about 11 us per fenced write, for
up to three indexed lookups and a shared row lock. Status, dispatch and
completion updates do not call it.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(runtime): stop the legacy merge when the task fence refuses
Addresses the P1 from the fifth review round on MUL-5999.
ReassignTasksToRuntime returned only a row count, so a fenced refusal was
indistinguishable from "the old runtime had no tasks". mergeLegacyRuntimes
read both as success and went on to DeleteAgentRuntime(old), where
agent_task_queue's ON DELETE CASCADE silently took the history the
reassignment exists to preserve — and once that FK goes away, the same path
would instead leave tasks pointing at a deleted runtime. Either way the
merge was leaning on the cascade, which is what this PR set out to stop.
The query now returns the fence verdict alongside the count:
SELECT (SELECT ok FROM fence) AS fence_ok,
(SELECT count(*) FROM reassigned) AS reassigned_tasks
and the merge is a single helper, mergeLegacyRuntime, that aborts with
errRuntimeMergeFenced before touching anything else when fence_ok is false.
Tasks now move FIRST, because that is the step carrying the fence: nothing
else in the merge — agent reassignment, legacy daemon_id bookkeeping, the
old runtime delete — runs unless the fence agreed. The next daemon
registration re-runs the merge, by which time the workspace is either gone
or its teardown rolled back.
TestMergeLegacyRuntime_KeepsOldRuntimeWhenFenceRefuses covers it: with a
vanished merge target the old runtime and its task both survive and the task
stays on the old runtime, while a merge onto a live runtime still completes.
Removing the abort fails that test — and fails it with SQLSTATE 23503 from
agent_runtime_id_fkey, which is precisely the "an FK is doing the work"
shape the review objected to.
Also replaced the fixed 300 ms sleep in the queued-writer race with a poll
on pg_stat_activity for a backend actually parked on a lock, so the test
cannot pass without the race being set up (it now fails on timeout instead).
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(runtime): make the legacy runtime merge a single transaction
Addresses the P1 from the sixth review round on MUL-5999.
The merge ran four autocommit statements, so the fence's workspace lock
ended with the statement that took it. Two ways that left a tenant
inconsistent while daemon registration still reported success:
* a transient failure on the agent reassignment committed the task move
and nothing else, leaving tasks on the new runtime and their agents on
the old one until some later registration happened to retry;
* in the gap after the task commit, a workspace teardown could lock and
delete the target runtime — and its UnbindTasksFromRuntime step clears
runtime_id on exactly the tasks that had just moved. The next merge
looks tasks up by the OLD runtime id, so that history was detached for
good.
mergeLegacyRuntime now opens a transaction and runs the whole merge on one
qtx: fenced task reassignment, agent reassignment, the legacy daemon_id
audit write, and the old-runtime delete, then commits. The fence stays the
first statement, so the workspace lock it takes is held until COMMIT — a
teardown cannot start deleting the target mid-merge — and the lock order
against teardown is unchanged. Any failure returns an error and rolls the
whole thing back.
The audit write is no longer best-effort: inside a transaction a failed
statement poisons the rest, so it either lands with the merge or the merge
is abandoned.
TestMergeLegacyRuntime_RollsBackWhenAStepFails injects the failure in the
database rather than through a code seam — a trigger makes the agent move
raise — and asserts the task, the agent, both runtime rows and the audit
column are all exactly as they were, then that the same merge completes
once the injection is removed. Reverting to autocommit fails it with
"task moved to <new runtime>; the task reassignment was not rolled back".
Also took the non-blocking suggestion: the queued-writer race now has the
writer report its own pg_backend_pid() over a channel and waits for that
specific backend to park on a lock, instead of matching statement text
against every backend on a shared test database.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(runtime): fence late writes against the runtime a merge is collapsing
Addresses the P1 from the seventh review round on MUL-5999, which the
reviewer reproduced rather than inferred.
The fence locked the workspace row and nothing else. A task write takes
FOR KEY SHARE there, and so did the legacy runtime merge — and two KEY SHARE
locks do not conflict. So an enqueue against the OLD runtime went straight
through after the merge had scanned its tasks, and DeleteAgentRuntime then
removed that brand-new task through agent_task_queue's ON DELETE CASCADE.
Making the merge one transaction widened the window rather than creating it:
the agent's new runtime_id is invisible until COMMIT, so concurrent writers
keep resolving the old runtime for the whole merge.
Both sides of the contract now lock the owner rows too, in one order:
workspace rows (by id), then agent, issue, runtime.
* lock_task_owner_rows (migration 284, renamed from
lock_task_owner_workspaces) takes FOR KEY SHARE on each owner row after
the workspace rows, and re-checks that they still exist. That is what
conflicts with a delete.
* The merge takes FOR KEY SHARE on the workspace and then FOR UPDATE on
both runtime rows in id order, before the task scan, and holds them to
COMMIT. FOR UPDATE is the level that conflicts with a writer's KEY
SHARE; both runtimes in one ordered statement so two merges running in
opposite directions cannot take the pair in opposite orders. Locking
fewer rows than asked for means one disappeared, and the merge is
abandoned.
A late writer therefore either commits before the merge takes the lock — in
which case the scan that follows sees its task and moves it — or waits, and
then finds the runtime gone and writes no row.
TestRuntimeMerge_LateWriteToOldRuntimeIsFencedNotCascaded drives the merge
statement by statement so it can stop where the window was (after the agent
update, before the old runtime is deleted), enqueues against the old runtime
through the generated query, and requires that the writer is parked on a
lock, that it comes back as pgx.ErrNoRows, and that the merge's own history
ends up on the target. It explicitly rejects both 23503 and a row that
lands and then vanishes.
Both halves are load-bearing: dropping the runtime lock from the fence, or
the FOR UPDATE from the merge, fails that test — the latter with "the late
write was rejected by a foreign key (23503), not by the fence".
Cost: 5,000 predicate calls in 74.4 ms, ~15 us per fenced write (was ~11 us
with the workspace lock alone).
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* docs(workspace): correct the fence comment and drop a trailing newline
Two review nits, no behaviour change.
lockWorkspaceTaskOwners still described migration 284 as a BEFORE trigger on
agent_task_queue. It became a called predicate, lock_task_owner_rows, in
9fc1d79, and this comment is the place someone reads to understand how the
fence works — so it now says what it is: a predicate every ownership-writing
statement calls in its own WHERE clause, taking FOR KEY SHARE on the
workspace rows and then the owner rows.
Also removed the blank line at EOF in the migration, which git diff --check
was flagging.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
1501 lines
50 KiB
Go
1501 lines
50 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: runtime.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const cancelAgentTasksByRuntimeOrAgent = `-- name: CancelAgentTasksByRuntimeOrAgent :many
|
|
UPDATE agent_task_queue
|
|
SET status = 'cancelled', completed_at = now()
|
|
WHERE (runtime_id = ANY($1::uuid[]) OR agent_id = ANY($2::uuid[]))
|
|
AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
|
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, session_rollout_missing, retired_session_id, quick_actions_disabled, regenerate_quick_actions_for
|
|
`
|
|
|
|
type CancelAgentTasksByRuntimeOrAgentParams struct {
|
|
RuntimeIds []pgtype.UUID `json:"runtime_ids"`
|
|
AgentIds []pgtype.UUID `json:"agent_ids"`
|
|
}
|
|
|
|
// Cancels every active task that either lives on one of the given runtimes
|
|
// OR belongs to one of the given agents. Used by the member-revocation flow:
|
|
// the runtime-side covers tasks queued against the leaving member's runtimes;
|
|
// the agent-side covers tasks pinned to a different runtime that those agents
|
|
// left behind from a prior UpdateAgent (agent.runtime_id can change, but
|
|
// agent_task_queue.runtime_id does not get rewritten when it does, so a task
|
|
// queued on runtime A by agent X — later moved to runtime B — survives the
|
|
// runtime-only revoke and could still be claimed because ClaimAgentTask does
|
|
// not gate on agent.archived_at).
|
|
//
|
|
// We use 'cancelled' rather than 'failed' so the daemon's per-task status
|
|
// poller (watchTaskCancellation) interrupts the running agent gracefully.
|
|
// Returns the affected rows so the caller can broadcast task:cancelled and
|
|
// reconcile per-agent status.
|
|
//
|
|
// The status list must cover EVERY non-terminal status, not just the ones the
|
|
// daemon is actively working: 'deferred' (migration 128, comment-routing
|
|
// escalation) was missing here and only went unnoticed because the runtime
|
|
// delete used to cascade those rows away. Since MUL-5559 the runtime delete
|
|
// unbinds history rows instead, and agent_task_queue_active_requires_runtime
|
|
// rejects an active row without a runtime — so a missed status now surfaces as
|
|
// a failed delete (runtime_delete_not_drained) instead of silent data loss.
|
|
func (q *Queries) CancelAgentTasksByRuntimeOrAgent(ctx context.Context, arg CancelAgentTasksByRuntimeOrAgentParams) ([]AgentTaskQueue, error) {
|
|
rows, err := q.db.Query(ctx, cancelAgentTasksByRuntimeOrAgent, arg.RuntimeIds, arg.AgentIds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []AgentTaskQueue{}
|
|
for rows.Next() {
|
|
var i AgentTaskQueue
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.AgentID,
|
|
&i.IssueID,
|
|
&i.Status,
|
|
&i.Priority,
|
|
&i.DispatchedAt,
|
|
&i.StartedAt,
|
|
&i.CompletedAt,
|
|
&i.Result,
|
|
&i.Error,
|
|
&i.CreatedAt,
|
|
&i.Context,
|
|
&i.RuntimeID,
|
|
&i.SessionID,
|
|
&i.WorkDir,
|
|
&i.TriggerCommentID,
|
|
&i.ChatSessionID,
|
|
&i.AutopilotRunID,
|
|
&i.Attempt,
|
|
&i.MaxAttempts,
|
|
&i.ParentTaskID,
|
|
&i.FailureReason,
|
|
&i.TriggerSummary,
|
|
&i.ForceFreshSession,
|
|
&i.IsLeaderTask,
|
|
&i.WaitReason,
|
|
&i.InitiatorUserID,
|
|
&i.HandoffNote,
|
|
&i.PrepareLeaseExpiresAt,
|
|
&i.SquadID,
|
|
&i.RuntimeMcpOverlay,
|
|
&i.EscalationForTaskID,
|
|
&i.FireAt,
|
|
&i.OriginatorUserID,
|
|
&i.RuntimeConnectedApps,
|
|
&i.CoalescedCommentIds,
|
|
&i.DeliveredCommentIds,
|
|
&i.ChatInputTaskID,
|
|
&i.ChatFinalizeDeferredAt,
|
|
&i.OriginatorSource,
|
|
&i.DelegatedFromTaskID,
|
|
&i.RetryOfTaskID,
|
|
&i.RerunOfTaskID,
|
|
&i.RuleVersionID,
|
|
&i.TriggerEvidenceKind,
|
|
&i.TriggerEvidenceRefID,
|
|
&i.AccountableUserID,
|
|
&i.SessionRolloutMissing,
|
|
&i.RetiredSessionID,
|
|
&i.QuickActionsDisabled,
|
|
&i.RegenerateQuickActionsFor,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const countActiveAgentsByRuntime = `-- name: CountActiveAgentsByRuntime :one
|
|
SELECT count(*) FROM agent WHERE runtime_id = $1 AND archived_at IS NULL
|
|
`
|
|
|
|
func (q *Queries) CountActiveAgentsByRuntime(ctx context.Context, runtimeID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countActiveAgentsByRuntime, runtimeID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const countUndrainedTasksByRuntimeOrAgent = `-- name: CountUndrainedTasksByRuntimeOrAgent :one
|
|
SELECT count(*) FROM agent_task_queue
|
|
WHERE (runtime_id = ANY($1::uuid[]) OR agent_id = ANY($2::uuid[]))
|
|
AND completed_at IS NULL
|
|
`
|
|
|
|
type CountUndrainedTasksByRuntimeOrAgentParams struct {
|
|
RuntimeIds []pgtype.UUID `json:"runtime_ids"`
|
|
AgentIds []pgtype.UUID `json:"agent_ids"`
|
|
}
|
|
|
|
// Belt-and-braces gate for the runtime-delete transaction: after cancelling,
|
|
// every task on this runtime OR owned by an agent being unbound must be terminal
|
|
// (completed_at IS NOT NULL) before the unbind UPDATE runs. The agent-side
|
|
// predicate must mirror CancelAgentTasksByRuntimeOrAgent: a task can remain
|
|
// pinned to another runtime after its agent moves. Non-zero means some
|
|
// non-terminal status escaped the cancel query — the handler aborts with 409
|
|
// runtime_delete_not_drained rather than letting the CHECK constraint turn it
|
|
// into an opaque 500, and rather than deleting rows to make it go away.
|
|
func (q *Queries) CountUndrainedTasksByRuntimeOrAgent(ctx context.Context, arg CountUndrainedTasksByRuntimeOrAgentParams) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countUndrainedTasksByRuntimeOrAgent, arg.RuntimeIds, arg.AgentIds)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const deleteAgentRuntime = `-- name: DeleteAgentRuntime :exec
|
|
DELETE FROM agent_runtime WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteAgentRuntime(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteAgentRuntime, id)
|
|
return err
|
|
}
|
|
|
|
const deleteStaleOfflineRuntimes = `-- name: DeleteStaleOfflineRuntimes :many
|
|
DELETE FROM agent_runtime
|
|
WHERE status = 'offline'
|
|
AND last_seen_at < now() - make_interval(secs => $1::double precision)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM agent
|
|
WHERE agent.runtime_id = agent_runtime.id
|
|
)
|
|
RETURNING id, workspace_id
|
|
`
|
|
|
|
type DeleteStaleOfflineRuntimesRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
// Deletes runtimes that have been offline for longer than the TTL and have
|
|
// no agents bound (active or archived). The FK constraint on agent.runtime_id
|
|
// is ON DELETE RESTRICT, so we must exclude all agent references.
|
|
func (q *Queries) DeleteStaleOfflineRuntimes(ctx context.Context, staleSeconds float64) ([]DeleteStaleOfflineRuntimesRow, error) {
|
|
rows, err := q.db.Query(ctx, deleteStaleOfflineRuntimes, staleSeconds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []DeleteStaleOfflineRuntimesRow{}
|
|
for rows.Next() {
|
|
var i DeleteStaleOfflineRuntimesRow
|
|
if err := rows.Scan(&i.ID, &i.WorkspaceID); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const deleteSystemAgentsByRuntime = `-- name: DeleteSystemAgentsByRuntime :exec
|
|
DELETE FROM agent WHERE runtime_id = $1 AND kind = 'system'
|
|
`
|
|
|
|
// System agents are invisible execution infrastructure (for example the Agent
|
|
// Builder). Remove them before deleting their runtime so the RESTRICT runtime
|
|
// FK cannot block an otherwise dependency-free delete.
|
|
func (q *Queries) DeleteSystemAgentsByRuntime(ctx context.Context, runtimeID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteSystemAgentsByRuntime, runtimeID)
|
|
return err
|
|
}
|
|
|
|
const failTasksForOfflineRuntimes = `-- name: FailTasksForOfflineRuntimes :many
|
|
UPDATE agent_task_queue
|
|
SET status = 'failed', completed_at = now(), error = 'runtime went offline',
|
|
failure_reason = 'runtime_offline',
|
|
wait_reason = NULL
|
|
WHERE status IN ('dispatched', 'running', 'waiting_local_directory')
|
|
AND runtime_id IN (
|
|
SELECT id FROM agent_runtime WHERE status = 'offline'
|
|
)
|
|
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, session_rollout_missing, retired_session_id, quick_actions_disabled, regenerate_quick_actions_for
|
|
`
|
|
|
|
// Marks dispatched/running/waiting_local_directory tasks as failed when
|
|
// their runtime is offline. This cleans up orphaned tasks after a daemon
|
|
// crash or network partition.
|
|
func (q *Queries) FailTasksForOfflineRuntimes(ctx context.Context) ([]AgentTaskQueue, error) {
|
|
rows, err := q.db.Query(ctx, failTasksForOfflineRuntimes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []AgentTaskQueue{}
|
|
for rows.Next() {
|
|
var i AgentTaskQueue
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.AgentID,
|
|
&i.IssueID,
|
|
&i.Status,
|
|
&i.Priority,
|
|
&i.DispatchedAt,
|
|
&i.StartedAt,
|
|
&i.CompletedAt,
|
|
&i.Result,
|
|
&i.Error,
|
|
&i.CreatedAt,
|
|
&i.Context,
|
|
&i.RuntimeID,
|
|
&i.SessionID,
|
|
&i.WorkDir,
|
|
&i.TriggerCommentID,
|
|
&i.ChatSessionID,
|
|
&i.AutopilotRunID,
|
|
&i.Attempt,
|
|
&i.MaxAttempts,
|
|
&i.ParentTaskID,
|
|
&i.FailureReason,
|
|
&i.TriggerSummary,
|
|
&i.ForceFreshSession,
|
|
&i.IsLeaderTask,
|
|
&i.WaitReason,
|
|
&i.InitiatorUserID,
|
|
&i.HandoffNote,
|
|
&i.PrepareLeaseExpiresAt,
|
|
&i.SquadID,
|
|
&i.RuntimeMcpOverlay,
|
|
&i.EscalationForTaskID,
|
|
&i.FireAt,
|
|
&i.OriginatorUserID,
|
|
&i.RuntimeConnectedApps,
|
|
&i.CoalescedCommentIds,
|
|
&i.DeliveredCommentIds,
|
|
&i.ChatInputTaskID,
|
|
&i.ChatFinalizeDeferredAt,
|
|
&i.OriginatorSource,
|
|
&i.DelegatedFromTaskID,
|
|
&i.RetryOfTaskID,
|
|
&i.RerunOfTaskID,
|
|
&i.RuleVersionID,
|
|
&i.TriggerEvidenceKind,
|
|
&i.TriggerEvidenceRefID,
|
|
&i.AccountableUserID,
|
|
&i.SessionRolloutMissing,
|
|
&i.RetiredSessionID,
|
|
&i.QuickActionsDisabled,
|
|
&i.RegenerateQuickActionsFor,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const findLegacyRuntimesByDaemonID = `-- name: FindLegacyRuntimesByDaemonID :many
|
|
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime
|
|
WHERE workspace_id = $1
|
|
AND provider = $2
|
|
AND LOWER(daemon_id) = LOWER($3)
|
|
`
|
|
|
|
type FindLegacyRuntimesByDaemonIDParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Provider string `json:"provider"`
|
|
DaemonID string `json:"daemon_id"`
|
|
}
|
|
|
|
// Looks up runtime rows keyed on a prior (hostname-derived) daemon_id. Used
|
|
// at register-time to find rows owned by the same machine under its old
|
|
// identity so agents/tasks can be re-pointed at the new UUID-keyed row.
|
|
//
|
|
// Comparison is case-insensitive because os.Hostname() has been observed to
|
|
// return different casings on the same machine (e.g. `Jiayuans-MacBook-Pro`
|
|
// vs `jiayuans-macbook-pro`) across reboots/mDNS state changes. A case-
|
|
// sensitive `=` would strand the old row; LOWER() on both sides handles drift
|
|
// without forcing the daemon to enumerate cased permutations.
|
|
//
|
|
// Returns many rather than one because case drift may have already minted
|
|
// duplicate rows historically (e.g. `Foo.local` AND `foo.local` under the
|
|
// same workspace+provider). A single-row lookup would consolidate only one
|
|
// of them and leave the rest orphaned. Callers must merge every returned
|
|
// row into the new UUID-keyed runtime.
|
|
func (q *Queries) FindLegacyRuntimesByDaemonID(ctx context.Context, arg FindLegacyRuntimesByDaemonIDParams) ([]AgentRuntime, error) {
|
|
rows, err := q.db.Query(ctx, findLegacyRuntimesByDaemonID, arg.WorkspaceID, arg.Provider, arg.DaemonID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []AgentRuntime{}
|
|
for rows.Next() {
|
|
var i AgentRuntime
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const forceOfflineRuntimesByIDs = `-- name: ForceOfflineRuntimesByIDs :many
|
|
UPDATE agent_runtime
|
|
SET status = 'offline', updated_at = now()
|
|
WHERE id = ANY($1::uuid[]) AND status = 'online'
|
|
RETURNING id, workspace_id, owner_id, daemon_id, provider
|
|
`
|
|
|
|
type ForceOfflineRuntimesByIDsRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
OwnerID pgtype.UUID `json:"owner_id"`
|
|
DaemonID pgtype.Text `json:"daemon_id"`
|
|
Provider string `json:"provider"`
|
|
}
|
|
|
|
// Unconditionally flips a known set of runtime IDs to offline. Distinct from
|
|
// MarkRuntimesOfflineByIDs (which keeps a stale-window predicate so the
|
|
// sweeper cannot demote a runtime that just heartbeated): this variant is
|
|
// used by intentional revocation paths — e.g. removing a workspace member —
|
|
// where the caller has already decided the runtime should be offline
|
|
// regardless of recent liveness.
|
|
func (q *Queries) ForceOfflineRuntimesByIDs(ctx context.Context, runtimeIds []pgtype.UUID) ([]ForceOfflineRuntimesByIDsRow, error) {
|
|
rows, err := q.db.Query(ctx, forceOfflineRuntimesByIDs, runtimeIds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ForceOfflineRuntimesByIDsRow{}
|
|
for rows.Next() {
|
|
var i ForceOfflineRuntimesByIDsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.OwnerID,
|
|
&i.DaemonID,
|
|
&i.Provider,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const getAgentRuntime = `-- name: GetAgentRuntime :one
|
|
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime
|
|
WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetAgentRuntime(ctx context.Context, id pgtype.UUID) (AgentRuntime, error) {
|
|
row := q.db.QueryRow(ctx, getAgentRuntime, id)
|
|
var i AgentRuntime
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getAgentRuntimeForWorkspace = `-- name: GetAgentRuntimeForWorkspace :one
|
|
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime
|
|
WHERE id = $1 AND workspace_id = $2
|
|
`
|
|
|
|
type GetAgentRuntimeForWorkspaceParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
func (q *Queries) GetAgentRuntimeForWorkspace(ctx context.Context, arg GetAgentRuntimeForWorkspaceParams) (AgentRuntime, error) {
|
|
row := q.db.QueryRow(ctx, getAgentRuntimeForWorkspace, arg.ID, arg.WorkspaceID)
|
|
var i AgentRuntime
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getAgentRuntimes = `-- name: GetAgentRuntimes :many
|
|
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime
|
|
WHERE id = ANY($1::uuid[])
|
|
`
|
|
|
|
// Batch variant of GetAgentRuntime (MUL-4257): loads every runtime in the
|
|
// input set in one round trip so the machine-level batch claim handler can
|
|
// resolve+authorize all of a daemon's runtimes without one point query per
|
|
// runtime. Rows are returned only for ids that exist; the caller matches them
|
|
// back by id and skips any that are missing.
|
|
func (q *Queries) GetAgentRuntimes(ctx context.Context, ids []pgtype.UUID) ([]AgentRuntime, error) {
|
|
rows, err := q.db.Query(ctx, getAgentRuntimes, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []AgentRuntime{}
|
|
for rows.Next() {
|
|
var i AgentRuntime
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAgentRuntimes = `-- name: ListAgentRuntimes :many
|
|
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime
|
|
WHERE workspace_id = $1
|
|
ORDER BY created_at ASC
|
|
`
|
|
|
|
func (q *Queries) ListAgentRuntimes(ctx context.Context, workspaceID pgtype.UUID) ([]AgentRuntime, error) {
|
|
rows, err := q.db.Query(ctx, listAgentRuntimes, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []AgentRuntime{}
|
|
for rows.Next() {
|
|
var i AgentRuntime
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAgentRuntimesByOwner = `-- name: ListAgentRuntimesByOwner :many
|
|
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime
|
|
WHERE workspace_id = $1 AND owner_id = $2
|
|
ORDER BY created_at ASC
|
|
`
|
|
|
|
type ListAgentRuntimesByOwnerParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
OwnerID pgtype.UUID `json:"owner_id"`
|
|
}
|
|
|
|
func (q *Queries) ListAgentRuntimesByOwner(ctx context.Context, arg ListAgentRuntimesByOwnerParams) ([]AgentRuntime, error) {
|
|
rows, err := q.db.Query(ctx, listAgentRuntimesByOwner, arg.WorkspaceID, arg.OwnerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []AgentRuntime{}
|
|
for rows.Next() {
|
|
var i AgentRuntime
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listDaemonCustomNames = `-- name: ListDaemonCustomNames :many
|
|
SELECT custom_name FROM agent_runtime
|
|
WHERE workspace_id = $1
|
|
AND daemon_id = $2
|
|
AND id <> $3
|
|
`
|
|
|
|
type ListDaemonCustomNamesParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
DaemonID pgtype.Text `json:"daemon_id"`
|
|
ExcludeID pgtype.UUID `json:"exclude_id"`
|
|
}
|
|
|
|
// Lists the custom_name of every OTHER runtime on (workspace_id, daemon_id)
|
|
// (MUL-4217). @exclude_id drops the just-registered row. The caller derives
|
|
// the machine-level name in Go — the same "all runtimes share one non-null
|
|
// name" rule the frontend applies in sharedCustomName — so a freshly-added
|
|
// runtime on an already-named machine can inherit that name and keep the
|
|
// machine's display name stable. A daemon hosts only a handful of runtimes
|
|
// (one per provider), so this is a tiny read.
|
|
func (q *Queries) ListDaemonCustomNames(ctx context.Context, arg ListDaemonCustomNamesParams) ([]pgtype.Text, error) {
|
|
rows, err := q.db.Query(ctx, listDaemonCustomNames, arg.WorkspaceID, arg.DaemonID, arg.ExcludeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.Text{}
|
|
for rows.Next() {
|
|
var custom_name pgtype.Text
|
|
if err := rows.Scan(&custom_name); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, custom_name)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const lockAgentRuntime = `-- name: LockAgentRuntime :one
|
|
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
`
|
|
|
|
// Acquires a row-level exclusive lock on the runtime row. Used at the
|
|
// top of the cascade-delete transaction so that:
|
|
// 1. PostgreSQL's FK validation on agent.runtime_id (FK ... ON DELETE
|
|
// RESTRICT) needs FOR KEY SHARE on the parent runtime row, which
|
|
// conflicts with FOR UPDATE — so any concurrent INSERT or UPDATE
|
|
// that would point a new/moved agent at this runtime blocks until
|
|
// our transaction finishes; and
|
|
// 2. concurrent UPDATE/DELETE of the runtime row itself (e.g. another
|
|
// delete attempt) waits for us to commit.
|
|
//
|
|
// Combined with ListUserAgentsByRuntimeForUpdate (which row-locks active and
|
|
// archived user agents) this closes both plan drift and archived-agent restore
|
|
// races under read-committed isolation.
|
|
func (q *Queries) LockAgentRuntime(ctx context.Context, id pgtype.UUID) (AgentRuntime, error) {
|
|
row := q.db.QueryRow(ctx, lockAgentRuntime, id)
|
|
var i AgentRuntime
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const lockRuntimesForMerge = `-- name: LockRuntimesForMerge :many
|
|
SELECT id FROM agent_runtime
|
|
WHERE id = ANY($1::uuid[])
|
|
ORDER BY id
|
|
FOR UPDATE
|
|
`
|
|
|
|
// Step 2: the runtime rows themselves, FOR UPDATE, in id order.
|
|
//
|
|
// FOR UPDATE is the point. A task write's fence takes FOR KEY SHARE on the runtime
|
|
// it references, and KEY SHARE conflicts with UPDATE but not with another KEY
|
|
// SHARE — so while the merge held only the workspace's KEY SHARE, a concurrent
|
|
// enqueue against the OLD runtime went straight through after the task scan and was
|
|
// then silently removed by ON DELETE CASCADE when the old runtime was deleted.
|
|
// Holding FOR UPDATE on both runtimes from before the scan until COMMIT means a
|
|
// late writer either commits first (and the scan sees its task) or waits and finds
|
|
// the runtime gone, which its fence reports as "no row written".
|
|
//
|
|
// Both runtimes are locked in one ordered statement so two merges running in
|
|
// opposite directions cannot take the same pair in opposite orders.
|
|
//
|
|
// Returns the ids it actually locked: a caller that asked for two and got fewer
|
|
// knows a runtime disappeared before it got there and must abandon the merge.
|
|
func (q *Queries) LockRuntimesForMerge(ctx context.Context, runtimeIds []pgtype.UUID) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, lockRuntimesForMerge, runtimeIds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const lockWorkspaceForRuntimeMerge = `-- name: LockWorkspaceForRuntimeMerge :exec
|
|
SELECT 1 FROM workspace w
|
|
WHERE w.id IN (
|
|
SELECT r.workspace_id FROM agent_runtime r WHERE r.id = ANY($1::uuid[])
|
|
)
|
|
ORDER BY w.id
|
|
FOR KEY SHARE
|
|
`
|
|
|
|
// Step 1 of the legacy runtime merge's fence, and the same first step every task
|
|
// write takes (lock_task_owner_rows, migration 284): the workspace row, FOR KEY
|
|
// SHARE. Taking it here rather than relying on the fence inside the reassignment
|
|
// keeps the merge's lock order identical to the writers' — workspaces before owner
|
|
// rows — so the two can never hold each other's next lock (MUL-5999).
|
|
func (q *Queries) LockWorkspaceForRuntimeMerge(ctx context.Context, runtimeIds []pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, lockWorkspaceForRuntimeMerge, runtimeIds)
|
|
return err
|
|
}
|
|
|
|
const markAgentRuntimeOnline = `-- name: MarkAgentRuntimeOnline :one
|
|
UPDATE agent_runtime
|
|
SET status = 'online', last_seen_at = now(), updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name
|
|
`
|
|
|
|
// Used on the offline→online transition (and on first heartbeat after
|
|
// registration). Writes status, last_seen_at, and updated_at because the
|
|
// status flip is a real state change and we want updated_at to reflect it.
|
|
func (q *Queries) MarkAgentRuntimeOnline(ctx context.Context, id pgtype.UUID) (AgentRuntime, error) {
|
|
row := q.db.QueryRow(ctx, markAgentRuntimeOnline, id)
|
|
var i AgentRuntime
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const markRuntimesOfflineByIDs = `-- name: MarkRuntimesOfflineByIDs :many
|
|
UPDATE agent_runtime
|
|
SET status = 'offline', updated_at = now()
|
|
WHERE status = 'online'
|
|
AND id = ANY($1::uuid[])
|
|
AND last_seen_at < now() - make_interval(secs => $2::double precision)
|
|
RETURNING id, workspace_id, owner_id, daemon_id, provider
|
|
`
|
|
|
|
type MarkRuntimesOfflineByIDsParams struct {
|
|
Ids []pgtype.UUID `json:"ids"`
|
|
StaleSeconds float64 `json:"stale_seconds"`
|
|
}
|
|
|
|
type MarkRuntimesOfflineByIDsRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
OwnerID pgtype.UUID `json:"owner_id"`
|
|
DaemonID pgtype.Text `json:"daemon_id"`
|
|
Provider string `json:"provider"`
|
|
}
|
|
|
|
// Flips a known set of runtime IDs from online to offline. Paired with
|
|
// SelectStaleOnlineRuntimes in the sweeper so the candidate selection and
|
|
// the actual write are decoupled (the LivenessStore filter sits between).
|
|
//
|
|
// Re-checks the stale predicate inside the UPDATE so a concurrent heartbeat
|
|
// between the SELECT (candidate gather), the LivenessStore filter, and this
|
|
// UPDATE cannot demote a runtime that just refreshed last_seen_at. The
|
|
// legacy MarkStaleRuntimesOffline UPDATE had this property implicitly
|
|
// because the predicate and the write lived in one statement; here we
|
|
// carry it forward explicitly so the SELECT/filter/UPDATE pipeline retains
|
|
// the same race-freedom.
|
|
func (q *Queries) MarkRuntimesOfflineByIDs(ctx context.Context, arg MarkRuntimesOfflineByIDsParams) ([]MarkRuntimesOfflineByIDsRow, error) {
|
|
rows, err := q.db.Query(ctx, markRuntimesOfflineByIDs, arg.Ids, arg.StaleSeconds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MarkRuntimesOfflineByIDsRow{}
|
|
for rows.Next() {
|
|
var i MarkRuntimesOfflineByIDsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.OwnerID,
|
|
&i.DaemonID,
|
|
&i.Provider,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const reassignAgentsToRuntime = `-- name: ReassignAgentsToRuntime :execrows
|
|
UPDATE agent
|
|
SET runtime_id = $1
|
|
WHERE runtime_id = $2
|
|
`
|
|
|
|
type ReassignAgentsToRuntimeParams struct {
|
|
NewRuntimeID pgtype.UUID `json:"new_runtime_id"`
|
|
OldRuntimeID pgtype.UUID `json:"old_runtime_id"`
|
|
}
|
|
|
|
// Re-points every agent referencing old_runtime_id at new_runtime_id.
|
|
func (q *Queries) ReassignAgentsToRuntime(ctx context.Context, arg ReassignAgentsToRuntimeParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, reassignAgentsToRuntime, arg.NewRuntimeID, arg.OldRuntimeID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const reassignTasksToRuntime = `-- name: ReassignTasksToRuntime :one
|
|
WITH fence AS MATERIALIZED (
|
|
-- Once per statement rather than once per row: the predicate is VOLATILE, so
|
|
-- calling it from the WHERE clause of a bulk UPDATE would re-run it for every
|
|
-- candidate row.
|
|
SELECT lock_task_owner_rows(NULL, NULL, $1) AS ok
|
|
),
|
|
reassigned AS (
|
|
UPDATE agent_task_queue
|
|
SET runtime_id = $1
|
|
WHERE runtime_id = $2
|
|
AND (SELECT ok FROM fence)
|
|
RETURNING id
|
|
)
|
|
SELECT
|
|
(SELECT ok FROM fence) AS fence_ok,
|
|
(SELECT count(*) FROM reassigned) AS reassigned_tasks
|
|
`
|
|
|
|
type ReassignTasksToRuntimeParams struct {
|
|
NewRuntimeID pgtype.UUID `json:"new_runtime_id"`
|
|
OldRuntimeID pgtype.UUID `json:"old_runtime_id"`
|
|
}
|
|
|
|
type ReassignTasksToRuntimeRow struct {
|
|
FenceOk bool `json:"fence_ok"`
|
|
ReassignedTasks int64 `json:"reassigned_tasks"`
|
|
}
|
|
|
|
// Fenced against workspace teardown: lock_task_owner_rows (migration 284)
|
|
// locks the owners' workspace rows in the writer's own transaction and returns
|
|
// false once they are gone, so this statement writes no row instead of stranding
|
|
// a task in a workspace that has just been deleted (MUL-5999).
|
|
// Re-points every queued/running/completed task referencing old_runtime_id.
|
|
// Required before deleting the old runtime row because agent_task_queue has
|
|
// an ON DELETE CASCADE FK that would otherwise drop historical tasks.
|
|
//
|
|
// Returns the fence verdict separately from the row count on purpose. "0 rows"
|
|
// is ambiguous — it means either "the old runtime had no tasks" or "the fence
|
|
// refused" — and a caller that cannot tell them apart would go on to delete the
|
|
// old runtime, letting that same ON DELETE CASCADE drop the very history this
|
|
// statement exists to preserve. FenceOk = false must abort the merge.
|
|
func (q *Queries) ReassignTasksToRuntime(ctx context.Context, arg ReassignTasksToRuntimeParams) (ReassignTasksToRuntimeRow, error) {
|
|
row := q.db.QueryRow(ctx, reassignTasksToRuntime, arg.NewRuntimeID, arg.OldRuntimeID)
|
|
var i ReassignTasksToRuntimeRow
|
|
err := row.Scan(&i.FenceOk, &i.ReassignedTasks)
|
|
return i, err
|
|
}
|
|
|
|
const recordRuntimeLegacyDaemonID = `-- name: RecordRuntimeLegacyDaemonID :exec
|
|
UPDATE agent_runtime
|
|
SET legacy_daemon_id = COALESCE(legacy_daemon_id, $2)
|
|
WHERE id = $1
|
|
`
|
|
|
|
type RecordRuntimeLegacyDaemonIDParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
LegacyDaemonID pgtype.Text `json:"legacy_daemon_id"`
|
|
}
|
|
|
|
// Remembers the most recent hostname-derived daemon_id that was merged into
|
|
// this row. Useful for debugging when tracing back why a given runtime row
|
|
// subsumed an old one, and only overwrites NULL so the earliest merge is
|
|
// preserved.
|
|
func (q *Queries) RecordRuntimeLegacyDaemonID(ctx context.Context, arg RecordRuntimeLegacyDaemonIDParams) error {
|
|
_, err := q.db.Exec(ctx, recordRuntimeLegacyDaemonID, arg.ID, arg.LegacyDaemonID)
|
|
return err
|
|
}
|
|
|
|
const selectStaleOnlineRuntimes = `-- name: SelectStaleOnlineRuntimes :many
|
|
SELECT id, workspace_id, owner_id, daemon_id, provider FROM agent_runtime
|
|
WHERE status = 'online'
|
|
AND last_seen_at < now() - make_interval(secs => $1::double precision)
|
|
`
|
|
|
|
type SelectStaleOnlineRuntimesRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
OwnerID pgtype.UUID `json:"owner_id"`
|
|
DaemonID pgtype.Text `json:"daemon_id"`
|
|
Provider string `json:"provider"`
|
|
}
|
|
|
|
// Lists online runtimes whose last_seen_at exceeds the stale window. The
|
|
// sweeper uses this as a candidate set, then optionally filters via the
|
|
// LivenessStore before flipping rows to offline (a fresh Redis liveness
|
|
// record means the DB row is just lagging, not actually dead).
|
|
func (q *Queries) SelectStaleOnlineRuntimes(ctx context.Context, staleSeconds float64) ([]SelectStaleOnlineRuntimesRow, error) {
|
|
rows, err := q.db.Query(ctx, selectStaleOnlineRuntimes, staleSeconds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SelectStaleOnlineRuntimesRow{}
|
|
for rows.Next() {
|
|
var i SelectStaleOnlineRuntimesRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.OwnerID,
|
|
&i.DaemonID,
|
|
&i.Provider,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const setAgentRuntimeOffline = `-- name: SetAgentRuntimeOffline :exec
|
|
UPDATE agent_runtime
|
|
SET status = 'offline', updated_at = now()
|
|
WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) SetAgentRuntimeOffline(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, setAgentRuntimeOffline, id)
|
|
return err
|
|
}
|
|
|
|
const touchAgentRuntimeLastSeen = `-- name: TouchAgentRuntimeLastSeen :execrows
|
|
UPDATE agent_runtime
|
|
SET last_seen_at = now()
|
|
WHERE id = $1 AND status = 'online'
|
|
`
|
|
|
|
// Bumps last_seen_at on an already-online runtime. Deliberately does NOT
|
|
// touch status or updated_at: status is unchanged on the hot heartbeat path,
|
|
// and avoiding updated_at keeps the row HOT-eligible (no index columns
|
|
// change) and avoids invalidating any downstream consumer that watches
|
|
// updated_at.
|
|
//
|
|
// The status='online' predicate is load-bearing: callers read rt.Status from
|
|
// a prior SELECT and may race with the sweeper, which can flip the row to
|
|
// offline between that SELECT and this UPDATE. Without the predicate this
|
|
// query would silently leave a freshly-heartbeated runtime stuck in offline.
|
|
// Returning affected rows lets callers detect that race and fall back to
|
|
// MarkAgentRuntimeOnline to flip the row back online.
|
|
func (q *Queries) TouchAgentRuntimeLastSeen(ctx context.Context, id pgtype.UUID) (int64, error) {
|
|
result, err := q.db.Exec(ctx, touchAgentRuntimeLastSeen, id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const touchAgentRuntimesLastSeenBatch = `-- name: TouchAgentRuntimesLastSeenBatch :execrows
|
|
UPDATE agent_runtime
|
|
SET last_seen_at = now()
|
|
WHERE id = ANY($1::uuid[]) AND status = 'online'
|
|
`
|
|
|
|
// Bulk variant of TouchAgentRuntimeLastSeen used by the BatchedHeartbeatScheduler:
|
|
// coalesces N per-runtime "bump last_seen_at" requests into a single UPDATE so a
|
|
// fleet beating every 15s costs ~1 DB transaction per batch tick instead of N.
|
|
//
|
|
// Same load-bearing predicate as the single-id form: status='online' avoids
|
|
// silently un-deleting a sweeper-flipped offline row, and we deliberately do
|
|
// NOT touch updated_at so the rows stay HOT-eligible. Affected-rows < len(ids)
|
|
// means some IDs raced to offline between Schedule and flush; their next beat
|
|
// will fall through the recordHeartbeat sync path and call MarkAgentRuntimeOnline.
|
|
func (q *Queries) TouchAgentRuntimesLastSeenBatch(ctx context.Context, ids []pgtype.UUID) (int64, error) {
|
|
result, err := q.db.Exec(ctx, touchAgentRuntimesLastSeenBatch, ids)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const unbindTasksFromRuntime = `-- name: UnbindTasksFromRuntime :execrows
|
|
UPDATE agent_task_queue
|
|
SET runtime_id = NULL
|
|
WHERE runtime_id = $1 AND completed_at IS NOT NULL
|
|
`
|
|
|
|
// Detaches this runtime's task history so deleting the runtime row cannot
|
|
// cascade it away (agent_task_queue.runtime_id is ON DELETE CASCADE, and
|
|
// task_message / task_usage / task_token cascade from the task in turn).
|
|
// Restricted to terminal rows: an active task must keep its runtime, per
|
|
// agent_task_queue_active_requires_runtime. The caller runs
|
|
// CancelAgentTasksByRuntimeOrAgent +
|
|
// CountUndrainedTasksByRuntimeOrAgent first, so at this point "terminal" is
|
|
// every row on the runtime.
|
|
func (q *Queries) UnbindTasksFromRuntime(ctx context.Context, runtimeID pgtype.UUID) (int64, error) {
|
|
result, err := q.db.Exec(ctx, unbindTasksFromRuntime, runtimeID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const unbindUserAgentsFromRuntime = `-- name: UnbindUserAgentsFromRuntime :many
|
|
UPDATE agent
|
|
SET runtime_id = NULL, updated_at = now()
|
|
WHERE runtime_id = $1 AND kind = 'user'
|
|
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist, permission_mode, kind, system_key, disabled_runtime_skills, service_tier
|
|
`
|
|
|
|
// MUL-5559: the runtime-delete replacement for archive-then-hard-delete. Every
|
|
// user agent bound to this runtime becomes unbound (runtime_id IS NULL) and
|
|
// keeps its row, chats, labels, channel installations and autopilot config.
|
|
//
|
|
// Deliberately NOT filtered on archived_at: an agent archived earlier is just
|
|
// as much the user's data as an active one, and hard-deleting it was the same
|
|
// bug. Deliberately restricted to kind = 'user': system agents are invisible
|
|
// execution infrastructure with no UI to rebind them (see
|
|
// DeleteSystemAgentsByRuntime), so leaving them unbound would strand rows no
|
|
// one can repair.
|
|
func (q *Queries) UnbindUserAgentsFromRuntime(ctx context.Context, runtimeID pgtype.UUID) ([]Agent, error) {
|
|
rows, err := q.db.Query(ctx, unbindUserAgentsFromRuntime, runtimeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Agent{}
|
|
for rows.Next() {
|
|
var i Agent
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Name,
|
|
&i.AvatarUrl,
|
|
&i.RuntimeMode,
|
|
&i.RuntimeConfig,
|
|
&i.Visibility,
|
|
&i.Status,
|
|
&i.MaxConcurrentTasks,
|
|
&i.OwnerID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Description,
|
|
&i.RuntimeID,
|
|
&i.Instructions,
|
|
&i.ArchivedAt,
|
|
&i.ArchivedBy,
|
|
&i.CustomEnv,
|
|
&i.CustomArgs,
|
|
&i.McpConfig,
|
|
&i.Model,
|
|
&i.ThinkingLevel,
|
|
&i.ComposioToolkitAllowlist,
|
|
&i.PermissionMode,
|
|
&i.Kind,
|
|
&i.SystemKey,
|
|
&i.DisabledRuntimeSkills,
|
|
&i.ServiceTier,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const updateAgentRuntimeCustomName = `-- name: UpdateAgentRuntimeCustomName :one
|
|
UPDATE agent_runtime
|
|
SET custom_name = $1, updated_at = now()
|
|
WHERE id = $2
|
|
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name
|
|
`
|
|
|
|
type UpdateAgentRuntimeCustomNameParams struct {
|
|
CustomName pgtype.Text `json:"custom_name"`
|
|
ID pgtype.UUID `json:"id"`
|
|
}
|
|
|
|
// Sets or clears a runtime's user-facing custom name (MUL-4217). custom_name
|
|
// overrides the daemon-proposed `name` for display; passing NULL reverts to
|
|
// the default. Kept separate from the registration upserts above (which do
|
|
// name = EXCLUDED.name on every heartbeat) so a custom name is never
|
|
// clobbered by the daemon. Gated at the handler to owner / workspace admin.
|
|
func (q *Queries) UpdateAgentRuntimeCustomName(ctx context.Context, arg UpdateAgentRuntimeCustomNameParams) (AgentRuntime, error) {
|
|
row := q.db.QueryRow(ctx, updateAgentRuntimeCustomName, arg.CustomName, arg.ID)
|
|
var i AgentRuntime
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const updateAgentRuntimeCustomNameByDaemon = `-- name: UpdateAgentRuntimeCustomNameByDaemon :many
|
|
UPDATE agent_runtime
|
|
SET custom_name = $1, updated_at = now()
|
|
WHERE workspace_id = $2
|
|
AND daemon_id = $3
|
|
AND ($4::uuid IS NULL OR owner_id = $4)
|
|
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name
|
|
`
|
|
|
|
type UpdateAgentRuntimeCustomNameByDaemonParams struct {
|
|
CustomName pgtype.Text `json:"custom_name"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
DaemonID pgtype.Text `json:"daemon_id"`
|
|
OwnerID pgtype.UUID `json:"owner_id"`
|
|
}
|
|
|
|
// Machine-level rename (MUL-4217): applies one custom name to every runtime
|
|
// sharing a daemon_id in the workspace, since a single machine hosts one
|
|
// runtime per provider. @owner_id is NULL for workspace owners/admins (rename
|
|
// the whole machine) or the actor's user id otherwise (only their own
|
|
// runtimes on that machine), so a member cannot relabel someone else's
|
|
// runtime that happens to share the host.
|
|
func (q *Queries) UpdateAgentRuntimeCustomNameByDaemon(ctx context.Context, arg UpdateAgentRuntimeCustomNameByDaemonParams) ([]AgentRuntime, error) {
|
|
rows, err := q.db.Query(ctx, updateAgentRuntimeCustomNameByDaemon,
|
|
arg.CustomName,
|
|
arg.WorkspaceID,
|
|
arg.DaemonID,
|
|
arg.OwnerID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []AgentRuntime{}
|
|
for rows.Next() {
|
|
var i AgentRuntime
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const updateAgentRuntimeVisibility = `-- name: UpdateAgentRuntimeVisibility :one
|
|
UPDATE agent_runtime
|
|
SET visibility = $1, updated_at = now()
|
|
WHERE id = $2
|
|
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name
|
|
`
|
|
|
|
type UpdateAgentRuntimeVisibilityParams struct {
|
|
Visibility string `json:"visibility"`
|
|
ID pgtype.UUID `json:"id"`
|
|
}
|
|
|
|
// Toggles a runtime between 'private' (only owner can bind agents) and
|
|
// 'public' (any workspace member can). Default for new rows is 'private'
|
|
// (see migration 083). Gated at the handler layer to owner / workspace
|
|
// admin only.
|
|
func (q *Queries) UpdateAgentRuntimeVisibility(ctx context.Context, arg UpdateAgentRuntimeVisibilityParams) (AgentRuntime, error) {
|
|
row := q.db.QueryRow(ctx, updateAgentRuntimeVisibility, arg.Visibility, arg.ID)
|
|
var i AgentRuntime
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const upsertAgentRuntime = `-- name: UpsertAgentRuntime :one
|
|
INSERT INTO agent_runtime (
|
|
workspace_id,
|
|
daemon_id,
|
|
name,
|
|
runtime_mode,
|
|
provider,
|
|
status,
|
|
device_info,
|
|
metadata,
|
|
owner_id,
|
|
last_seen_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
|
|
ON CONFLICT (workspace_id, daemon_id, provider) WHERE profile_id IS NULL
|
|
DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
runtime_mode = EXCLUDED.runtime_mode,
|
|
status = EXCLUDED.status,
|
|
device_info = EXCLUDED.device_info,
|
|
metadata = EXCLUDED.metadata,
|
|
owner_id = COALESCE(EXCLUDED.owner_id, agent_runtime.owner_id),
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, (xmax = 0) AS inserted
|
|
`
|
|
|
|
type UpsertAgentRuntimeParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
DaemonID pgtype.Text `json:"daemon_id"`
|
|
Name string `json:"name"`
|
|
RuntimeMode string `json:"runtime_mode"`
|
|
Provider string `json:"provider"`
|
|
Status string `json:"status"`
|
|
DeviceInfo string `json:"device_info"`
|
|
Metadata []byte `json:"metadata"`
|
|
OwnerID pgtype.UUID `json:"owner_id"`
|
|
}
|
|
|
|
type UpsertAgentRuntimeRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
DaemonID pgtype.Text `json:"daemon_id"`
|
|
Name string `json:"name"`
|
|
RuntimeMode string `json:"runtime_mode"`
|
|
Provider string `json:"provider"`
|
|
Status string `json:"status"`
|
|
DeviceInfo string `json:"device_info"`
|
|
Metadata []byte `json:"metadata"`
|
|
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
|
OwnerID pgtype.UUID `json:"owner_id"`
|
|
LegacyDaemonID pgtype.Text `json:"legacy_daemon_id"`
|
|
Visibility string `json:"visibility"`
|
|
ProfileID pgtype.UUID `json:"profile_id"`
|
|
CustomName pgtype.Text `json:"custom_name"`
|
|
Inserted bool `json:"inserted"`
|
|
}
|
|
|
|
// (xmax = 0) AS inserted distinguishes a fresh insert (true) from an upsert
|
|
// that updated an existing row (false). Analytics reads this to fire
|
|
// runtime_registered/runtime_ready only on first-time registration.
|
|
// Built-in runtimes carry no profile_id. The arbiter is the partial unique
|
|
// index from migration 121 (WHERE profile_id IS NULL); the predicate must be
|
|
// spelled out so Postgres selects that partial index, not the custom-runtime
|
|
// one on (workspace_id, daemon_id, profile_id).
|
|
func (q *Queries) UpsertAgentRuntime(ctx context.Context, arg UpsertAgentRuntimeParams) (UpsertAgentRuntimeRow, error) {
|
|
row := q.db.QueryRow(ctx, upsertAgentRuntime,
|
|
arg.WorkspaceID,
|
|
arg.DaemonID,
|
|
arg.Name,
|
|
arg.RuntimeMode,
|
|
arg.Provider,
|
|
arg.Status,
|
|
arg.DeviceInfo,
|
|
arg.Metadata,
|
|
arg.OwnerID,
|
|
)
|
|
var i UpsertAgentRuntimeRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
&i.Inserted,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const upsertAgentRuntimeWithProfile = `-- name: UpsertAgentRuntimeWithProfile :one
|
|
INSERT INTO agent_runtime (
|
|
workspace_id,
|
|
daemon_id,
|
|
name,
|
|
runtime_mode,
|
|
provider,
|
|
status,
|
|
device_info,
|
|
metadata,
|
|
owner_id,
|
|
profile_id,
|
|
last_seen_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now())
|
|
ON CONFLICT (workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL
|
|
DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
runtime_mode = EXCLUDED.runtime_mode,
|
|
provider = EXCLUDED.provider,
|
|
status = EXCLUDED.status,
|
|
device_info = EXCLUDED.device_info,
|
|
metadata = EXCLUDED.metadata,
|
|
owner_id = COALESCE(EXCLUDED.owner_id, agent_runtime.owner_id),
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, (xmax = 0) AS inserted
|
|
`
|
|
|
|
type UpsertAgentRuntimeWithProfileParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
DaemonID pgtype.Text `json:"daemon_id"`
|
|
Name string `json:"name"`
|
|
RuntimeMode string `json:"runtime_mode"`
|
|
Provider string `json:"provider"`
|
|
Status string `json:"status"`
|
|
DeviceInfo string `json:"device_info"`
|
|
Metadata []byte `json:"metadata"`
|
|
OwnerID pgtype.UUID `json:"owner_id"`
|
|
ProfileID pgtype.UUID `json:"profile_id"`
|
|
}
|
|
|
|
type UpsertAgentRuntimeWithProfileRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
DaemonID pgtype.Text `json:"daemon_id"`
|
|
Name string `json:"name"`
|
|
RuntimeMode string `json:"runtime_mode"`
|
|
Provider string `json:"provider"`
|
|
Status string `json:"status"`
|
|
DeviceInfo string `json:"device_info"`
|
|
Metadata []byte `json:"metadata"`
|
|
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
|
OwnerID pgtype.UUID `json:"owner_id"`
|
|
LegacyDaemonID pgtype.Text `json:"legacy_daemon_id"`
|
|
Visibility string `json:"visibility"`
|
|
ProfileID pgtype.UUID `json:"profile_id"`
|
|
CustomName pgtype.Text `json:"custom_name"`
|
|
Inserted bool `json:"inserted"`
|
|
}
|
|
|
|
// Custom-runtime registration: a daemon resolved a workspace runtime_profile's
|
|
// command_name on PATH and is registering an instance of it. The arbiter is the
|
|
// partial unique index from migration 120 (WHERE profile_id IS NOT NULL), so a
|
|
// single daemon can host the built-in provider AND any number of custom
|
|
// profiles of the same protocol family. provider stays the protocol family so
|
|
// task routing (agent.New(provider)) is unchanged; profile_id is the stable
|
|
// identity. (xmax = 0) AS inserted mirrors UpsertAgentRuntime.
|
|
func (q *Queries) UpsertAgentRuntimeWithProfile(ctx context.Context, arg UpsertAgentRuntimeWithProfileParams) (UpsertAgentRuntimeWithProfileRow, error) {
|
|
row := q.db.QueryRow(ctx, upsertAgentRuntimeWithProfile,
|
|
arg.WorkspaceID,
|
|
arg.DaemonID,
|
|
arg.Name,
|
|
arg.RuntimeMode,
|
|
arg.Provider,
|
|
arg.Status,
|
|
arg.DeviceInfo,
|
|
arg.Metadata,
|
|
arg.OwnerID,
|
|
arg.ProfileID,
|
|
)
|
|
var i UpsertAgentRuntimeWithProfileRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.DaemonID,
|
|
&i.Name,
|
|
&i.RuntimeMode,
|
|
&i.Provider,
|
|
&i.Status,
|
|
&i.DeviceInfo,
|
|
&i.Metadata,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.OwnerID,
|
|
&i.LegacyDaemonID,
|
|
&i.Visibility,
|
|
&i.ProfileID,
|
|
&i.CustomName,
|
|
&i.Inserted,
|
|
)
|
|
return i, err
|
|
}
|