@desplega.ai/agent-swarm 1.145.0 → 1.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/{acp-adapter-580t41c0.js → acp-adapter-9n319wqc.js} +61 -6
- package/dist/{actions-m662tjng.js → actions-2vxqvpr9.js} +9 -8
- package/dist/{app-zb3rndm8.js → app-w066xfy0.js} +6 -5
- package/dist/assistant-kt31dj2h.js +236 -0
- package/dist/{boot-reembed-072x00vn.js → boot-reembed-az5rassp.js} +7 -6
- package/dist/{boot-reembed-65zz2smt.js → boot-reembed-j3mm3rfz.js} +8 -7
- package/dist/{boot-scrub-logs-yz0ams04.js → boot-scrub-logs-b6h54817.js} +4 -4
- package/dist/{claude-adapter-paj270y2.js → claude-adapter-w1z04ann.js} +8 -6
- package/dist/{claude-managed-setup-w619jncn.js → claude-managed-setup-f5pmsyrp.js} +24 -3
- package/dist/{claude-sdk-session-swt80j9z.js → claude-sdk-session-f0cc1kec.js} +8 -6
- package/dist/{cli-dt553ktb.js → cli-0b6y7kxm.js} +1 -1
- package/dist/{cli-bxdhw615.js → cli-12fz972k.js} +4 -4
- package/dist/{cli-s2f20r5w.js → cli-1th1728g.js} +2 -2
- package/dist/{cli-rs6ec3b9.js → cli-3tgwnf43.js} +3 -3
- package/dist/{cli-gxnzc6wh.js → cli-4nr988h6.js} +1 -1
- package/dist/{cli-axs8e6d4.js → cli-53s590z8.js} +1 -1
- package/dist/{cli-ygbjp3z8.js → cli-54wwve05.js} +5 -4
- package/dist/{cli-v0asr5yv.js → cli-719k9j2c.js} +2 -2
- package/dist/{cli-anktweqr.js → cli-89xfd7f9.js} +21 -47
- package/dist/{cli-q34a0e10.js → cli-8v0g1yc8.js} +1 -1
- package/dist/cli-9d8ntetj.js +42 -0
- package/dist/{cli-z9fprcrf.js → cli-atcve9y8.js} +881 -738
- package/dist/{cli-jmb7g47h.js → cli-b5bq9crk.js} +14 -31
- package/dist/cli-d8brzxjb.js +190 -0
- package/dist/{cli-gzky0gkr.js → cli-de14znh8.js} +2 -2
- package/dist/{cli-xytwff7f.js → cli-dttph1bw.js} +2 -2
- package/dist/cli-ep19k3mb.js +19 -0
- package/dist/{cli-m1xa8be7.js → cli-f146mn5s.js} +7 -50
- package/dist/{cli-1fj0chdk.js → cli-f7dsy61y.js} +5 -3
- package/dist/{cli-f567d5c1.js → cli-ftht3zzj.js} +2782 -2589
- package/dist/{cli-a0g1gbt5.js → cli-hbv0kq6w.js} +2 -2
- package/dist/{cli-fhsz3twr.js → cli-jsp14b6c.js} +171 -1
- package/dist/{cli-7fnkj1hj.js → cli-kpv03zhj.js} +21 -17
- package/dist/{cli-m4j6trn4.js → cli-m8yrsg97.js} +2 -2
- package/dist/cli-m9sxbkhm.js +779 -0
- package/dist/cli-mmemxxdc.js +823 -0
- package/dist/{cli-tqtvj5pt.js → cli-n8508nre.js} +1 -1
- package/dist/{cli-z96t0thj.js → cli-p1d5b073.js} +37 -2
- package/dist/{cli-gh8y8f36.js → cli-rw51bq3j.js} +13 -10
- package/dist/{cli-59ej1c07.js → cli-smby5m17.js} +7 -6
- package/dist/{cli-65vqzz3v.js → cli-tzvk9haz.js} +94 -372
- package/dist/{cli-dc9k2ba9.js → cli-w9kb3bk1.js} +1 -1
- package/dist/{cli-kpev657q.js → cli-y6b6fb76.js} +25 -3
- package/dist/{cli-123q2get.js → cli-yz3djrm0.js} +9 -9
- package/dist/{cli-wvp6bvy4.js → cli-zhjmqxzh.js} +1 -1
- package/dist/cli.js +13 -13
- package/dist/{codex-adapter-k98vaw2r.js → codex-adapter-zw4dwsx7.js} +2 -2
- package/dist/{codex-hook-9pf19cks.js → codex-hook-bw8p5nh4.js} +2 -2
- package/dist/{codex-session-runner-tngtphx7.js → codex-session-runner-k981s65v.js} +2 -2
- package/dist/{commands-y8nbr3fv.js → commands-sykrxx7e.js} +4 -4
- package/dist/{db-30p4h2t1.js → db-sfbsc8wt.js} +6 -4
- package/dist/handlers-0aspcejm.js +603 -0
- package/dist/{hook-dcw4jfbj.js → hook-4nvqj2sb.js} +7 -5
- package/dist/{hook-21jexvfx.js → hook-hxccs7m5.js} +24 -49
- package/dist/{http-r4kq5g8d.js → http-y06z517d.js} +230 -94
- package/dist/{index-q3qja6ta.js → index-hssww7kj.js} +15 -14
- package/dist/{index-gay66b9w.js → index-k8znfx0z.js} +13 -12
- package/dist/{index-53zyht4z.js → index-tce1rss4.js} +371 -110
- package/dist/{index-nqvf26za.js → index-x5j894zf.js} +14 -13
- package/dist/{keepalive-xs8fa55y.js → keepalive-w733ax66.js} +6 -6
- package/dist/{lead-d9m2t4s0.js → lead-75bjqa9d.js} +29 -27
- package/dist/{maintenance-3zh82g6z.js → maintenance-w4f1zjck.js} +8 -7
- package/dist/{oauth-refresh-sweep-03snsye5.js → oauth-refresh-sweep-wdetky26.js} +5 -5
- package/dist/{onboard-0cg1925x.js → onboard-bb5net8s.js} +2 -2
- package/dist/{otel-impl-0ab95z27.js → otel-impl-0w7c14ap.js} +1 -1
- package/dist/{pi-mono-adapter-dtkz4e7j.js → pi-mono-adapter-2has4gnp.js} +12 -2
- package/dist/{pricing-refresh-e5jhzz4j.js → pricing-refresh-9nzsrgcq.js} +6 -6
- package/dist/{rbac-roles-p1mf40z3.js → rbac-roles-r3hqjp09.js} +5 -5
- package/dist/{rbac-roles-2w3cmrkc.js → rbac-roles-z1jrffbt.js} +4 -4
- package/dist/{registry-9kdck5ye.js → registry-v5wytg0s.js} +1 -2
- package/dist/{render-v2-xhawry3k.js → render-v2-vbr89fnb.js} +6 -5
- package/dist/{seed-pricing-t223sw91.js → seed-pricing-g62hy1pk.js} +7 -5
- package/dist/{setup-zcs0rce7.js → setup-gnfqnptp.js} +2 -2
- package/dist/{worker-qnp2ka62.js → worker-kznxp4qm.js} +29 -27
- package/openapi.json +263 -12
- package/package.json +1 -1
- package/plugin/commands/start-worker.md +4 -9
- package/plugin/pi-skills/start-worker/SKILL.md +4 -9
- package/src/be/db/agents.ts +556 -0
- package/src/be/db/context-versions.ts +117 -0
- package/src/be/db/runtime.ts +406 -0
- package/src/be/db/tasks/read.ts +1167 -0
- package/src/be/db/tasks/write.ts +953 -0
- package/src/be/db.ts +584 -3429
- package/src/be/migrations/147_scheduled_tasks_parent_task.sql +7 -0
- package/src/be/migrations/148_pages_svg.sql +123 -0
- package/src/be/migrations/149_session_tokens.sql +24 -0
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-pricing.ts +13 -8
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
- package/src/be/seed-scripts/catalog/star-history-refresh.ts +101 -0
- package/src/be/seed-scripts/catalog/star-history-renderer.ts +119 -0
- package/src/be/seed-scripts/index.ts +17 -0
- package/src/be/seed-skills/index.ts +14 -0
- package/src/be/swarm-config-guard.ts +5 -2
- package/src/be/task-attachment-store.ts +70 -0
- package/src/be/users.ts +77 -0
- package/src/commands/attachments-section.ts +37 -0
- package/src/commands/claude-managed-setup.ts +45 -3
- package/src/commands/claude-md-session.ts +408 -0
- package/src/commands/context-preamble.ts +15 -3
- package/src/commands/profile-sync.ts +205 -5
- package/src/commands/provider-credentials.ts +60 -27
- package/src/commands/runner.ts +63 -58
- package/src/commands/templates.ts +25 -2
- package/src/hooks/hook.ts +26 -67
- package/src/http/agents.ts +40 -1
- package/src/http/auth.ts +13 -1
- package/src/http/fs.ts +21 -51
- package/src/http/index.ts +2 -0
- package/src/http/mcp.ts +32 -0
- package/src/http/pages-public.ts +20 -4
- package/src/http/pages.ts +11 -3
- package/src/http/poll.ts +4 -0
- package/src/http/repos.ts +3 -3
- package/src/http/sessions.ts +82 -0
- package/src/http/tasks.ts +10 -2
- package/src/http/utils.ts +79 -1
- package/src/prompts/session-templates.ts +12 -8
- package/src/providers/acp-adapter.ts +62 -8
- package/src/providers/pi-mono-adapter.ts +23 -6
- package/src/providers/pricing-sources.md +11 -0
- package/src/scheduler/scheduler.test.ts +49 -2
- package/src/scheduler/scheduler.ts +4 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +8 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +8 -0
- package/src/server.ts +2 -0
- package/src/slack/assistant.ts +87 -46
- package/src/slack/handlers.test.ts +1 -3
- package/src/slack/handlers.ts +102 -87
- package/src/slack/inbound-files.ts +466 -0
- package/src/slack/templates.ts +14 -0
- package/src/tasks/task-terminal-effects.ts +158 -0
- package/src/tests/acp-adapter.test.ts +369 -117
- package/src/tests/budget-claim-gate.test.ts +1 -0
- package/src/tests/build-pi-skills.test.ts +5 -7
- package/src/tests/claude-managed-setup.test.ts +52 -4
- package/src/tests/claude-md-session.test.ts +603 -0
- package/src/tests/codex-skill-resolver.test.ts +19 -0
- package/src/tests/context-versioning.test.ts +107 -0
- package/src/tests/cors-allowlist.test.ts +309 -0
- package/src/tests/credential-check.test.ts +157 -14
- package/src/tests/db-agents.test.ts +91 -0
- package/src/tests/db-context-versions.test.ts +37 -0
- package/src/tests/db-extraction-contract.test.ts +45 -0
- package/src/tests/db-runtime.test.ts +70 -0
- package/src/tests/db-task-read-contract.test.ts +624 -0
- package/src/tests/db-task-write-contract.test.ts +653 -0
- package/src/tests/db-tasks-read.test.ts +183 -0
- package/src/tests/db-tasks-write.test.ts +230 -0
- package/src/tests/defer-task.test.ts +389 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/e2e-visuals-stage.test.ts +151 -0
- package/src/tests/file-lock.test.ts +144 -0
- package/src/tests/fixtures/file-lock-holder.ts +13 -0
- package/src/tests/fixtures/hold-file-lock.ts +25 -0
- package/src/tests/heartbeat-expiry.test.ts +188 -0
- package/src/tests/http-api-integration.test.ts +78 -1
- package/src/tests/pages-public-html.test.ts +43 -0
- package/src/tests/pages-svg-migration.test.ts +42 -0
- package/src/tests/preload.ts +4 -1
- package/src/tests/profile-sync-warning.test.ts +16 -0
- package/src/tests/profile-sync.test.ts +126 -0
- package/src/tests/prompt-template-session.test.ts +20 -4
- package/src/tests/provider-registration.test.ts +93 -0
- package/src/tests/rbac-e2e-helpers.test.ts +53 -0
- package/src/tests/rbac-e2e-helpers.ts +19 -2
- package/src/tests/reload-config.test.ts +26 -0
- package/src/tests/routing-affinity-inventory.test.ts +1 -1
- package/src/tests/runner-attachments-section.test.ts +22 -2
- package/src/tests/runner-context-preamble.test.ts +30 -0
- package/src/tests/runner-deployment-resume.test.ts +107 -0
- package/src/tests/runner-repo-autostash.test.ts +12 -0
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tests/session-tokens.test.ts +279 -0
- package/src/tests/slack-file-tools-attachments.test.ts +408 -0
- package/src/tests/slack-inbound-files.test.ts +847 -0
- package/src/tests/star-history-refresh.test.ts +102 -0
- package/src/tests/swarm-config-reserved-keys.test.ts +21 -0
- package/src/tests/swarm-repos.test.ts +20 -0
- package/src/tests/tool-annotations.test.ts +4 -3
- package/src/tests/ui-logs-parser.test.ts +176 -0
- package/src/tools/create-page.ts +4 -2
- package/src/tools/defer-task.ts +239 -0
- package/src/tools/repos/update-repo.ts +2 -2
- package/src/tools/schedules/create-schedule.ts +1 -0
- package/src/tools/schedules/list-schedules.ts +1 -0
- package/src/tools/schedules/patch-schedule.ts +1 -0
- package/src/tools/schedules/update-schedule.ts +1 -0
- package/src/tools/slack-download-file.ts +60 -29
- package/src/tools/slack-read.ts +73 -32
- package/src/tools/store-progress.ts +16 -146
- package/src/tools/tool-config.ts +1 -0
- package/src/tools/utils.ts +21 -0
- package/src/types.ts +37 -4
- package/src/utils/acp-session-token.ts +56 -0
- package/src/utils/file-lock.ts +111 -0
- package/src/utils/heartbeat-expiry.ts +78 -0
- package/src/utils/request-auth-context.ts +2 -0
- package/src/utils/task-attachment-links.ts +22 -0
- package/templates/skills/code-quality/content.md +8 -5
- package/templates/skills/download-task-attachment/content.md +2 -1
- package/{plugin/pi-skills → templates/skills}/review-offered-task/SKILL.md +5 -5
- package/templates/skills/review-offered-task/config.json +14 -0
- package/{plugin/commands/review-offered-task.md → templates/skills/review-offered-task/content.md} +3 -8
- package/templates/skills/scheduling/content.md +4 -0
- package/templates/skills/slack-interaction/content.md +2 -2
- package/templates/skills/work-on-task/SKILL.md +16 -0
- package/templates/skills/work-on-task/config.json +14 -0
- package/templates/skills/work-on-task/content.md +11 -0
- package/dist/assistant-0qt42nzg.js +0 -212
- package/dist/cli-fs1kkqgr.js +0 -317
- package/dist/cli-jnf5zghe.js +0 -269
- package/dist/cli-n1q8q14d.js +0 -177
- package/dist/handlers-yfp1qn81.js +0 -592
- package/plugin/commands/work-on-task.md +0 -39
- package/plugin/pi-skills/work-on-task/SKILL.md +0 -39
package/src/be/db.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
2
|
-
import { parseProviderMeta } from "@/utils/provider-metadata.ts";
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
3
2
|
import pkg from "../../package.json";
|
|
4
3
|
import { defaultAssetKey, normalizeAssetKey } from "../assets/key";
|
|
5
4
|
import {
|
|
@@ -8,7 +7,6 @@ import {
|
|
|
8
7
|
matchesDefaultClaudeMd,
|
|
9
8
|
matchesDefaultIdentityMd,
|
|
10
9
|
} from "../prompts/defaults";
|
|
11
|
-
import { configureDbResolver } from "../prompts/resolver";
|
|
12
10
|
import { realtimeBus } from "../realtime/bus";
|
|
13
11
|
import { slackChannelFromContextKey } from "../tasks/slack-routing";
|
|
14
12
|
import { _resolveIntegrationType, emitIntegrationConnected, telemetry } from "../telemetry";
|
|
@@ -16,14 +14,11 @@ import type {
|
|
|
16
14
|
ActiveSession,
|
|
17
15
|
Agent,
|
|
18
16
|
AgentAvatar,
|
|
19
|
-
AgentCredStatus,
|
|
20
17
|
AgentLog,
|
|
21
18
|
AgentLogEventType,
|
|
22
19
|
AgentMcpServer,
|
|
23
20
|
AgentSkill,
|
|
24
|
-
AgentStatus,
|
|
25
21
|
AgentTask,
|
|
26
|
-
AgentTaskSource,
|
|
27
22
|
AgentTaskStatus,
|
|
28
23
|
AgentTaskSummary,
|
|
29
24
|
AgentWithTasks,
|
|
@@ -35,16 +30,13 @@ import type {
|
|
|
35
30
|
BudgetRefusalCause,
|
|
36
31
|
BudgetRefusalNotification,
|
|
37
32
|
BudgetScope,
|
|
38
|
-
ChangeSource,
|
|
39
33
|
Channel,
|
|
40
34
|
ChannelMessage,
|
|
41
35
|
ChannelType,
|
|
42
36
|
ContextSnapshot,
|
|
43
37
|
ContextSnapshotEventType,
|
|
44
|
-
ContextVersion,
|
|
45
38
|
CooldownConfig,
|
|
46
39
|
FavoriteItemType,
|
|
47
|
-
FollowUpConfig,
|
|
48
40
|
InboxItemState,
|
|
49
41
|
InboxItemStatus,
|
|
50
42
|
InboxItemType,
|
|
@@ -71,12 +63,12 @@ import type {
|
|
|
71
63
|
PricingProvider,
|
|
72
64
|
PricingRow,
|
|
73
65
|
PricingTokenClass,
|
|
66
|
+
ProfileExpectedHashes,
|
|
67
|
+
ProfileSyncConflict,
|
|
74
68
|
PromptTemplate,
|
|
75
69
|
PromptTemplateHistory,
|
|
76
70
|
ProviderName,
|
|
77
|
-
ReasoningEffort,
|
|
78
71
|
RepoGuidelines,
|
|
79
|
-
RoutingAffinity,
|
|
80
72
|
ScheduledTask,
|
|
81
73
|
ScheduledTaskSummary,
|
|
82
74
|
ScriptRun,
|
|
@@ -123,22 +115,18 @@ import type {
|
|
|
123
115
|
WorkflowVersion,
|
|
124
116
|
} from "../types";
|
|
125
117
|
import {
|
|
126
|
-
AgentAvatarSchema,
|
|
127
118
|
type CreateTaskOptions,
|
|
128
119
|
CreateTaskOptionsSchema,
|
|
129
|
-
FollowUpConfigSchema,
|
|
130
120
|
isTerminalTaskStatus,
|
|
131
121
|
type ModelTier,
|
|
132
122
|
parseModelTier,
|
|
133
|
-
ReasoningEffortSchema,
|
|
134
|
-
RoutingAffinitySchema,
|
|
135
123
|
SERVER_GENERATED_ATTACHMENT_CAPABILITY,
|
|
136
124
|
SessionCostModelBreakdownSchema,
|
|
137
125
|
} from "../types";
|
|
138
126
|
import { deriveProviderFromKeyType } from "../utils/credentials";
|
|
139
|
-
import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
140
127
|
import type { RateLimitWindowTelemetry } from "../utils/error-tracker";
|
|
141
128
|
import { extractGitHubPullRequestUrls } from "../utils/github-pull-request";
|
|
129
|
+
import { validateHeartbeatExpiry } from "../utils/heartbeat-expiry";
|
|
142
130
|
import {
|
|
143
131
|
type BudgetedIdentityField,
|
|
144
132
|
checkIdentityFieldBudget,
|
|
@@ -146,20 +134,167 @@ import {
|
|
|
146
134
|
} from "../utils/identity-field-budget";
|
|
147
135
|
import { getCurrentRequestUserId } from "../utils/request-auth-context";
|
|
148
136
|
import { registerVolatileSecret, scrubSecrets } from "../utils/secret-scrubber";
|
|
149
|
-
import { auditAssetKeys
|
|
150
|
-
import {
|
|
151
|
-
import { decryptSecret, encryptSecret, getEncryptionKey, resolveEncryptionKey } from "./crypto";
|
|
137
|
+
import { auditAssetKeys } from "./asset-key-audit";
|
|
138
|
+
import { decryptSecret, encryptSecret, getEncryptionKey } from "./crypto";
|
|
152
139
|
import { normalizeDate, normalizeDateRequired } from "./date-utils";
|
|
153
|
-
import {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
140
|
+
import {
|
|
141
|
+
type AgentRow,
|
|
142
|
+
configureAgentDependencies,
|
|
143
|
+
getActiveTaskCount,
|
|
144
|
+
getAgentById,
|
|
145
|
+
getAllAgents,
|
|
146
|
+
isAgentEligibleForTask,
|
|
147
|
+
rowToAgent,
|
|
148
|
+
} from "./db/agents";
|
|
149
|
+
import {
|
|
150
|
+
computeContentHash,
|
|
151
|
+
createContextVersion,
|
|
152
|
+
getLatestContextVersion,
|
|
153
|
+
} from "./db/context-versions";
|
|
154
|
+
import { getDb, getDbClient } from "./db/runtime";
|
|
155
|
+
import {
|
|
156
|
+
type AgentTaskRow,
|
|
157
|
+
configureTaskReadDependencies,
|
|
158
|
+
type ExistingTrackerContextWork,
|
|
159
|
+
findExistingLinearTrackerContextWork,
|
|
160
|
+
getTaskById,
|
|
161
|
+
getTasksByAgentId,
|
|
162
|
+
rowToAgentTask,
|
|
163
|
+
rowToAgentTaskSummary,
|
|
164
|
+
} from "./db/tasks/read";
|
|
165
|
+
import { configureTaskWriteDependencies, failTask } from "./db/tasks/write";
|
|
157
166
|
import { promotePendingSteeringForTask } from "./steering";
|
|
158
167
|
import { isReservedConfigKey, reservedKeyError } from "./swarm-config-guard";
|
|
159
168
|
import { emitTaskStarted } from "./task-lifecycle-events";
|
|
160
169
|
|
|
161
|
-
|
|
162
|
-
|
|
170
|
+
export {
|
|
171
|
+
buildRoutingAffinityFromAgent,
|
|
172
|
+
createAgent,
|
|
173
|
+
deleteAgent,
|
|
174
|
+
getActiveTaskCount,
|
|
175
|
+
getAgentById,
|
|
176
|
+
getAgentHarnessProviders,
|
|
177
|
+
getAllAgents,
|
|
178
|
+
getLeadAgent,
|
|
179
|
+
getRemainingCapacity,
|
|
180
|
+
hasCapacity,
|
|
181
|
+
incrementEmptyPollCount,
|
|
182
|
+
isAgentEligibleForTask,
|
|
183
|
+
isPoolAffinityEnforcementEnabled,
|
|
184
|
+
listAgentsWithCredStatusByProvider,
|
|
185
|
+
MAX_EMPTY_POLLS,
|
|
186
|
+
resetEmptyPollCount,
|
|
187
|
+
setAgentHarnessProvider,
|
|
188
|
+
shouldBlockPolling,
|
|
189
|
+
updateAgentActivity,
|
|
190
|
+
updateAgentCredentialMissing,
|
|
191
|
+
updateAgentCredentialState,
|
|
192
|
+
updateAgentCredStatus,
|
|
193
|
+
updateAgentMaxTasks,
|
|
194
|
+
updateAgentProvider,
|
|
195
|
+
updateAgentStatus,
|
|
196
|
+
updateAgentStatusFromCapacity,
|
|
197
|
+
} from "./db/agents";
|
|
198
|
+
export {
|
|
199
|
+
computeContentHash,
|
|
200
|
+
createContextVersion,
|
|
201
|
+
getContextVersion,
|
|
202
|
+
getContextVersionHistory,
|
|
203
|
+
getLatestContextVersion,
|
|
204
|
+
} from "./db/context-versions";
|
|
205
|
+
|
|
206
|
+
configureAgentDependencies({
|
|
207
|
+
createLogEntry: (entry) => createLogEntry(entry),
|
|
208
|
+
installSystemDefaultSkillsForAgent: (agentId) => installSystemDefaultSkillsForAgent(agentId),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
export {
|
|
212
|
+
__resetSqliteVecExtensionPathCacheForTests,
|
|
213
|
+
closeDb,
|
|
214
|
+
getDb,
|
|
215
|
+
getDbClient,
|
|
216
|
+
initDb,
|
|
217
|
+
isSqliteVecAvailable,
|
|
218
|
+
resolveSqliteVecExtensionPath,
|
|
219
|
+
} from "./db/runtime";
|
|
220
|
+
|
|
221
|
+
export {
|
|
222
|
+
type ExistingTrackerContextWork,
|
|
223
|
+
type ExistingTrackerContextWorkReason,
|
|
224
|
+
findCompletedTaskInThread,
|
|
225
|
+
findExistingLinearTrackerContextWork,
|
|
226
|
+
findRecentCancelledTaskInThread,
|
|
227
|
+
findTaskByGitHub,
|
|
228
|
+
findTaskByVcs,
|
|
229
|
+
getAgentCurrentTask,
|
|
230
|
+
getAgentWorkingOnThread,
|
|
231
|
+
getAllTasks,
|
|
232
|
+
getChildTasks,
|
|
233
|
+
getCompletedSlackTasks,
|
|
234
|
+
getInProgressSlackTasks,
|
|
235
|
+
getInProgressTasksByContextKey,
|
|
236
|
+
getLatestActiveTaskInThread,
|
|
237
|
+
getLatestLeadTaskInThread,
|
|
238
|
+
getLatestScriptRunStepTaskByContextKey,
|
|
239
|
+
getLatestTaskByContextKey,
|
|
240
|
+
getMostRecentTaskInThread,
|
|
241
|
+
getPendingSlackRelayTasks,
|
|
242
|
+
getRecentlyFinishedWorkerTasks,
|
|
243
|
+
getSlackTasksMissingTree,
|
|
244
|
+
getTaskById,
|
|
245
|
+
getTaskStats,
|
|
246
|
+
getTasksByAgentId,
|
|
247
|
+
getTasksByStatus,
|
|
248
|
+
getTasksCount,
|
|
249
|
+
hasNonTerminalRerouteDecisionChild,
|
|
250
|
+
hasNonTerminalResumeChild,
|
|
251
|
+
markFinalizedSlackRelaysDelivered,
|
|
252
|
+
markSlackRelayAttempted,
|
|
253
|
+
markSlackRelayDelivered,
|
|
254
|
+
markTasksNotified,
|
|
255
|
+
resetTasksNotified,
|
|
256
|
+
type TaskFilters,
|
|
257
|
+
} from "./db/tasks/read";
|
|
258
|
+
|
|
259
|
+
export {
|
|
260
|
+
assignUnassignedTaskPending,
|
|
261
|
+
backfillSupersedeTaskResumeTaskId,
|
|
262
|
+
cancelTask,
|
|
263
|
+
completeTask,
|
|
264
|
+
createTask,
|
|
265
|
+
deleteTask,
|
|
266
|
+
failTask,
|
|
267
|
+
getOrphanedInProgressTasksForAgent,
|
|
268
|
+
getPausedTasksForAgent,
|
|
269
|
+
getPendingTaskForAgent,
|
|
270
|
+
getRecentlyCancelledTasksForAgent,
|
|
271
|
+
overwriteTerminalTaskResultText,
|
|
272
|
+
pauseTask,
|
|
273
|
+
resetOrphanedInProgressTasksForAgent,
|
|
274
|
+
resumeTask,
|
|
275
|
+
startTask,
|
|
276
|
+
supersedeTask,
|
|
277
|
+
updateTaskClaudeSessionId,
|
|
278
|
+
updateTaskProgress,
|
|
279
|
+
updateTaskTitle,
|
|
280
|
+
} from "./db/tasks/write";
|
|
281
|
+
|
|
282
|
+
configureTaskWriteDependencies({
|
|
283
|
+
createLogEntry: (...args) => createLogEntry(...args),
|
|
284
|
+
checkDependencies: (...args) => checkDependencies(...args),
|
|
285
|
+
reconcileTaskPullRequestAttachments: (...args) => reconcileTaskPullRequestAttachments(...args),
|
|
286
|
+
emitTaskLifecycleTelemetryAfterCommit: (...args) =>
|
|
287
|
+
emitTaskLifecycleTelemetryAfterCommit(...args),
|
|
288
|
+
taskContextForTelemetry: (...args) => taskContextForTelemetry(...args),
|
|
289
|
+
promotePendingSteeringForTask: (...args) => promotePendingSteeringForTask(...args),
|
|
290
|
+
cascadeFailDependents: (...args) => cascadeFailDependents(...args),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
configureTaskReadDependencies({
|
|
294
|
+
checkDependencies: (taskId) => checkDependencies(taskId),
|
|
295
|
+
assetKeyPrefixPattern: (input) => assetKeyPrefixPattern(input),
|
|
296
|
+
previewText: (text, maxChars) => previewText(text, maxChars),
|
|
297
|
+
});
|
|
163
298
|
|
|
164
299
|
type TaskTelemetryProps = Parameters<typeof telemetry.taskEvent>[1];
|
|
165
300
|
type TaskTelemetryContext = {
|
|
@@ -211,303 +346,6 @@ function taskContextForTelemetry(task: AgentTask): TaskTelemetryContext {
|
|
|
211
346
|
return context;
|
|
212
347
|
}
|
|
213
348
|
|
|
214
|
-
export function isSqliteVecAvailable(): boolean {
|
|
215
|
-
return sqliteVecAvailable;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Resolve the sqlite-vec loadable extension path without opening a Database.
|
|
220
|
-
* Mirrors loadSqliteVec's env-var-first, npm-fallback resolution, but returns
|
|
221
|
-
* the path instead of loading it: the bounded db-query child process (see
|
|
222
|
-
* src/http/db-query-bounded.ts) opens its own read-only connection and must
|
|
223
|
-
* load the extension itself, so the parent resolves the path once and passes
|
|
224
|
-
* it along. The result is memoized — `undefined` is a valid resolved answer
|
|
225
|
-
* (extension genuinely unavailable) and is cached too, so a failing
|
|
226
|
-
* `require` doesn't retry on every query.
|
|
227
|
-
*/
|
|
228
|
-
let cachedSqliteVecExtensionPath: string | undefined | null = null;
|
|
229
|
-
|
|
230
|
-
export function resolveSqliteVecExtensionPath(): string | undefined {
|
|
231
|
-
if (cachedSqliteVecExtensionPath !== null) return cachedSqliteVecExtensionPath;
|
|
232
|
-
const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH;
|
|
233
|
-
if (extensionPath) {
|
|
234
|
-
cachedSqliteVecExtensionPath = extensionPath;
|
|
235
|
-
return cachedSqliteVecExtensionPath;
|
|
236
|
-
}
|
|
237
|
-
try {
|
|
238
|
-
cachedSqliteVecExtensionPath = (
|
|
239
|
-
require("sqlite-vec") as { getLoadablePath(): string }
|
|
240
|
-
).getLoadablePath();
|
|
241
|
-
} catch {
|
|
242
|
-
cachedSqliteVecExtensionPath = undefined;
|
|
243
|
-
}
|
|
244
|
-
return cachedSqliteVecExtensionPath;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** Test-only: clear the memoized sqlite-vec extension path cache. */
|
|
248
|
-
export function __resetSqliteVecExtensionPathCacheForTests(): void {
|
|
249
|
-
cachedSqliteVecExtensionPath = null;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function loadSqliteVec(database: Database): void {
|
|
253
|
-
sqliteVecAvailable = false;
|
|
254
|
-
try {
|
|
255
|
-
const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH;
|
|
256
|
-
if (extensionPath) {
|
|
257
|
-
database.loadExtension(extensionPath);
|
|
258
|
-
} else {
|
|
259
|
-
const sqliteVec = require("sqlite-vec");
|
|
260
|
-
sqliteVec.load(database);
|
|
261
|
-
}
|
|
262
|
-
sqliteVecAvailable = true;
|
|
263
|
-
console.log(`[db] sqlite-vec loaded${extensionPath ? ` from ${extensionPath}` : ""}`);
|
|
264
|
-
} catch (err) {
|
|
265
|
-
console.warn(
|
|
266
|
-
"[db] sqlite-vec not available, falling back to in-memory cosine:",
|
|
267
|
-
(err as Error).message,
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
|
|
273
|
-
if (db) {
|
|
274
|
-
return db;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Fast path for tests: restore from pre-built template that already has
|
|
278
|
-
// migrations, seeds, and all post-init work baked in. Only the per-connection
|
|
279
|
-
// PRAGMA and the in-memory resolver function need to be set.
|
|
280
|
-
const templateGlobals = globalThis as typeof globalThis & {
|
|
281
|
-
__testMigrationTemplate?: Uint8Array;
|
|
282
|
-
};
|
|
283
|
-
const templateBytes = templateGlobals.__testMigrationTemplate;
|
|
284
|
-
if (templateBytes) {
|
|
285
|
-
db = Database.deserialize(templateBytes);
|
|
286
|
-
db.run("PRAGMA busy_timeout = 5000;");
|
|
287
|
-
db.run("PRAGMA foreign_keys = ON;");
|
|
288
|
-
loadSqliteVec(db);
|
|
289
|
-
configureDbResolver(resolvePromptTemplate);
|
|
290
|
-
enforceAssetKeyStartupAudit(db);
|
|
291
|
-
// Ensure the encryption key is resolved even when restoring from the test
|
|
292
|
-
// template. The cache may have been cleared via __resetEncryptionKeyForTests
|
|
293
|
-
// between test suites; this call is a no-op if the cache is already warm.
|
|
294
|
-
resolveEncryptionKey(dbPath);
|
|
295
|
-
return db;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
db = new Database(dbPath, { create: true });
|
|
299
|
-
console.log(`Database initialized at ${dbPath}`);
|
|
300
|
-
|
|
301
|
-
const database = db;
|
|
302
|
-
database.run("PRAGMA journal_mode = WAL;");
|
|
303
|
-
database.run("PRAGMA busy_timeout = 5000;");
|
|
304
|
-
database.run("PRAGMA foreign_keys = ON;");
|
|
305
|
-
database.run("PRAGMA synchronous = NORMAL;");
|
|
306
|
-
database.run("PRAGMA cache_size = -64000;");
|
|
307
|
-
database.run("PRAGMA mmap_size = 268435456;");
|
|
308
|
-
database.run("PRAGMA temp_store = MEMORY;");
|
|
309
|
-
|
|
310
|
-
// Load sqlite-vec extension for vector search.
|
|
311
|
-
// In compiled binaries (`bun build --compile`) the JS lives in /$bunfs/ and
|
|
312
|
-
// `require.resolve("sqlite-vec-<platform>/vec0.so")` can't find the native
|
|
313
|
-
// asset — so we prefer an explicit filesystem path when set, and only fall
|
|
314
|
-
// back to the npm resolver for normal dev runs.
|
|
315
|
-
loadSqliteVec(database);
|
|
316
|
-
|
|
317
|
-
// Run database migrations (schema creation + incremental changes)
|
|
318
|
-
try {
|
|
319
|
-
runMigrations(database);
|
|
320
|
-
} catch (error) {
|
|
321
|
-
db = null;
|
|
322
|
-
try {
|
|
323
|
-
database.close();
|
|
324
|
-
} catch (closeError) {
|
|
325
|
-
console.error("[migrations] Failed to close database after migration failure:", closeError);
|
|
326
|
-
}
|
|
327
|
-
throw error;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Compatibility migration for legacy databases that predate profile fields
|
|
331
|
-
ensureAgentProfileColumns(database);
|
|
332
|
-
|
|
333
|
-
// Migration: Remove restrictive CHECK constraint on agent_tasks.status
|
|
334
|
-
// Old databases have CHECK(status IN ('pending','in_progress','completed','failed'))
|
|
335
|
-
// which blocks 'cancelled', 'paused', 'offered', 'unassigned' statuses
|
|
336
|
-
try {
|
|
337
|
-
const taskSchemaInfo = db
|
|
338
|
-
.prepare<{ sql: string | null }, []>(
|
|
339
|
-
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'agent_tasks'",
|
|
340
|
-
)
|
|
341
|
-
.get();
|
|
342
|
-
|
|
343
|
-
const schemaSql = taskSchemaInfo?.sql ?? "";
|
|
344
|
-
const hasStatusCheck = /status\s+TEXT\b[^,]*\bCHECK\s*\(\s*status\s+IN\s*\(/i.test(schemaSql);
|
|
345
|
-
const statusAllowsCancelled = /status\s+IN\s*\([^)]*'cancelled'/i.test(schemaSql);
|
|
346
|
-
const needsStatusMigration = hasStatusCheck && !statusAllowsCancelled;
|
|
347
|
-
|
|
348
|
-
if (needsStatusMigration) {
|
|
349
|
-
console.log("[Migration] Removing restrictive CHECK constraint on agent_tasks.status");
|
|
350
|
-
db.run("PRAGMA foreign_keys=off");
|
|
351
|
-
|
|
352
|
-
// Migrations run before this compatibility guard, so a legacy table now
|
|
353
|
-
// carries every current column. Rebuilding from an old hard-coded column
|
|
354
|
-
// list would silently discard later fields. Derive the replacement
|
|
355
|
-
// schema, copy list, indexes, and triggers from the live table instead;
|
|
356
|
-
// remove only the obsolete status CHECK.
|
|
357
|
-
const rebuiltSchemaSql = schemaSql
|
|
358
|
-
.replace(
|
|
359
|
-
/^(CREATE TABLE\s+(?:IF NOT EXISTS\s+)?)(?:"agent_tasks"|agent_tasks)/i,
|
|
360
|
-
"$1agent_tasks_new",
|
|
361
|
-
)
|
|
362
|
-
.replace(/\s+CHECK\s*\(\s*status\s+IN\s*\([^)]*\)\s*\)/i, "");
|
|
363
|
-
if (rebuiltSchemaSql === schemaSql || !/agent_tasks_new/i.test(rebuiltSchemaSql)) {
|
|
364
|
-
throw new Error("Could not derive agent_tasks schema without legacy status CHECK");
|
|
365
|
-
}
|
|
366
|
-
const columns = db
|
|
367
|
-
.prepare<{ name: string }, []>('PRAGMA table_info("agent_tasks")')
|
|
368
|
-
.all()
|
|
369
|
-
.map((column) => `"${column.name.replaceAll('"', '""')}"`)
|
|
370
|
-
.join(", ");
|
|
371
|
-
const schemaObjects = db
|
|
372
|
-
.prepare<{ sql: string }, []>(
|
|
373
|
-
`SELECT sql FROM sqlite_master
|
|
374
|
-
WHERE tbl_name = 'agent_tasks'
|
|
375
|
-
AND type IN ('index', 'trigger')
|
|
376
|
-
AND sql IS NOT NULL
|
|
377
|
-
ORDER BY type, name`,
|
|
378
|
-
)
|
|
379
|
-
.all()
|
|
380
|
-
.map((row) => row.sql);
|
|
381
|
-
|
|
382
|
-
database.transaction(() => {
|
|
383
|
-
database.run("DROP TABLE IF EXISTS agent_tasks_new");
|
|
384
|
-
database.run(rebuiltSchemaSql);
|
|
385
|
-
database.run(`INSERT INTO agent_tasks_new (${columns}) SELECT ${columns} FROM agent_tasks`);
|
|
386
|
-
database.run("DROP TABLE agent_tasks");
|
|
387
|
-
database.run("ALTER TABLE agent_tasks_new RENAME TO agent_tasks");
|
|
388
|
-
for (const sql of schemaObjects) database.run(sql);
|
|
389
|
-
})();
|
|
390
|
-
|
|
391
|
-
db.run("PRAGMA foreign_keys=on");
|
|
392
|
-
console.log("[Migration] Successfully removed CHECK constraint on agent_tasks.status");
|
|
393
|
-
}
|
|
394
|
-
} catch (e) {
|
|
395
|
-
console.error("[Migration] Failed to update agent_tasks CHECK constraint:", e);
|
|
396
|
-
try {
|
|
397
|
-
db.run("PRAGMA foreign_keys=on");
|
|
398
|
-
} catch (cleanupError) {
|
|
399
|
-
console.error("[Migration] Failed to re-enable SQLite foreign_keys pragma:", cleanupError);
|
|
400
|
-
}
|
|
401
|
-
throw e;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Mandatory namespace invariant: structural corruption is fatal before the
|
|
405
|
-
// API starts listening. Unknown personal users/provider drift remain
|
|
406
|
-
// readable warnings so operators can repair them through the audit surface.
|
|
407
|
-
enforceAssetKeyStartupAudit(database);
|
|
408
|
-
|
|
409
|
-
// Backfill: Seed v1 for existing agents that don't have any context versions yet
|
|
410
|
-
seedContextVersions();
|
|
411
|
-
|
|
412
|
-
// Inject DB resolver into the prompt template resolver (DI to avoid worker/API boundary violation)
|
|
413
|
-
configureDbResolver(resolvePromptTemplate);
|
|
414
|
-
|
|
415
|
-
// Seed default prompt templates from the in-memory code registry
|
|
416
|
-
seedDefaultTemplates();
|
|
417
|
-
|
|
418
|
-
const hasExistingEncryptedSecrets =
|
|
419
|
-
(database
|
|
420
|
-
.prepare<{ present: number }, []>(
|
|
421
|
-
`SELECT EXISTS(
|
|
422
|
-
SELECT 1 FROM swarm_config WHERE isSecret = 1 AND encrypted = 1
|
|
423
|
-
UNION ALL
|
|
424
|
-
SELECT 1 FROM oauth_apps
|
|
425
|
-
WHERE clientSecretEncrypted = 1 AND clientSecret IS NOT NULL
|
|
426
|
-
UNION ALL
|
|
427
|
-
SELECT 1 FROM oauth_authorizations WHERE tokensEncrypted = 1
|
|
428
|
-
) AS present`,
|
|
429
|
-
)
|
|
430
|
-
.get()?.present ?? 0) === 1;
|
|
431
|
-
|
|
432
|
-
// Track whether user provided the key (for backup decision)
|
|
433
|
-
const userProvidedKey = !!(
|
|
434
|
-
process.env.SECRETS_ENCRYPTION_KEY?.length || process.env.SECRETS_ENCRYPTION_KEY_FILE?.length
|
|
435
|
-
);
|
|
436
|
-
|
|
437
|
-
// Resolve the secrets encryption key after migrations so we can tell whether
|
|
438
|
-
// this DB already contains encrypted secret rows (must reuse an explicit or
|
|
439
|
-
// on-disk key) or is still plaintext-only (safe to generate a new key before
|
|
440
|
-
// auto-migrating legacy plaintext rows).
|
|
441
|
-
resolveEncryptionKey(dbPath, { allowGenerate: !hasExistingEncryptedSecrets });
|
|
442
|
-
|
|
443
|
-
// Migration 117 carries plaintext tracker OAuth rows with explicit flags;
|
|
444
|
-
// encrypt them only after the shared key has been resolved. This pass is
|
|
445
|
-
// idempotent and intentionally fatal on failure so boot never continues
|
|
446
|
-
// with OAuth credentials left in plaintext.
|
|
447
|
-
try {
|
|
448
|
-
autoEncryptLegacyOAuthSecrets(database);
|
|
449
|
-
} catch (err) {
|
|
450
|
-
console.error(
|
|
451
|
-
`[oauth-encryption] FATAL: failed to auto-encrypt legacy OAuth secrets: ${(err as Error).message}`,
|
|
452
|
-
);
|
|
453
|
-
throw err;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Auto-encrypt any legacy plaintext secrets that predate the encryption
|
|
457
|
-
// feature. Runs after all compatibility guards; failures are fatal because
|
|
458
|
-
// continuing would leave secrets at rest in plaintext — the opposite of the
|
|
459
|
-
// guarantee this feature provides.
|
|
460
|
-
try {
|
|
461
|
-
autoEncryptLegacyPlaintextSecrets(database, dbPath, { createBackup: !userProvidedKey });
|
|
462
|
-
} catch (err) {
|
|
463
|
-
console.error(
|
|
464
|
-
`[secrets] FATAL: failed to auto-encrypt legacy secrets: ${(err as Error).message}`,
|
|
465
|
-
);
|
|
466
|
-
throw err;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Retire the legacy SCRIPT_CREDENTIAL_BINDINGS swarm-config blob: promote any
|
|
470
|
-
// remaining entries to relational rows so the credential broker is
|
|
471
|
-
// relational-only. Idempotent; failures are fatal because a silently-dropped
|
|
472
|
-
// binding would leave scripts unable to authenticate.
|
|
473
|
-
try {
|
|
474
|
-
migrateLegacyCredentialBindingBlob(database);
|
|
475
|
-
} catch (err) {
|
|
476
|
-
console.error(
|
|
477
|
-
`[credential-bindings] FATAL: failed to migrate legacy credential binding blob: ${(err as Error).message}`,
|
|
478
|
-
);
|
|
479
|
-
throw err;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
return db;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
export function getDb(path?: string): Database {
|
|
486
|
-
if (!db) {
|
|
487
|
-
return initDb(path ?? process.env.DATABASE_PATH);
|
|
488
|
-
}
|
|
489
|
-
return db;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
export function closeDb(): void {
|
|
493
|
-
if (db) {
|
|
494
|
-
db.close();
|
|
495
|
-
db = null;
|
|
496
|
-
}
|
|
497
|
-
sqliteVecAvailable = false;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Async seam over the shared connection. The client resolves the underlying
|
|
501
|
-
// handle per operation via getDb(), so close/reopen cycles need no reset.
|
|
502
|
-
let dbClientInstance: DbClient | null = null;
|
|
503
|
-
|
|
504
|
-
export function getDbClient(): DbClient {
|
|
505
|
-
if (!dbClientInstance) {
|
|
506
|
-
dbClientInstance = createBunSqliteClient(() => getDb());
|
|
507
|
-
}
|
|
508
|
-
return dbClientInstance;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
349
|
// ============================================================================
|
|
512
350
|
// Context Versioning
|
|
513
351
|
// ============================================================================
|
|
@@ -528,1408 +366,241 @@ const BUDGETED_IDENTITY_FIELDS: BudgetedIdentityField[] = [
|
|
|
528
366
|
"toolsMd",
|
|
529
367
|
];
|
|
530
368
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
// "missing" on a table that was never created and throw `no such table:
|
|
535
|
-
// agents` from the ALTER below. This is a legacy-compat shim for
|
|
536
|
-
// pre-migration-system databases; runMigrations() (and its own
|
|
537
|
-
// assertNotEmptyDatabase guard) is what's responsible for the `agents`
|
|
538
|
-
// table existing at all, and already fails loudly if it doesn't. This
|
|
539
|
-
// function must never be the thing that crashes startup instead.
|
|
540
|
-
const agentsTableExists = database
|
|
541
|
-
.prepare<{ name: string }, []>(
|
|
542
|
-
"SELECT name FROM sqlite_master WHERE type='table' AND name='agents'",
|
|
543
|
-
)
|
|
544
|
-
.get();
|
|
545
|
-
if (!agentsTableExists) {
|
|
546
|
-
console.warn("[Migration] agents table does not exist yet — skipping profile column backfill");
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
369
|
+
// ============================================================================
|
|
370
|
+
// AgentTask Queries
|
|
371
|
+
// ============================================================================
|
|
549
372
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
.all()
|
|
554
|
-
.map((row) => row.name),
|
|
373
|
+
export async function getSlackRenderV2ActivatedAt(): Promise<string | null> {
|
|
374
|
+
const row = await getDbClient().get<{ activated_at: string }>(
|
|
375
|
+
`SELECT activated_at FROM slack_render_v2_state WHERE id = 1`,
|
|
555
376
|
);
|
|
556
|
-
|
|
557
|
-
for (const column of VERSIONABLE_FIELDS) {
|
|
558
|
-
if (!existingColumns.has(column)) {
|
|
559
|
-
try {
|
|
560
|
-
database.run(`ALTER TABLE agents ADD COLUMN ${column} TEXT`);
|
|
561
|
-
} catch (error) {
|
|
562
|
-
console.error(`[Migration] Failed to add missing agents.${column} column`, error);
|
|
563
|
-
throw error;
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
export function computeContentHash(content: string): string {
|
|
570
|
-
const hasher = new Bun.CryptoHasher("sha256");
|
|
571
|
-
hasher.update(content);
|
|
572
|
-
return hasher.digest("hex");
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
type ContextVersionRow = {
|
|
576
|
-
id: string;
|
|
577
|
-
agentId: string;
|
|
578
|
-
field: string;
|
|
579
|
-
content: string;
|
|
580
|
-
version: number;
|
|
581
|
-
changeSource: string;
|
|
582
|
-
changedByAgentId: string | null;
|
|
583
|
-
changeReason: string | null;
|
|
584
|
-
contentHash: string;
|
|
585
|
-
previousVersionId: string | null;
|
|
586
|
-
createdAt: string;
|
|
587
|
-
};
|
|
588
|
-
|
|
589
|
-
function rowToContextVersion(row: ContextVersionRow): ContextVersion {
|
|
590
|
-
return {
|
|
591
|
-
id: row.id,
|
|
592
|
-
agentId: row.agentId,
|
|
593
|
-
field: row.field as VersionableField,
|
|
594
|
-
content: row.content,
|
|
595
|
-
version: row.version,
|
|
596
|
-
changeSource: row.changeSource as ChangeSource,
|
|
597
|
-
changedByAgentId: row.changedByAgentId,
|
|
598
|
-
changeReason: row.changeReason,
|
|
599
|
-
contentHash: row.contentHash,
|
|
600
|
-
previousVersionId: row.previousVersionId,
|
|
601
|
-
createdAt: row.createdAt,
|
|
602
|
-
};
|
|
377
|
+
return row?.activated_at ?? null;
|
|
603
378
|
}
|
|
604
379
|
|
|
605
|
-
export async function
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
changeReason?: string | null;
|
|
613
|
-
contentHash: string;
|
|
614
|
-
previousVersionId?: string | null;
|
|
615
|
-
}): Promise<ContextVersion> {
|
|
616
|
-
const id = crypto.randomUUID();
|
|
617
|
-
const now = new Date().toISOString();
|
|
618
|
-
|
|
619
|
-
const row = await getDbClient().get<ContextVersionRow>(
|
|
620
|
-
`INSERT INTO context_versions (id, agentId, field, content, version, changeSource, changedByAgentId, changeReason, contentHash, previousVersionId, createdAt)
|
|
621
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
|
622
|
-
[
|
|
623
|
-
id,
|
|
624
|
-
params.agentId,
|
|
625
|
-
params.field,
|
|
626
|
-
params.content,
|
|
627
|
-
params.version,
|
|
628
|
-
params.changeSource,
|
|
629
|
-
params.changedByAgentId ?? null,
|
|
630
|
-
params.changeReason ?? null,
|
|
631
|
-
params.contentHash,
|
|
632
|
-
params.previousVersionId ?? null,
|
|
633
|
-
now,
|
|
634
|
-
],
|
|
380
|
+
export async function ensureSlackRenderV2Activation(): Promise<string> {
|
|
381
|
+
const activatedAt = new Date().toISOString();
|
|
382
|
+
await getDbClient().run(
|
|
383
|
+
`INSERT INTO slack_render_v2_state (id, activated_at)
|
|
384
|
+
VALUES (1, ?)
|
|
385
|
+
ON CONFLICT(id) DO NOTHING`,
|
|
386
|
+
[activatedAt],
|
|
635
387
|
);
|
|
636
|
-
|
|
637
|
-
if (!
|
|
638
|
-
return
|
|
388
|
+
const persisted = await getSlackRenderV2ActivatedAt();
|
|
389
|
+
if (!persisted) throw new Error("Failed to persist Slack render v2 activation");
|
|
390
|
+
return persisted;
|
|
639
391
|
}
|
|
640
392
|
|
|
641
|
-
export async function
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
): Promise<ContextVersion | null> {
|
|
645
|
-
const row = await getDbClient().get<ContextVersionRow>(
|
|
646
|
-
`SELECT * FROM context_versions WHERE agentId = ? AND field = ? ORDER BY version DESC LIMIT 1`,
|
|
647
|
-
[agentId, field],
|
|
393
|
+
export async function getSlackDelegationActivatedAt(): Promise<string | null> {
|
|
394
|
+
const row = await getDbClient().get<{ delegation_activated_at: string | null }>(
|
|
395
|
+
`SELECT delegation_activated_at FROM slack_render_v2_state WHERE id = 1`,
|
|
648
396
|
);
|
|
649
|
-
|
|
650
|
-
return row ? rowToContextVersion(row) : null;
|
|
397
|
+
return row?.delegation_activated_at ?? null;
|
|
651
398
|
}
|
|
652
399
|
|
|
653
|
-
export async function
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
400
|
+
export async function ensureSlackDelegationActivation(): Promise<string> {
|
|
401
|
+
const activatedAt = new Date().toISOString();
|
|
402
|
+
await getDbClient().run(
|
|
403
|
+
`INSERT INTO slack_render_v2_state (id, activated_at, delegation_activated_at)
|
|
404
|
+
VALUES (1, ?, ?)
|
|
405
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
406
|
+
delegation_activated_at = COALESCE(slack_render_v2_state.delegation_activated_at, excluded.delegation_activated_at)`,
|
|
407
|
+
[activatedAt, activatedAt],
|
|
657
408
|
);
|
|
658
|
-
|
|
659
|
-
|
|
409
|
+
const persisted = await getSlackDelegationActivatedAt();
|
|
410
|
+
if (!persisted) throw new Error("Failed to persist Slack render v2 delegation activation");
|
|
411
|
+
return persisted;
|
|
660
412
|
}
|
|
661
413
|
|
|
662
|
-
export
|
|
663
|
-
|
|
664
|
-
field?: VersionableField;
|
|
665
|
-
limit?: number;
|
|
666
|
-
}): Promise<ContextVersion[]> {
|
|
667
|
-
const limit = params.limit ?? 10;
|
|
668
|
-
|
|
669
|
-
if (params.field) {
|
|
670
|
-
const rows = await getDbClient().query<ContextVersionRow>(
|
|
671
|
-
`SELECT * FROM context_versions WHERE agentId = ? AND field = ? ORDER BY version DESC LIMIT ?`,
|
|
672
|
-
[params.agentId, params.field, limit],
|
|
673
|
-
);
|
|
674
|
-
return rows.map(rowToContextVersion);
|
|
675
|
-
}
|
|
414
|
+
export type SlackMessageKind = "tree" | "outcome" | "agent";
|
|
415
|
+
export type SlackConclusionKind = "complete" | "timeout";
|
|
676
416
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
417
|
+
export interface SlackMessageRecord {
|
|
418
|
+
id: string;
|
|
419
|
+
contextKey: string;
|
|
420
|
+
channelId: string;
|
|
421
|
+
threadTs: string;
|
|
422
|
+
ts: string;
|
|
423
|
+
kind: SlackMessageKind;
|
|
424
|
+
taskId?: string;
|
|
425
|
+
permalink?: string;
|
|
426
|
+
finalizedAt?: string;
|
|
427
|
+
streamChunksAppended: number;
|
|
428
|
+
conclusionKind?: SlackConclusionKind;
|
|
429
|
+
createdAt: string;
|
|
430
|
+
updatedAt: string;
|
|
682
431
|
}
|
|
683
432
|
|
|
684
|
-
|
|
685
|
-
* Seed v1 context versions for existing agents that don't have any versions yet.
|
|
686
|
-
* Called during migration.
|
|
687
|
-
*/
|
|
688
|
-
function seedContextVersions(): void {
|
|
689
|
-
const database = getDb();
|
|
690
|
-
const agents = database
|
|
691
|
-
.prepare<
|
|
692
|
-
{
|
|
693
|
-
id: string;
|
|
694
|
-
soulMd: string | null;
|
|
695
|
-
identityMd: string | null;
|
|
696
|
-
toolsMd: string | null;
|
|
697
|
-
claudeMd: string | null;
|
|
698
|
-
setupScript: string | null;
|
|
699
|
-
heartbeatMd: string | null;
|
|
700
|
-
},
|
|
701
|
-
[]
|
|
702
|
-
>(`SELECT id, soulMd, identityMd, toolsMd, claudeMd, setupScript, heartbeatMd FROM agents`)
|
|
703
|
-
.all();
|
|
704
|
-
|
|
705
|
-
for (const agent of agents) {
|
|
706
|
-
for (const field of VERSIONABLE_FIELDS) {
|
|
707
|
-
const content = agent[field];
|
|
708
|
-
if (!content) continue;
|
|
709
|
-
|
|
710
|
-
// Check if a version already exists for this agent+field
|
|
711
|
-
const existing = database
|
|
712
|
-
.prepare<{ id: string }, [string, string]>(
|
|
713
|
-
`SELECT id FROM context_versions WHERE agentId = ? AND field = ? LIMIT 1`,
|
|
714
|
-
)
|
|
715
|
-
.get(agent.id, field);
|
|
716
|
-
if (existing) continue;
|
|
717
|
-
|
|
718
|
-
const id = crypto.randomUUID();
|
|
719
|
-
const hash = computeContentHash(content);
|
|
720
|
-
const now = new Date().toISOString();
|
|
433
|
+
const PENDING_SLACK_MESSAGE_TS_PREFIX = "pending:";
|
|
721
434
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
`INSERT INTO context_versions (id, agentId, field, content, version, changeSource, contentHash, createdAt)
|
|
725
|
-
VALUES (?, ?, ?, ?, 1, 'system', ?, ?)`,
|
|
726
|
-
)
|
|
727
|
-
.run(id, agent.id, field, content, hash, now);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
435
|
+
export function isPendingSlackMessage(record: SlackMessageRecord): boolean {
|
|
436
|
+
return record.ts.startsWith(PENDING_SLACK_MESSAGE_TS_PREFIX);
|
|
730
437
|
}
|
|
731
438
|
|
|
732
|
-
|
|
733
|
-
// Agent Queries
|
|
734
|
-
// ============================================================================
|
|
735
|
-
|
|
736
|
-
type AgentRow = {
|
|
439
|
+
type SlackMessageRow = {
|
|
737
440
|
id: string;
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
toolsMd: string | null;
|
|
751
|
-
heartbeatMd: string | null;
|
|
752
|
-
lastActivityAt: string | null;
|
|
753
|
-
provider: string | null;
|
|
754
|
-
createdAt: string;
|
|
755
|
-
lastUpdatedAt: string;
|
|
756
|
-
/** JSON array of env-var names; populated only when status is `waiting_for_credentials`. */
|
|
757
|
-
credentialMissing: string | null;
|
|
758
|
-
/** Phase 1.5: per-agent harness provider pushed on worker registration. */
|
|
759
|
-
harness_provider: string | null;
|
|
760
|
-
/** Migration 055: worker-self-reported credential snapshot (JSON of AgentCredStatus). NULL = unreported. */
|
|
761
|
-
cred_status: string | null;
|
|
762
|
-
/** Migration 119: custom avatar (JSON of AgentAvatar). NULL = deterministic hash-derived fallback. */
|
|
763
|
-
avatar: string | null;
|
|
441
|
+
context_key: string;
|
|
442
|
+
channel_id: string;
|
|
443
|
+
thread_ts: string;
|
|
444
|
+
ts: string;
|
|
445
|
+
kind: SlackMessageKind;
|
|
446
|
+
task_id: string | null;
|
|
447
|
+
permalink: string | null;
|
|
448
|
+
finalized_at: string | null;
|
|
449
|
+
stream_chunks_appended: number;
|
|
450
|
+
conclusion_kind: SlackConclusionKind | null;
|
|
451
|
+
created_at: string;
|
|
452
|
+
updated_at: string;
|
|
764
453
|
};
|
|
765
454
|
|
|
766
|
-
|
|
767
|
-
* hand-edited row, or a future downgrade) falls back to `null` so rendering
|
|
768
|
-
* always has a deterministic path — never throws. */
|
|
769
|
-
function parseAgentAvatar(raw: string | null): AgentAvatar | null {
|
|
770
|
-
if (!raw) return null;
|
|
771
|
-
try {
|
|
772
|
-
const parsed = AgentAvatarSchema.safeParse(JSON.parse(raw));
|
|
773
|
-
return parsed.success ? parsed.data : null;
|
|
774
|
-
} catch {
|
|
775
|
-
return null;
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
/**
|
|
780
|
-
* Map an agent row to the `Agent` shape. When `slim` is true the six identity
|
|
781
|
-
* markdown blobs (`claudeMd`/`soulMd`/`identityMd`/`toolsMd`/`heartbeatMd`/
|
|
782
|
-
* `setupScript`) are omitted — they bloat list responses by ~16 KB/agent and
|
|
783
|
-
* are never needed at the swarm-overview level. Fetch them via
|
|
784
|
-
* `GET /api/agents/{id}` when required.
|
|
785
|
-
*/
|
|
786
|
-
function rowToAgent(row: AgentRow, slim = false): Agent {
|
|
787
|
-
const base: Agent = {
|
|
788
|
-
id: row.id,
|
|
789
|
-
name: row.name,
|
|
790
|
-
isLead: row.isLead === 1,
|
|
791
|
-
status: row.status,
|
|
792
|
-
description: row.description ?? undefined,
|
|
793
|
-
role: row.role ?? undefined,
|
|
794
|
-
capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
|
|
795
|
-
maxTasks: row.maxTasks ?? 1,
|
|
796
|
-
emptyPollCount: row.emptyPollCount ?? 0,
|
|
797
|
-
lastActivityAt: row.lastActivityAt ?? undefined,
|
|
798
|
-
provider: (row.provider as ProviderName | null) ?? undefined,
|
|
799
|
-
harnessProvider: (row.harness_provider as ProviderName | null) ?? null,
|
|
800
|
-
createdAt: row.createdAt,
|
|
801
|
-
lastUpdatedAt: row.lastUpdatedAt,
|
|
802
|
-
credentialMissing: row.credentialMissing
|
|
803
|
-
? (JSON.parse(row.credentialMissing) as string[])
|
|
804
|
-
: null,
|
|
805
|
-
credStatus: row.cred_status ? (JSON.parse(row.cred_status) as AgentCredStatus) : null,
|
|
806
|
-
avatar: parseAgentAvatar(row.avatar),
|
|
807
|
-
};
|
|
808
|
-
if (slim) return base;
|
|
455
|
+
function rowToSlackMessage(row: SlackMessageRow): SlackMessageRecord {
|
|
809
456
|
return {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
457
|
+
id: row.id,
|
|
458
|
+
contextKey: row.context_key,
|
|
459
|
+
channelId: row.channel_id,
|
|
460
|
+
threadTs: row.thread_ts,
|
|
461
|
+
ts: row.ts,
|
|
462
|
+
kind: row.kind,
|
|
463
|
+
taskId: row.task_id ?? undefined,
|
|
464
|
+
permalink: row.permalink ?? undefined,
|
|
465
|
+
finalizedAt: row.finalized_at ?? undefined,
|
|
466
|
+
streamChunksAppended: row.stream_chunks_appended,
|
|
467
|
+
conclusionKind: row.conclusion_kind ?? undefined,
|
|
468
|
+
createdAt: row.created_at,
|
|
469
|
+
updatedAt: row.updated_at,
|
|
817
470
|
};
|
|
818
471
|
}
|
|
819
472
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
*/
|
|
855
|
-
export async function updateAgentCredentialMissing(
|
|
856
|
-
agentId: string,
|
|
857
|
-
missing: string[] | null,
|
|
858
|
-
): Promise<void> {
|
|
859
|
-
const json = missing && missing.length > 0 ? JSON.stringify(missing) : null;
|
|
860
|
-
await getDbClient().run(
|
|
861
|
-
"UPDATE agents SET credentialMissing = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
|
862
|
-
[json, agentId],
|
|
863
|
-
);
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
export async function createAgent(
|
|
867
|
-
agent: Omit<Agent, "id" | "createdAt" | "lastUpdatedAt"> & { id?: string },
|
|
868
|
-
): Promise<Agent> {
|
|
869
|
-
const id = agent.id ?? crypto.randomUUID();
|
|
870
|
-
const maxTasks = agent.maxTasks ?? 1;
|
|
871
|
-
const row = await getDbClient().get<AgentRow>(
|
|
872
|
-
"INSERT INTO agents (id, name, isLead, status, maxTasks, provider, harness_provider, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *",
|
|
473
|
+
export async function recordSlackMessage(input: {
|
|
474
|
+
contextKey: string;
|
|
475
|
+
channelId: string;
|
|
476
|
+
threadTs: string;
|
|
477
|
+
ts: string;
|
|
478
|
+
kind: SlackMessageKind;
|
|
479
|
+
taskId?: string;
|
|
480
|
+
permalink?: string;
|
|
481
|
+
finalized?: boolean;
|
|
482
|
+
streamChunksAppended?: number;
|
|
483
|
+
conclusionKind?: SlackConclusionKind;
|
|
484
|
+
actorId?: string;
|
|
485
|
+
}): Promise<SlackMessageRecord> {
|
|
486
|
+
const id = crypto.randomUUID();
|
|
487
|
+
const now = new Date().toISOString();
|
|
488
|
+
const row = await getDbClient().get<SlackMessageRow>(
|
|
489
|
+
`INSERT INTO slack_messages (
|
|
490
|
+
id, context_key, channel_id, thread_ts, ts, kind, task_id, permalink,
|
|
491
|
+
finalized_at, stream_chunks_appended, conclusion_kind, created_at, updated_at, created_by, updated_by
|
|
492
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
493
|
+
ON CONFLICT(channel_id, ts) DO UPDATE SET
|
|
494
|
+
context_key = excluded.context_key,
|
|
495
|
+
kind = excluded.kind,
|
|
496
|
+
task_id = COALESCE(excluded.task_id, slack_messages.task_id),
|
|
497
|
+
permalink = COALESCE(excluded.permalink, slack_messages.permalink),
|
|
498
|
+
finalized_at = COALESCE(excluded.finalized_at, slack_messages.finalized_at),
|
|
499
|
+
stream_chunks_appended = MAX(
|
|
500
|
+
excluded.stream_chunks_appended,
|
|
501
|
+
slack_messages.stream_chunks_appended
|
|
502
|
+
),
|
|
503
|
+
conclusion_kind = COALESCE(excluded.conclusion_kind, slack_messages.conclusion_kind),
|
|
504
|
+
updated_at = excluded.updated_at,
|
|
505
|
+
updated_by = excluded.updated_by
|
|
506
|
+
RETURNING *`,
|
|
873
507
|
[
|
|
874
508
|
id,
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
509
|
+
input.contextKey,
|
|
510
|
+
input.channelId,
|
|
511
|
+
input.threadTs,
|
|
512
|
+
input.ts,
|
|
513
|
+
input.kind,
|
|
514
|
+
input.taskId ?? null,
|
|
515
|
+
input.permalink ?? null,
|
|
516
|
+
input.finalized ? now : null,
|
|
517
|
+
input.streamChunksAppended ?? 0,
|
|
518
|
+
input.conclusionKind ?? null,
|
|
519
|
+
now,
|
|
520
|
+
now,
|
|
521
|
+
input.actorId ?? null,
|
|
522
|
+
input.actorId ?? null,
|
|
881
523
|
],
|
|
882
524
|
);
|
|
883
|
-
if (!row) throw new Error("Failed to
|
|
884
|
-
|
|
885
|
-
await installSystemDefaultSkillsForAgent(id);
|
|
886
|
-
} catch (err) {
|
|
887
|
-
console.warn(
|
|
888
|
-
"[db] Failed to install system-default skills for new agent:",
|
|
889
|
-
(err as Error).message,
|
|
890
|
-
);
|
|
891
|
-
}
|
|
892
|
-
try {
|
|
893
|
-
await createLogEntry({ eventType: "agent_joined", agentId: id, newValue: agent.status });
|
|
894
|
-
} catch {}
|
|
895
|
-
return rowToAgent(row);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
export async function getAgentById(id: string): Promise<Agent | null> {
|
|
899
|
-
const row = await getDbClient().get<AgentRow>("SELECT * FROM agents WHERE id = ?", [id]);
|
|
900
|
-
return row ? rowToAgent(row) : null;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
export async function getAllAgents(opts?: { slim?: boolean }): Promise<Agent[]> {
|
|
904
|
-
const rows = await getDbClient().query<AgentRow>("SELECT * FROM agents ORDER BY name");
|
|
905
|
-
return rows.map((row) => rowToAgent(row, opts?.slim ?? false));
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
export async function getLeadAgent(): Promise<Agent | null> {
|
|
909
|
-
const leads = (await getAllAgents()).filter((a) => a.isLead);
|
|
910
|
-
// Prefer a usable (non-offline) lead so callers route to one that can actually
|
|
911
|
-
// poll — e.g. an old offline lead must not shadow a live replacement. Falls
|
|
912
|
-
// back to any lead (incl. offline) so existing "is there a lead at all?"
|
|
913
|
-
// semantics are preserved; callers that require a live lead must check
|
|
914
|
-
// `status` themselves (see escalateUnreclaimedResumes).
|
|
915
|
-
return leads.find((a) => a.status !== "offline") ?? leads[0] ?? null;
|
|
525
|
+
if (!row) throw new Error("Failed to record Slack message");
|
|
526
|
+
return rowToSlackMessage(row);
|
|
916
527
|
}
|
|
917
528
|
|
|
918
|
-
export async function
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
agentId: id,
|
|
929
|
-
oldValue: oldAgent.status,
|
|
930
|
-
newValue: status,
|
|
931
|
-
});
|
|
932
|
-
} catch {}
|
|
529
|
+
export async function reserveSlackMessage(input: {
|
|
530
|
+
contextKey: string;
|
|
531
|
+
channelId: string;
|
|
532
|
+
threadTs: string;
|
|
533
|
+
kind: "tree" | "outcome";
|
|
534
|
+
taskId?: string;
|
|
535
|
+
actorId?: string;
|
|
536
|
+
}): Promise<{ record: SlackMessageRecord; created: boolean }> {
|
|
537
|
+
if (input.kind === "outcome" && !input.taskId) {
|
|
538
|
+
throw new Error("Outcome Slack message reservations require a task ID");
|
|
933
539
|
}
|
|
934
|
-
return row ? rowToAgent(row) : null;
|
|
935
|
-
}
|
|
936
540
|
|
|
937
|
-
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
541
|
+
const id = crypto.randomUUID();
|
|
542
|
+
const now = new Date().toISOString();
|
|
543
|
+
const pendingTs = `${PENDING_SLACK_MESSAGE_TS_PREFIX}${id}`;
|
|
544
|
+
const inserted = await getDbClient().get<SlackMessageRow>(
|
|
545
|
+
`INSERT INTO slack_messages (
|
|
546
|
+
id, context_key, channel_id, thread_ts, ts, kind, task_id,
|
|
547
|
+
stream_chunks_appended, created_at, updated_at, created_by, updated_by
|
|
548
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
|
549
|
+
ON CONFLICT DO NOTHING
|
|
550
|
+
RETURNING *`,
|
|
551
|
+
[
|
|
552
|
+
id,
|
|
553
|
+
input.contextKey,
|
|
554
|
+
input.channelId,
|
|
555
|
+
input.threadTs,
|
|
556
|
+
pendingTs,
|
|
557
|
+
input.kind,
|
|
558
|
+
input.taskId ?? null,
|
|
559
|
+
now,
|
|
560
|
+
now,
|
|
561
|
+
input.actorId ?? null,
|
|
562
|
+
input.actorId ?? null,
|
|
563
|
+
],
|
|
942
564
|
);
|
|
943
|
-
return
|
|
944
|
-
}
|
|
565
|
+
if (inserted) return { record: rowToSlackMessage(inserted), created: true };
|
|
945
566
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
WHERE id = ? RETURNING *`,
|
|
953
|
-
[provider, id],
|
|
954
|
-
);
|
|
955
|
-
return row ? rowToAgent(row) : null;
|
|
567
|
+
const existing =
|
|
568
|
+
input.kind === "tree"
|
|
569
|
+
? await getSlackTreeMessageByThread(input.channelId, input.threadTs)
|
|
570
|
+
: await getSlackOutcomeMessage(input.taskId!);
|
|
571
|
+
if (!existing) throw new Error("Failed to reserve Slack message");
|
|
572
|
+
return { record: existing, created: false };
|
|
956
573
|
}
|
|
957
574
|
|
|
958
|
-
|
|
959
|
-
* Phase 1.5 (cloud-personalization): set the per-agent `harness_provider`
|
|
960
|
-
* column. Pass `null` to clear. Validation against the canonical provider
|
|
961
|
-
* list happens at the API layer via `ProviderNameSchema`.
|
|
962
|
-
*
|
|
963
|
-
* Returns the updated row, or null if the agent does not exist.
|
|
964
|
-
*/
|
|
965
|
-
export async function setAgentHarnessProvider(
|
|
575
|
+
export async function bindSlackMessageTimestamp(
|
|
966
576
|
id: string,
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
577
|
+
ts: string,
|
|
578
|
+
options: { streamChunksAppended?: number; renderedThrough?: string } = {},
|
|
579
|
+
): Promise<SlackMessageRecord | null> {
|
|
580
|
+
const now = new Date().toISOString();
|
|
581
|
+
const row = await getDbClient().get<SlackMessageRow>(
|
|
582
|
+
`UPDATE slack_messages SET
|
|
583
|
+
ts = ?,
|
|
584
|
+
stream_chunks_appended = MAX(stream_chunks_appended, ?),
|
|
585
|
+
updated_at = COALESCE(?, ?)
|
|
586
|
+
WHERE id = ? AND ts LIKE 'pending:%'
|
|
587
|
+
RETURNING *`,
|
|
588
|
+
[ts, options.streamChunksAppended ?? 0, options.renderedThrough ?? null, now, id],
|
|
973
589
|
);
|
|
974
|
-
return row ?
|
|
590
|
+
return row ? rowToSlackMessage(row) : null;
|
|
975
591
|
}
|
|
976
592
|
|
|
977
|
-
|
|
978
|
-
* Migration 055 — write the worker-self-reported credential snapshot.
|
|
979
|
-
* Pass `null` to clear (e.g. on agent re-registration). Validation against
|
|
980
|
-
* the JSON shape happens at the API layer via `AgentCredStatusSchema`.
|
|
981
|
-
*
|
|
982
|
-
* Worker reports this alongside the existing `updateAgentCredentialState`
|
|
983
|
-
* call; we keep the writes in two functions so the dispatch pattern stays
|
|
984
|
-
* one-row-one-fact, and the PATCH handler can choose which to call based
|
|
985
|
-
* on which fields the request body carried.
|
|
986
|
-
*/
|
|
987
|
-
export async function updateAgentCredStatus(
|
|
593
|
+
export async function updateSlackMessageRecord(
|
|
988
594
|
id: string,
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
);
|
|
997
|
-
return row ? rowToAgent(row) : null;
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
/**
|
|
1001
|
-
* Migration 055 — read all agents whose `harness_provider` matches a given
|
|
1002
|
-
* provider, with their reported `cred_status`. Used by the credential-status
|
|
1003
|
-
* API endpoint to roll up "is this provider working across the fleet?".
|
|
1004
|
-
*
|
|
1005
|
-
* Agents with NULL `cred_status` (never reported, or CRED_CHECK_DISABLE=1)
|
|
1006
|
-
* are still returned — the caller surfaces them as "unreported".
|
|
1007
|
-
*/
|
|
1008
|
-
export async function listAgentsWithCredStatusByProvider(provider: string): Promise<Agent[]> {
|
|
1009
|
-
const rows = await getDbClient().query<AgentRow>(
|
|
1010
|
-
`SELECT * FROM agents WHERE harness_provider = ? ORDER BY name`,
|
|
1011
|
-
[provider],
|
|
1012
|
-
);
|
|
1013
|
-
return rows.map((row) => rowToAgent(row));
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
/**
|
|
1017
|
-
* Phase 1.5 (cloud-personalization): aggregate count of registered agents
|
|
1018
|
-
* by `harness_provider`. NULL rows (agents that registered before the
|
|
1019
|
-
* migration or never pushed a value) are excluded — they show up in the
|
|
1020
|
-
* total agent count but not here.
|
|
1021
|
-
*
|
|
1022
|
-
* Used by future fleet displays. Not consumed in this phase.
|
|
1023
|
-
*/
|
|
1024
|
-
export async function getAgentHarnessProviders(): Promise<
|
|
1025
|
-
Array<{ provider: string; count: number }>
|
|
1026
|
-
> {
|
|
1027
|
-
const rows = await getDbClient().query<{ provider: string; count: number }>(
|
|
1028
|
-
`SELECT harness_provider AS provider, COUNT(*) AS count
|
|
1029
|
-
FROM agents
|
|
1030
|
-
WHERE harness_provider IS NOT NULL
|
|
1031
|
-
GROUP BY harness_provider
|
|
1032
|
-
ORDER BY harness_provider`,
|
|
1033
|
-
);
|
|
1034
|
-
return rows.map((r) => ({ provider: r.provider, count: r.count }));
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
export async function updateAgentActivity(id: string): Promise<void> {
|
|
1038
|
-
await getDbClient().run(
|
|
1039
|
-
`UPDATE agents SET lastActivityAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
|
1040
|
-
[id],
|
|
1041
|
-
);
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
// ============================================================================
|
|
1045
|
-
// Agent Poll Tracking Functions
|
|
1046
|
-
// ============================================================================
|
|
1047
|
-
|
|
1048
|
-
/** Maximum consecutive empty polls before agent should stop polling */
|
|
1049
|
-
export const MAX_EMPTY_POLLS = 2;
|
|
1050
|
-
|
|
1051
|
-
/**
|
|
1052
|
-
* Increment the empty poll count for an agent.
|
|
1053
|
-
* Returns the new count after incrementing.
|
|
1054
|
-
*/
|
|
1055
|
-
export async function incrementEmptyPollCount(agentId: string): Promise<number> {
|
|
1056
|
-
const row = await getDbClient().get<{ emptyPollCount: number }>(
|
|
1057
|
-
`UPDATE agents
|
|
1058
|
-
SET emptyPollCount = emptyPollCount + 1,
|
|
1059
|
-
lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
1060
|
-
WHERE id = ?
|
|
1061
|
-
RETURNING emptyPollCount`,
|
|
1062
|
-
[agentId],
|
|
1063
|
-
);
|
|
1064
|
-
return row?.emptyPollCount ?? 0;
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
/**
|
|
1068
|
-
* Reset the empty poll count for an agent to zero.
|
|
1069
|
-
* Called when a task is assigned or agent re-registers.
|
|
1070
|
-
*/
|
|
1071
|
-
export async function resetEmptyPollCount(agentId: string): Promise<void> {
|
|
1072
|
-
await getDbClient().run(
|
|
1073
|
-
`UPDATE agents
|
|
1074
|
-
SET emptyPollCount = 0,
|
|
1075
|
-
lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
1076
|
-
WHERE id = ?`,
|
|
1077
|
-
[agentId],
|
|
1078
|
-
);
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
/**
|
|
1082
|
-
* Check if an agent has exceeded the maximum empty poll count.
|
|
1083
|
-
*/
|
|
1084
|
-
export async function shouldBlockPolling(agentId: string): Promise<boolean> {
|
|
1085
|
-
const agent = await getAgentById(agentId);
|
|
1086
|
-
return (agent?.emptyPollCount ?? 0) >= MAX_EMPTY_POLLS;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
export async function deleteAgent(id: string): Promise<boolean> {
|
|
1090
|
-
const agent = await getAgentById(id);
|
|
1091
|
-
if (agent) {
|
|
1092
|
-
try {
|
|
1093
|
-
await createLogEntry({ eventType: "agent_left", agentId: id, oldValue: agent.status });
|
|
1094
|
-
} catch {}
|
|
1095
|
-
}
|
|
1096
|
-
const result = await getDbClient().run("DELETE FROM agents WHERE id = ?", [id]);
|
|
1097
|
-
return result.changes > 0;
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
// ============================================================================
|
|
1101
|
-
// Agent Capacity Functions
|
|
1102
|
-
// ============================================================================
|
|
1103
|
-
|
|
1104
|
-
/**
|
|
1105
|
-
* Get the count of active (in_progress) tasks for an agent.
|
|
1106
|
-
* Used to determine current capacity usage.
|
|
1107
|
-
*/
|
|
1108
|
-
/**
|
|
1109
|
-
* Tasks occupying one of the agent's concurrency slots.
|
|
1110
|
-
*
|
|
1111
|
-
* A claimed offer counts too — omitting it let a second concurrent poll take
|
|
1112
|
-
* another task past the limit. It is counted only through `offeredTo`, the
|
|
1113
|
-
* agent actually reviewing it: `agentId` on an offer may still be the lead
|
|
1114
|
-
* that created it, which would otherwise consume the lead's own capacity.
|
|
1115
|
-
*/
|
|
1116
|
-
export async function getActiveTaskCount(agentId: string): Promise<number> {
|
|
1117
|
-
const result = await getDbClient().get<{ count: number }>(
|
|
1118
|
-
`SELECT COUNT(*) as count FROM agent_tasks
|
|
1119
|
-
WHERE (agentId = ? AND status = 'in_progress')
|
|
1120
|
-
OR (offeredTo = ? AND status = 'reviewing')`,
|
|
1121
|
-
[agentId, agentId],
|
|
1122
|
-
);
|
|
1123
|
-
return result?.count ?? 0;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
/**
|
|
1127
|
-
* Check if an agent has capacity to accept more tasks.
|
|
1128
|
-
*/
|
|
1129
|
-
export async function hasCapacity(agentId: string): Promise<boolean> {
|
|
1130
|
-
const agent = await getAgentById(agentId);
|
|
1131
|
-
if (!agent) return false;
|
|
1132
|
-
const activeCount = await getActiveTaskCount(agentId);
|
|
1133
|
-
return activeCount < (agent.maxTasks ?? 1);
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
/**
|
|
1137
|
-
* Get remaining capacity (available task slots) for an agent.
|
|
1138
|
-
*/
|
|
1139
|
-
export async function getRemainingCapacity(agentId: string): Promise<number> {
|
|
1140
|
-
const agent = await getAgentById(agentId);
|
|
1141
|
-
if (!agent) return 0;
|
|
1142
|
-
const activeCount = await getActiveTaskCount(agentId);
|
|
1143
|
-
return Math.max(0, (agent.maxTasks ?? 1) - activeCount);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
/**
|
|
1147
|
-
* Update agent status based on current capacity.
|
|
1148
|
-
* Agent is 'busy' when any tasks are in progress, 'idle' when none.
|
|
1149
|
-
* Does not modify 'offline' status.
|
|
1150
|
-
*/
|
|
1151
|
-
export async function updateAgentStatusFromCapacity(agentId: string): Promise<void> {
|
|
1152
|
-
const agent = await getAgentById(agentId);
|
|
1153
|
-
if (!agent || agent.status === "offline") return;
|
|
1154
|
-
// `waiting_for_credentials` is owned by the worker's credential-wait
|
|
1155
|
-
// tick — task-completion shouldn't accidentally promote a blocked agent
|
|
1156
|
-
// back to idle.
|
|
1157
|
-
if (agent.status === "waiting_for_credentials") return;
|
|
1158
|
-
|
|
1159
|
-
const activeCount = await getActiveTaskCount(agentId);
|
|
1160
|
-
const newStatus = activeCount > 0 ? "busy" : "idle";
|
|
1161
|
-
|
|
1162
|
-
if (agent.status !== newStatus) {
|
|
1163
|
-
await updateAgentStatus(agentId, newStatus);
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
// ============================================================================
|
|
1168
|
-
// Routing Affinity (interrupted/pooled task role & capability gating)
|
|
1169
|
-
// ============================================================================
|
|
1170
|
-
|
|
1171
|
-
/**
|
|
1172
|
-
* Kill-switch for the pool eligibility gate (`isAgentEligibleForTask` and its
|
|
1173
|
-
* callers: `claimTask`, `assignUnassignedTaskPending`,
|
|
1174
|
-
* `getUnassignedTaskIdsForAgent`). ON by default. Set to `0` to restore
|
|
1175
|
-
* pre-affinity behavior verbatim — mirrors the `HEARTBEAT_PIN_*_RESUME`
|
|
1176
|
-
* rollback convention. A function (read dynamically), not a module-load-time
|
|
1177
|
-
* const, so it can be toggled mid-test (see `isGracefulResumePinEnabled` in
|
|
1178
|
-
* src/tasks/worker-follow-up.ts for the same pattern).
|
|
1179
|
-
*/
|
|
1180
|
-
export function isPoolAffinityEnforcementEnabled(): boolean {
|
|
1181
|
-
return isEnvFlagEnabled("POOL_AFFINITY_ENFORCEMENT", true);
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
/**
|
|
1185
|
-
* Snapshot an agent's role/harness/capabilities into a `RoutingAffinity`
|
|
1186
|
-
* blob, for stamping onto a continuation task (resume, retry) at the moment
|
|
1187
|
-
* of interruption. Returns `null` when the agent row is already gone —
|
|
1188
|
-
* callers fall back to the parent's own (inherited) `routingAffinity` via
|
|
1189
|
-
* `createTaskExtended`'s parentTaskId inheritance block.
|
|
1190
|
-
*/
|
|
1191
|
-
export async function buildRoutingAffinityFromAgent(
|
|
1192
|
-
agentId: string,
|
|
1193
|
-
): Promise<RoutingAffinity | null> {
|
|
1194
|
-
const agent = await getAgentById(agentId);
|
|
1195
|
-
if (!agent) return null;
|
|
1196
|
-
return {
|
|
1197
|
-
sourceAgentId: agent.id,
|
|
1198
|
-
role: agent.role,
|
|
1199
|
-
harnessProvider: agent.harnessProvider ?? agent.provider ?? undefined,
|
|
1200
|
-
capabilities: agent.capabilities ?? [],
|
|
1201
|
-
};
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
/**
|
|
1205
|
-
* The single eligibility gate every pool consumer (poll auto-claim,
|
|
1206
|
-
* `task-action claim`, `autoAssignPoolTasks`) MUST use before handing a task
|
|
1207
|
-
* to an agent. Exact-match on the snapshotted role string (no keyword
|
|
1208
|
-
* taxonomy in v1); `harnessProvider` is informational only and never
|
|
1209
|
-
* enforced (native session resume is deprecated). Missing role data on
|
|
1210
|
-
* either side is treated as INELIGIBLE — never fail-open to "anyone" — so a
|
|
1211
|
-
* capability-only requirement (no `role` set) can only ever be claimed by
|
|
1212
|
-
* its `sourceAgentId`, and otherwise queues until the starvation escalation
|
|
1213
|
-
* hands it to the Lead. Lead-only work is different: any Lead may claim it
|
|
1214
|
-
* (subject to explicitly required capabilities), because its source/role is
|
|
1215
|
-
* only recovery provenance and must not turn a worker's old role into a
|
|
1216
|
-
* constraint on the Lead pool.
|
|
1217
|
-
*/
|
|
1218
|
-
export function isAgentEligibleForTask(
|
|
1219
|
-
agent: Pick<Agent, "id" | "isLead" | "role" | "capabilities">,
|
|
1220
|
-
task: Pick<AgentTask, "routingAffinity" | "routingAffinityInvalid">,
|
|
1221
|
-
): boolean {
|
|
1222
|
-
const affinity = task.routingAffinity;
|
|
1223
|
-
// A malformed persisted blob is a security boundary failure, not an
|
|
1224
|
-
// untagged task. Quarantine it from every assignment/claim path.
|
|
1225
|
-
if (task.routingAffinityInvalid) return false;
|
|
1226
|
-
if (!affinity) return true; // Untagged task — unchanged behavior.
|
|
1227
|
-
|
|
1228
|
-
const requiredCapabilities = affinity.capabilities ?? [];
|
|
1229
|
-
const hasRequiredCapabilities = () => {
|
|
1230
|
-
const agentCapabilities = new Set(agent.capabilities ?? []);
|
|
1231
|
-
return requiredCapabilities.every((cap) => agentCapabilities.has(cap));
|
|
1232
|
-
};
|
|
1233
|
-
// Lead-only is an authorization boundary, never a best-effort pool hint or
|
|
1234
|
-
// a source-agent exception. Its explicit capability requirements remain
|
|
1235
|
-
// enforced even if the role/capability affinity kill-switch is enabled.
|
|
1236
|
-
// Do not require an unrelated worker role/source to match a Lead-only task.
|
|
1237
|
-
if (affinity.leadOnly) return agent.isLead && hasRequiredCapabilities();
|
|
1238
|
-
if (!isPoolAffinityEnforcementEnabled()) return true;
|
|
1239
|
-
|
|
1240
|
-
if (affinity.sourceAgentId && affinity.sourceAgentId === agent.id) return true; // Own work.
|
|
1241
|
-
|
|
1242
|
-
if (!agent.role || !affinity.role) return false; // Missing role data — no fail-open.
|
|
1243
|
-
if (agent.role !== affinity.role) return false;
|
|
1244
|
-
|
|
1245
|
-
return hasRequiredCapabilities();
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
// ============================================================================
|
|
1249
|
-
// AgentTask Queries
|
|
1250
|
-
// ============================================================================
|
|
1251
|
-
|
|
1252
|
-
type AgentTaskRow = {
|
|
1253
|
-
id: string;
|
|
1254
|
-
key: string;
|
|
1255
|
-
agentId: string | null;
|
|
1256
|
-
creatorAgentId: string | null;
|
|
1257
|
-
task: string;
|
|
1258
|
-
title: string | null;
|
|
1259
|
-
status: AgentTaskStatus;
|
|
1260
|
-
source: AgentTaskSource;
|
|
1261
|
-
taskType: string | null;
|
|
1262
|
-
tags: string | null;
|
|
1263
|
-
priority: number;
|
|
1264
|
-
dependsOn: string | null;
|
|
1265
|
-
offeredTo: string | null;
|
|
1266
|
-
offeredAt: string | null;
|
|
1267
|
-
acceptedAt: string | null;
|
|
1268
|
-
rejectionReason: string | null;
|
|
1269
|
-
slackChannelId: string | null;
|
|
1270
|
-
slackThreadTs: string | null;
|
|
1271
|
-
slackTriggerMessageTs: string | null;
|
|
1272
|
-
slackUserId: string | null;
|
|
1273
|
-
slackReplySent: number;
|
|
1274
|
-
slackProgressMessageTs: string | null;
|
|
1275
|
-
slackTreeRootMessageTs: string | null;
|
|
1276
|
-
vcsProvider: string | null;
|
|
1277
|
-
vcsRepo: string | null;
|
|
1278
|
-
vcsEventType: string | null;
|
|
1279
|
-
vcsNumber: number | null;
|
|
1280
|
-
vcsCommentId: number | null;
|
|
1281
|
-
vcsAuthor: string | null;
|
|
1282
|
-
vcsUrl: string | null;
|
|
1283
|
-
vcsInstallationId: number | null;
|
|
1284
|
-
vcsNodeId: string | null;
|
|
1285
|
-
agentmailInboxId: string | null;
|
|
1286
|
-
agentmailMessageId: string | null;
|
|
1287
|
-
agentmailThreadId: string | null;
|
|
1288
|
-
mentionMessageId: string | null;
|
|
1289
|
-
mentionChannelId: string | null;
|
|
1290
|
-
dir: string | null;
|
|
1291
|
-
parentTaskId: string | null;
|
|
1292
|
-
claudeSessionId: string | null;
|
|
1293
|
-
model: string | null;
|
|
1294
|
-
modelTier: string | null;
|
|
1295
|
-
effort: string | null;
|
|
1296
|
-
scheduleId: string | null;
|
|
1297
|
-
workflowRunId: string | null;
|
|
1298
|
-
workflowRunStepId: string | null;
|
|
1299
|
-
outputSchema: string | null;
|
|
1300
|
-
followUpConfig: string | null;
|
|
1301
|
-
contextKey: string | null;
|
|
1302
|
-
createdAt: string;
|
|
1303
|
-
lastUpdatedAt: string;
|
|
1304
|
-
finishedAt: string | null;
|
|
1305
|
-
notifiedAt: string | null;
|
|
1306
|
-
failureReason: string | null;
|
|
1307
|
-
output: string | null;
|
|
1308
|
-
progress: string | null;
|
|
1309
|
-
compactionCount: number | null;
|
|
1310
|
-
peakContextPercent: number | null;
|
|
1311
|
-
peakContextTokens: number | null;
|
|
1312
|
-
contextWindowSize: number | null;
|
|
1313
|
-
was_paused: number;
|
|
1314
|
-
credentialKeySuffix: string | null;
|
|
1315
|
-
credentialKeyType: string | null;
|
|
1316
|
-
requestedByUserId: string | null;
|
|
1317
|
-
requestedByUserIdInherited: number;
|
|
1318
|
-
swarmVersion: string | null;
|
|
1319
|
-
provider: string | null;
|
|
1320
|
-
providerMeta: string | null;
|
|
1321
|
-
harnessVariant: string | null;
|
|
1322
|
-
harnessVariantMeta: string | null;
|
|
1323
|
-
totalCostUsd?: number | null;
|
|
1324
|
-
routingAffinity: string | null;
|
|
1325
|
-
};
|
|
1326
|
-
|
|
1327
|
-
function rowToAgentTask(row: AgentTaskRow): AgentTask {
|
|
1328
|
-
let followUpConfig: FollowUpConfig | undefined;
|
|
1329
|
-
if (row.followUpConfig) {
|
|
1330
|
-
try {
|
|
1331
|
-
const parsed = FollowUpConfigSchema.safeParse(JSON.parse(row.followUpConfig));
|
|
1332
|
-
if (parsed.success) {
|
|
1333
|
-
followUpConfig = parsed.data;
|
|
1334
|
-
} else {
|
|
1335
|
-
console.warn(
|
|
1336
|
-
`[db] Ignoring invalid agent_tasks.followUpConfig for task ${row.id}:`,
|
|
1337
|
-
parsed.error.message,
|
|
1338
|
-
);
|
|
1339
|
-
}
|
|
1340
|
-
} catch (error) {
|
|
1341
|
-
console.warn(
|
|
1342
|
-
`[db] Ignoring malformed agent_tasks.followUpConfig for task ${row.id}:`,
|
|
1343
|
-
error instanceof Error ? error.message : String(error),
|
|
1344
|
-
);
|
|
1345
|
-
followUpConfig = undefined;
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
let routingAffinity: RoutingAffinity | undefined;
|
|
1350
|
-
let routingAffinityInvalid = false;
|
|
1351
|
-
if (row.routingAffinity) {
|
|
1352
|
-
try {
|
|
1353
|
-
const parsed = RoutingAffinitySchema.safeParse(JSON.parse(row.routingAffinity));
|
|
1354
|
-
if (parsed.success) {
|
|
1355
|
-
routingAffinity = parsed.data;
|
|
1356
|
-
} else {
|
|
1357
|
-
routingAffinityInvalid = true;
|
|
1358
|
-
console.warn(
|
|
1359
|
-
`[db] Quarantining invalid agent_tasks.routingAffinity for task ${row.id}:`,
|
|
1360
|
-
parsed.error.message,
|
|
1361
|
-
);
|
|
1362
|
-
}
|
|
1363
|
-
} catch (error) {
|
|
1364
|
-
routingAffinityInvalid = true;
|
|
1365
|
-
console.warn(
|
|
1366
|
-
`[db] Quarantining malformed agent_tasks.routingAffinity for task ${row.id}:`,
|
|
1367
|
-
error instanceof Error ? error.message : String(error),
|
|
1368
|
-
);
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
return {
|
|
1373
|
-
id: row.id,
|
|
1374
|
-
key: row.key,
|
|
1375
|
-
agentId: row.agentId,
|
|
1376
|
-
creatorAgentId: row.creatorAgentId ?? undefined,
|
|
1377
|
-
task: row.task,
|
|
1378
|
-
title: row.title ?? undefined,
|
|
1379
|
-
status: row.status,
|
|
1380
|
-
source: row.source,
|
|
1381
|
-
taskType: row.taskType ?? undefined,
|
|
1382
|
-
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
1383
|
-
priority: row.priority ?? 50,
|
|
1384
|
-
dependsOn: row.dependsOn ? JSON.parse(row.dependsOn) : [],
|
|
1385
|
-
offeredTo: row.offeredTo ?? undefined,
|
|
1386
|
-
offeredAt: row.offeredAt ?? undefined,
|
|
1387
|
-
acceptedAt: row.acceptedAt ?? undefined,
|
|
1388
|
-
rejectionReason: row.rejectionReason ?? undefined,
|
|
1389
|
-
slackChannelId: row.slackChannelId ?? undefined,
|
|
1390
|
-
slackThreadTs: row.slackThreadTs ?? undefined,
|
|
1391
|
-
slackTriggerMessageTs: row.slackTriggerMessageTs ?? undefined,
|
|
1392
|
-
slackUserId: row.slackUserId ?? undefined,
|
|
1393
|
-
slackReplySent: !!row.slackReplySent,
|
|
1394
|
-
slackProgressMessageTs: row.slackProgressMessageTs ?? undefined,
|
|
1395
|
-
slackTreeRootMessageTs: row.slackTreeRootMessageTs ?? undefined,
|
|
1396
|
-
vcsProvider: (row.vcsProvider as "github" | "gitlab" | null) ?? undefined,
|
|
1397
|
-
vcsRepo: row.vcsRepo ?? undefined,
|
|
1398
|
-
vcsEventType: row.vcsEventType ?? undefined,
|
|
1399
|
-
vcsNumber: row.vcsNumber ?? undefined,
|
|
1400
|
-
vcsCommentId: row.vcsCommentId ?? undefined,
|
|
1401
|
-
vcsAuthor: row.vcsAuthor ?? undefined,
|
|
1402
|
-
vcsUrl: row.vcsUrl ?? undefined,
|
|
1403
|
-
vcsInstallationId: row.vcsInstallationId ?? undefined,
|
|
1404
|
-
vcsNodeId: row.vcsNodeId ?? undefined,
|
|
1405
|
-
agentmailInboxId: row.agentmailInboxId ?? undefined,
|
|
1406
|
-
agentmailMessageId: row.agentmailMessageId ?? undefined,
|
|
1407
|
-
agentmailThreadId: row.agentmailThreadId ?? undefined,
|
|
1408
|
-
mentionMessageId: row.mentionMessageId ?? undefined,
|
|
1409
|
-
mentionChannelId: row.mentionChannelId ?? undefined,
|
|
1410
|
-
dir: row.dir ?? undefined,
|
|
1411
|
-
parentTaskId: row.parentTaskId ?? undefined,
|
|
1412
|
-
claudeSessionId: row.claudeSessionId ?? undefined,
|
|
1413
|
-
model: row.model ?? undefined,
|
|
1414
|
-
modelTier: parseModelTier(row.modelTier) ?? undefined,
|
|
1415
|
-
effort: ReasoningEffortSchema.safeParse(row.effort).success
|
|
1416
|
-
? (row.effort as ReasoningEffort)
|
|
1417
|
-
: undefined,
|
|
1418
|
-
scheduleId: row.scheduleId ?? undefined,
|
|
1419
|
-
workflowRunId: row.workflowRunId ?? undefined,
|
|
1420
|
-
workflowRunStepId: row.workflowRunStepId ?? undefined,
|
|
1421
|
-
outputSchema: row.outputSchema ? JSON.parse(row.outputSchema) : undefined,
|
|
1422
|
-
followUpConfig,
|
|
1423
|
-
contextKey: row.contextKey ?? undefined,
|
|
1424
|
-
compactionCount: row.compactionCount ?? undefined,
|
|
1425
|
-
peakContextPercent: row.peakContextPercent ?? undefined,
|
|
1426
|
-
peakContextTokens: row.peakContextTokens ?? undefined,
|
|
1427
|
-
contextWindowSize: row.contextWindowSize ?? undefined,
|
|
1428
|
-
createdAt: row.createdAt,
|
|
1429
|
-
lastUpdatedAt: row.lastUpdatedAt,
|
|
1430
|
-
finishedAt: row.finishedAt ?? undefined,
|
|
1431
|
-
notifiedAt: row.notifiedAt ?? undefined,
|
|
1432
|
-
failureReason: row.failureReason ?? undefined,
|
|
1433
|
-
output: row.output ?? undefined,
|
|
1434
|
-
progress: row.progress ?? undefined,
|
|
1435
|
-
wasPaused: !!row.was_paused,
|
|
1436
|
-
credentialKeySuffix: row.credentialKeySuffix ?? undefined,
|
|
1437
|
-
credentialKeyType: row.credentialKeyType ?? undefined,
|
|
1438
|
-
requestedByUserId: row.requestedByUserId ?? undefined,
|
|
1439
|
-
swarmVersion: row.swarmVersion ?? undefined,
|
|
1440
|
-
provider: (row.provider as ProviderName | null) ?? undefined,
|
|
1441
|
-
providerMeta: parseProviderMeta(row.provider as ProviderName | null, row.providerMeta),
|
|
1442
|
-
harnessVariant: row.harnessVariant ?? undefined,
|
|
1443
|
-
harnessVariantMeta: row.harnessVariantMeta ? JSON.parse(row.harnessVariantMeta) : undefined,
|
|
1444
|
-
totalCostUsd: row.totalCostUsd ?? undefined,
|
|
1445
|
-
routingAffinity,
|
|
1446
|
-
routingAffinityInvalid: routingAffinityInvalid || undefined,
|
|
1447
|
-
};
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
/**
|
|
1451
|
-
* Slim list-row mapper — truncates the `task` text to a bounded preview and
|
|
1452
|
-
* drops completion/integration/context blobs (`output`, `failureReason`,
|
|
1453
|
-
* `providerMeta`, all `vcs*`/`slack*`/`agentmail*`/`credential*`/`mention*` and
|
|
1454
|
-
* context-window fields). The preview is long enough for pool-triage; the full
|
|
1455
|
-
* brief is on `get-task-details` / `GET /api/tasks/{id}`.
|
|
1456
|
-
*/
|
|
1457
|
-
function rowToAgentTaskSummary(row: AgentTaskRow): AgentTaskSummary {
|
|
1458
|
-
const t = rowToAgentTask(row);
|
|
1459
|
-
return {
|
|
1460
|
-
id: t.id,
|
|
1461
|
-
key: t.key,
|
|
1462
|
-
agentId: t.agentId,
|
|
1463
|
-
creatorAgentId: t.creatorAgentId,
|
|
1464
|
-
task: previewText(t.task, TASK_PREVIEW_LENGTH),
|
|
1465
|
-
title: t.title,
|
|
1466
|
-
status: t.status,
|
|
1467
|
-
source: t.source,
|
|
1468
|
-
taskType: t.taskType,
|
|
1469
|
-
tags: t.tags,
|
|
1470
|
-
priority: t.priority,
|
|
1471
|
-
dependsOn: t.dependsOn,
|
|
1472
|
-
offeredTo: t.offeredTo,
|
|
1473
|
-
acceptedAt: t.acceptedAt,
|
|
1474
|
-
parentTaskId: t.parentTaskId,
|
|
1475
|
-
scheduleId: t.scheduleId,
|
|
1476
|
-
model: t.model,
|
|
1477
|
-
modelTier: t.modelTier,
|
|
1478
|
-
effort: t.effort,
|
|
1479
|
-
provider: t.provider,
|
|
1480
|
-
requestedByUserId: t.requestedByUserId,
|
|
1481
|
-
progress: t.progress,
|
|
1482
|
-
createdAt: t.createdAt,
|
|
1483
|
-
lastUpdatedAt: t.lastUpdatedAt,
|
|
1484
|
-
finishedAt: t.finishedAt,
|
|
1485
|
-
peakContextPercent: t.peakContextPercent,
|
|
1486
|
-
totalCostUsd: t.totalCostUsd,
|
|
1487
|
-
};
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
export async function createTask(
|
|
1491
|
-
agentId: string,
|
|
1492
|
-
task: string,
|
|
1493
|
-
options?: {
|
|
1494
|
-
source?: AgentTaskSource;
|
|
1495
|
-
slackChannelId?: string;
|
|
1496
|
-
slackThreadTs?: string;
|
|
1497
|
-
slackUserId?: string;
|
|
595
|
+
updates: {
|
|
596
|
+
permalink?: string;
|
|
597
|
+
finalized?: boolean;
|
|
598
|
+
streamChunksAppended?: number;
|
|
599
|
+
conclusionKind?: SlackConclusionKind;
|
|
600
|
+
actorId?: string;
|
|
601
|
+
touchUpdatedAt?: boolean;
|
|
1498
602
|
},
|
|
1499
|
-
): Promise<
|
|
1500
|
-
const id = crypto.randomUUID();
|
|
1501
|
-
const source = options?.source ?? "mcp";
|
|
1502
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
1503
|
-
`INSERT INTO agent_tasks (id, "key", agentId, task, status, source, slackChannelId, slackThreadTs, slackUserId, swarmVersion, createdAt, lastUpdatedAt)
|
|
1504
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *`,
|
|
1505
|
-
[
|
|
1506
|
-
id,
|
|
1507
|
-
defaultAssetKey("task", id),
|
|
1508
|
-
agentId,
|
|
1509
|
-
task,
|
|
1510
|
-
"pending",
|
|
1511
|
-
source,
|
|
1512
|
-
options?.slackChannelId ?? null,
|
|
1513
|
-
options?.slackThreadTs ?? null,
|
|
1514
|
-
options?.slackUserId ?? null,
|
|
1515
|
-
pkg.version,
|
|
1516
|
-
],
|
|
1517
|
-
);
|
|
1518
|
-
if (!row) throw new Error("Failed to create task");
|
|
1519
|
-
try {
|
|
1520
|
-
await createLogEntry({
|
|
1521
|
-
eventType: "task_created",
|
|
1522
|
-
agentId,
|
|
1523
|
-
taskId: id,
|
|
1524
|
-
newValue: "pending",
|
|
1525
|
-
metadata: { source },
|
|
1526
|
-
});
|
|
1527
|
-
} catch {}
|
|
1528
|
-
return rowToAgentTask(row);
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
/**
|
|
1532
|
-
* In-process dedup for `task_dispatch_rejected_affinity` logging in
|
|
1533
|
-
* `getPendingTaskForAgent` below — that function runs on every poll tick for
|
|
1534
|
-
* every agent with a directly-assigned pending task, so an unresolved skip
|
|
1535
|
-
* (e.g. a corrupt/misassigned legacy row) would otherwise write one log row
|
|
1536
|
-
* per poll forever. Keyed by taskId; per-process, like `alarmActive` in
|
|
1537
|
-
* `queue-stall-alarm.ts` — an API restart re-arms it, which is fine since the
|
|
1538
|
-
* point is "don't spam," not "log exactly once ever."
|
|
1539
|
-
*/
|
|
1540
|
-
const AFFINITY_DISPATCH_SKIP_LOG_COOLDOWN_MS = 5 * 60 * 1000;
|
|
1541
|
-
const lastAffinityDispatchSkipLoggedAt = new Map<string, number>();
|
|
1542
|
-
|
|
1543
|
-
async function logAffinityDispatchSkip(
|
|
1544
|
-
agent: Pick<Agent, "id" | "isLead" | "role">,
|
|
1545
|
-
task: Pick<AgentTask, "id" | "routingAffinity" | "routingAffinityInvalid">,
|
|
1546
|
-
): Promise<void> {
|
|
1547
|
-
const now = Date.now();
|
|
1548
|
-
const lastLoggedAt = lastAffinityDispatchSkipLoggedAt.get(task.id);
|
|
1549
|
-
if (lastLoggedAt !== undefined && now - lastLoggedAt < AFFINITY_DISPATCH_SKIP_LOG_COOLDOWN_MS) {
|
|
1550
|
-
return;
|
|
1551
|
-
}
|
|
1552
|
-
lastAffinityDispatchSkipLoggedAt.set(task.id, now);
|
|
1553
|
-
try {
|
|
1554
|
-
await createLogEntry({
|
|
1555
|
-
eventType: "task_dispatch_rejected_affinity",
|
|
1556
|
-
agentId: agent.id,
|
|
1557
|
-
taskId: task.id,
|
|
1558
|
-
metadata: {
|
|
1559
|
-
agentRole: agent.role ?? null,
|
|
1560
|
-
requiredRole: task.routingAffinity?.role ?? null,
|
|
1561
|
-
leadOnly: task.routingAffinity?.leadOnly === true,
|
|
1562
|
-
agentIsLead: agent.isLead ?? false,
|
|
1563
|
-
routingAffinityInvalid: task.routingAffinityInvalid === true,
|
|
1564
|
-
},
|
|
1565
|
-
});
|
|
1566
|
-
} catch {}
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
export async function getPendingTaskForAgent(agentId: string): Promise<AgentTask | null> {
|
|
1570
|
-
// Get all pending tasks for this agent, ordered by priority (desc) then creation time (asc)
|
|
1571
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
1572
|
-
"SELECT * FROM agent_tasks WHERE agentId = ? AND status = 'pending' ORDER BY priority DESC, createdAt ASC",
|
|
1573
|
-
[agentId],
|
|
1574
|
-
);
|
|
1575
|
-
|
|
1576
|
-
const agent = await getAgentById(agentId);
|
|
1577
|
-
if (!agent) return null;
|
|
1578
|
-
|
|
1579
|
-
for (const row of rows) {
|
|
1580
|
-
const task = rowToAgentTask(row);
|
|
1581
|
-
// `task.agentId` (the WHERE clause above) is a direct-assignment decision
|
|
1582
|
-
// already made by the task's creator — `createTaskExtended` enforces a
|
|
1583
|
-
// caller-declared requirement at creation time (see
|
|
1584
|
-
// `routingAffinityIsInheritedProvenance` there). Re-running the FULL
|
|
1585
|
-
// role/capability match here re-litigates that decision using metadata
|
|
1586
|
-
// that may be pure inherited PROVENANCE (e.g. a Lead-routed
|
|
1587
|
-
// worker-completion follow-up that inherits the finishing worker's
|
|
1588
|
-
// role/capabilities as lineage, not a requirement anyone declared) —
|
|
1589
|
-
// which permanently stalls dispatch to the agent the task is already
|
|
1590
|
-
// pinned to (the #1276-regression this fixes; see PR body). Mirror the
|
|
1591
|
-
// convention `acceptTask`/`claimOfferedTask` already use for an
|
|
1592
|
-
// established offer: only `leadOnly` (a real authorization boundary) and
|
|
1593
|
-
// `routingAffinityInvalid` (quarantined corrupt data) still veto a
|
|
1594
|
-
// directly-assigned task. Pool-claim paths (`claimTask`,
|
|
1595
|
-
// `assignUnassignedTaskPending`) are untouched and keep the full gate —
|
|
1596
|
-
// they are deciding "who gets this" from the pool, not redispatching an
|
|
1597
|
-
// assignment that was already authorized (or, for provenance, never a
|
|
1598
|
-
// requirement) at creation time.
|
|
1599
|
-
if (task.routingAffinityInvalid) {
|
|
1600
|
-
await logAffinityDispatchSkip(agent, task);
|
|
1601
|
-
continue;
|
|
1602
|
-
}
|
|
1603
|
-
if (task.routingAffinity?.leadOnly && !isAgentEligibleForTask(agent, task)) {
|
|
1604
|
-
await logAffinityDispatchSkip(agent, task);
|
|
1605
|
-
continue;
|
|
1606
|
-
}
|
|
1607
|
-
const { ready } = await checkDependencies(task.id);
|
|
1608
|
-
if (ready) return task;
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
return null;
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
export async function assignUnassignedTaskPending(
|
|
1615
|
-
taskId: string,
|
|
1616
|
-
agentId: string,
|
|
1617
|
-
): Promise<AgentTask | null> {
|
|
1618
|
-
// This guard is always needed for lead-only tasks; the predicate itself
|
|
1619
|
-
// handles the optional role/capability kill-switch.
|
|
1620
|
-
{
|
|
1621
|
-
const task = await getTaskById(taskId);
|
|
1622
|
-
const agent = await getAgentById(agentId);
|
|
1623
|
-
if (task && (!agent || !isAgentEligibleForTask(agent, task))) {
|
|
1624
|
-
try {
|
|
1625
|
-
await createLogEntry({
|
|
1626
|
-
eventType: "task_claim_rejected_affinity",
|
|
1627
|
-
agentId,
|
|
1628
|
-
taskId,
|
|
1629
|
-
metadata: {
|
|
1630
|
-
agentRole: agent?.role ?? null,
|
|
1631
|
-
requiredRole: task.routingAffinity?.role ?? null,
|
|
1632
|
-
leadOnly: task.routingAffinity?.leadOnly === true,
|
|
1633
|
-
agentIsLead: agent?.isLead ?? false,
|
|
1634
|
-
},
|
|
1635
|
-
});
|
|
1636
|
-
} catch {}
|
|
1637
|
-
return null;
|
|
1638
|
-
}
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
const now = new Date().toISOString();
|
|
1642
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
1643
|
-
`UPDATE agent_tasks SET agentId = ?, status = 'pending', lastUpdatedAt = ?
|
|
1644
|
-
WHERE id = ? AND status = 'unassigned' RETURNING *`,
|
|
1645
|
-
[agentId, now, taskId],
|
|
1646
|
-
);
|
|
1647
|
-
|
|
1648
|
-
if (row) {
|
|
1649
|
-
try {
|
|
1650
|
-
await createLogEntry({
|
|
1651
|
-
eventType: "task_status_change",
|
|
1652
|
-
agentId,
|
|
1653
|
-
taskId,
|
|
1654
|
-
oldValue: "unassigned",
|
|
1655
|
-
newValue: "pending",
|
|
1656
|
-
metadata: { pendingDispatch: true },
|
|
1657
|
-
});
|
|
1658
|
-
} catch {}
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
return row ? rowToAgentTask(row) : null;
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
export async function startTask(taskId: string): Promise<AgentTask | null> {
|
|
1665
|
-
const oldTask = await getTaskById(taskId);
|
|
1666
|
-
if (!oldTask) return null;
|
|
1667
|
-
|
|
1668
|
-
// Guard: never revive tasks that are already in a terminal state
|
|
1669
|
-
if (isTerminalTaskStatus(oldTask.status)) {
|
|
1670
|
-
return null;
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
1674
|
-
`UPDATE agent_tasks SET status = 'in_progress', lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
1675
|
-
WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
|
|
1676
|
-
[taskId],
|
|
1677
|
-
);
|
|
1678
|
-
if (row && oldTask) {
|
|
1679
|
-
try {
|
|
1680
|
-
await createLogEntry({
|
|
1681
|
-
eventType: "task_status_change",
|
|
1682
|
-
taskId,
|
|
1683
|
-
agentId: row.agentId ?? undefined,
|
|
1684
|
-
oldValue: oldTask.status,
|
|
1685
|
-
newValue: "in_progress",
|
|
1686
|
-
});
|
|
1687
|
-
} catch {}
|
|
1688
|
-
}
|
|
1689
|
-
const result = row ? rowToAgentTask(row) : null;
|
|
1690
|
-
// Fire-and-forget: notify lifecycle subscribers (e.g. GitHub eyes reaction)
|
|
1691
|
-
if (result && oldTask.status !== "in_progress") {
|
|
1692
|
-
emitTaskStarted(result);
|
|
1693
|
-
}
|
|
1694
|
-
return result;
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
export async function getTaskById(id: string): Promise<AgentTask | null> {
|
|
1698
|
-
const row = await getDbClient().get<AgentTaskRow>("SELECT * FROM agent_tasks WHERE id = ?", [id]);
|
|
1699
|
-
return row ? rowToAgentTask(row) : null;
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
export async function getSlackRenderV2ActivatedAt(): Promise<string | null> {
|
|
1703
|
-
const row = await getDbClient().get<{ activated_at: string }>(
|
|
1704
|
-
`SELECT activated_at FROM slack_render_v2_state WHERE id = 1`,
|
|
1705
|
-
);
|
|
1706
|
-
return row?.activated_at ?? null;
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
export async function ensureSlackRenderV2Activation(): Promise<string> {
|
|
1710
|
-
const activatedAt = new Date().toISOString();
|
|
1711
|
-
await getDbClient().run(
|
|
1712
|
-
`INSERT INTO slack_render_v2_state (id, activated_at)
|
|
1713
|
-
VALUES (1, ?)
|
|
1714
|
-
ON CONFLICT(id) DO NOTHING`,
|
|
1715
|
-
[activatedAt],
|
|
1716
|
-
);
|
|
1717
|
-
const persisted = await getSlackRenderV2ActivatedAt();
|
|
1718
|
-
if (!persisted) throw new Error("Failed to persist Slack render v2 activation");
|
|
1719
|
-
return persisted;
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
export async function getSlackDelegationActivatedAt(): Promise<string | null> {
|
|
1723
|
-
const row = await getDbClient().get<{ delegation_activated_at: string | null }>(
|
|
1724
|
-
`SELECT delegation_activated_at FROM slack_render_v2_state WHERE id = 1`,
|
|
1725
|
-
);
|
|
1726
|
-
return row?.delegation_activated_at ?? null;
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
export async function ensureSlackDelegationActivation(): Promise<string> {
|
|
1730
|
-
const activatedAt = new Date().toISOString();
|
|
1731
|
-
await getDbClient().run(
|
|
1732
|
-
`INSERT INTO slack_render_v2_state (id, activated_at, delegation_activated_at)
|
|
1733
|
-
VALUES (1, ?, ?)
|
|
1734
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
1735
|
-
delegation_activated_at = COALESCE(slack_render_v2_state.delegation_activated_at, excluded.delegation_activated_at)`,
|
|
1736
|
-
[activatedAt, activatedAt],
|
|
1737
|
-
);
|
|
1738
|
-
const persisted = await getSlackDelegationActivatedAt();
|
|
1739
|
-
if (!persisted) throw new Error("Failed to persist Slack render v2 delegation activation");
|
|
1740
|
-
return persisted;
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
export type SlackMessageKind = "tree" | "outcome" | "agent";
|
|
1744
|
-
export type SlackConclusionKind = "complete" | "timeout";
|
|
1745
|
-
|
|
1746
|
-
export interface SlackMessageRecord {
|
|
1747
|
-
id: string;
|
|
1748
|
-
contextKey: string;
|
|
1749
|
-
channelId: string;
|
|
1750
|
-
threadTs: string;
|
|
1751
|
-
ts: string;
|
|
1752
|
-
kind: SlackMessageKind;
|
|
1753
|
-
taskId?: string;
|
|
1754
|
-
permalink?: string;
|
|
1755
|
-
finalizedAt?: string;
|
|
1756
|
-
streamChunksAppended: number;
|
|
1757
|
-
conclusionKind?: SlackConclusionKind;
|
|
1758
|
-
createdAt: string;
|
|
1759
|
-
updatedAt: string;
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
const PENDING_SLACK_MESSAGE_TS_PREFIX = "pending:";
|
|
1763
|
-
|
|
1764
|
-
export function isPendingSlackMessage(record: SlackMessageRecord): boolean {
|
|
1765
|
-
return record.ts.startsWith(PENDING_SLACK_MESSAGE_TS_PREFIX);
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
type SlackMessageRow = {
|
|
1769
|
-
id: string;
|
|
1770
|
-
context_key: string;
|
|
1771
|
-
channel_id: string;
|
|
1772
|
-
thread_ts: string;
|
|
1773
|
-
ts: string;
|
|
1774
|
-
kind: SlackMessageKind;
|
|
1775
|
-
task_id: string | null;
|
|
1776
|
-
permalink: string | null;
|
|
1777
|
-
finalized_at: string | null;
|
|
1778
|
-
stream_chunks_appended: number;
|
|
1779
|
-
conclusion_kind: SlackConclusionKind | null;
|
|
1780
|
-
created_at: string;
|
|
1781
|
-
updated_at: string;
|
|
1782
|
-
};
|
|
1783
|
-
|
|
1784
|
-
function rowToSlackMessage(row: SlackMessageRow): SlackMessageRecord {
|
|
1785
|
-
return {
|
|
1786
|
-
id: row.id,
|
|
1787
|
-
contextKey: row.context_key,
|
|
1788
|
-
channelId: row.channel_id,
|
|
1789
|
-
threadTs: row.thread_ts,
|
|
1790
|
-
ts: row.ts,
|
|
1791
|
-
kind: row.kind,
|
|
1792
|
-
taskId: row.task_id ?? undefined,
|
|
1793
|
-
permalink: row.permalink ?? undefined,
|
|
1794
|
-
finalizedAt: row.finalized_at ?? undefined,
|
|
1795
|
-
streamChunksAppended: row.stream_chunks_appended,
|
|
1796
|
-
conclusionKind: row.conclusion_kind ?? undefined,
|
|
1797
|
-
createdAt: row.created_at,
|
|
1798
|
-
updatedAt: row.updated_at,
|
|
1799
|
-
};
|
|
1800
|
-
}
|
|
1801
|
-
|
|
1802
|
-
export async function recordSlackMessage(input: {
|
|
1803
|
-
contextKey: string;
|
|
1804
|
-
channelId: string;
|
|
1805
|
-
threadTs: string;
|
|
1806
|
-
ts: string;
|
|
1807
|
-
kind: SlackMessageKind;
|
|
1808
|
-
taskId?: string;
|
|
1809
|
-
permalink?: string;
|
|
1810
|
-
finalized?: boolean;
|
|
1811
|
-
streamChunksAppended?: number;
|
|
1812
|
-
conclusionKind?: SlackConclusionKind;
|
|
1813
|
-
actorId?: string;
|
|
1814
|
-
}): Promise<SlackMessageRecord> {
|
|
1815
|
-
const id = crypto.randomUUID();
|
|
1816
|
-
const now = new Date().toISOString();
|
|
1817
|
-
const row = await getDbClient().get<SlackMessageRow>(
|
|
1818
|
-
`INSERT INTO slack_messages (
|
|
1819
|
-
id, context_key, channel_id, thread_ts, ts, kind, task_id, permalink,
|
|
1820
|
-
finalized_at, stream_chunks_appended, conclusion_kind, created_at, updated_at, created_by, updated_by
|
|
1821
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1822
|
-
ON CONFLICT(channel_id, ts) DO UPDATE SET
|
|
1823
|
-
context_key = excluded.context_key,
|
|
1824
|
-
kind = excluded.kind,
|
|
1825
|
-
task_id = COALESCE(excluded.task_id, slack_messages.task_id),
|
|
1826
|
-
permalink = COALESCE(excluded.permalink, slack_messages.permalink),
|
|
1827
|
-
finalized_at = COALESCE(excluded.finalized_at, slack_messages.finalized_at),
|
|
1828
|
-
stream_chunks_appended = MAX(
|
|
1829
|
-
excluded.stream_chunks_appended,
|
|
1830
|
-
slack_messages.stream_chunks_appended
|
|
1831
|
-
),
|
|
1832
|
-
conclusion_kind = COALESCE(excluded.conclusion_kind, slack_messages.conclusion_kind),
|
|
1833
|
-
updated_at = excluded.updated_at,
|
|
1834
|
-
updated_by = excluded.updated_by
|
|
1835
|
-
RETURNING *`,
|
|
1836
|
-
[
|
|
1837
|
-
id,
|
|
1838
|
-
input.contextKey,
|
|
1839
|
-
input.channelId,
|
|
1840
|
-
input.threadTs,
|
|
1841
|
-
input.ts,
|
|
1842
|
-
input.kind,
|
|
1843
|
-
input.taskId ?? null,
|
|
1844
|
-
input.permalink ?? null,
|
|
1845
|
-
input.finalized ? now : null,
|
|
1846
|
-
input.streamChunksAppended ?? 0,
|
|
1847
|
-
input.conclusionKind ?? null,
|
|
1848
|
-
now,
|
|
1849
|
-
now,
|
|
1850
|
-
input.actorId ?? null,
|
|
1851
|
-
input.actorId ?? null,
|
|
1852
|
-
],
|
|
1853
|
-
);
|
|
1854
|
-
if (!row) throw new Error("Failed to record Slack message");
|
|
1855
|
-
return rowToSlackMessage(row);
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
export async function reserveSlackMessage(input: {
|
|
1859
|
-
contextKey: string;
|
|
1860
|
-
channelId: string;
|
|
1861
|
-
threadTs: string;
|
|
1862
|
-
kind: "tree" | "outcome";
|
|
1863
|
-
taskId?: string;
|
|
1864
|
-
actorId?: string;
|
|
1865
|
-
}): Promise<{ record: SlackMessageRecord; created: boolean }> {
|
|
1866
|
-
if (input.kind === "outcome" && !input.taskId) {
|
|
1867
|
-
throw new Error("Outcome Slack message reservations require a task ID");
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
const id = crypto.randomUUID();
|
|
1871
|
-
const now = new Date().toISOString();
|
|
1872
|
-
const pendingTs = `${PENDING_SLACK_MESSAGE_TS_PREFIX}${id}`;
|
|
1873
|
-
const inserted = await getDbClient().get<SlackMessageRow>(
|
|
1874
|
-
`INSERT INTO slack_messages (
|
|
1875
|
-
id, context_key, channel_id, thread_ts, ts, kind, task_id,
|
|
1876
|
-
stream_chunks_appended, created_at, updated_at, created_by, updated_by
|
|
1877
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
|
1878
|
-
ON CONFLICT DO NOTHING
|
|
1879
|
-
RETURNING *`,
|
|
1880
|
-
[
|
|
1881
|
-
id,
|
|
1882
|
-
input.contextKey,
|
|
1883
|
-
input.channelId,
|
|
1884
|
-
input.threadTs,
|
|
1885
|
-
pendingTs,
|
|
1886
|
-
input.kind,
|
|
1887
|
-
input.taskId ?? null,
|
|
1888
|
-
now,
|
|
1889
|
-
now,
|
|
1890
|
-
input.actorId ?? null,
|
|
1891
|
-
input.actorId ?? null,
|
|
1892
|
-
],
|
|
1893
|
-
);
|
|
1894
|
-
if (inserted) return { record: rowToSlackMessage(inserted), created: true };
|
|
1895
|
-
|
|
1896
|
-
const existing =
|
|
1897
|
-
input.kind === "tree"
|
|
1898
|
-
? await getSlackTreeMessageByThread(input.channelId, input.threadTs)
|
|
1899
|
-
: await getSlackOutcomeMessage(input.taskId!);
|
|
1900
|
-
if (!existing) throw new Error("Failed to reserve Slack message");
|
|
1901
|
-
return { record: existing, created: false };
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
export async function bindSlackMessageTimestamp(
|
|
1905
|
-
id: string,
|
|
1906
|
-
ts: string,
|
|
1907
|
-
options: { streamChunksAppended?: number; renderedThrough?: string } = {},
|
|
1908
|
-
): Promise<SlackMessageRecord | null> {
|
|
1909
|
-
const now = new Date().toISOString();
|
|
1910
|
-
const row = await getDbClient().get<SlackMessageRow>(
|
|
1911
|
-
`UPDATE slack_messages SET
|
|
1912
|
-
ts = ?,
|
|
1913
|
-
stream_chunks_appended = MAX(stream_chunks_appended, ?),
|
|
1914
|
-
updated_at = COALESCE(?, ?)
|
|
1915
|
-
WHERE id = ? AND ts LIKE 'pending:%'
|
|
1916
|
-
RETURNING *`,
|
|
1917
|
-
[ts, options.streamChunksAppended ?? 0, options.renderedThrough ?? null, now, id],
|
|
1918
|
-
);
|
|
1919
|
-
return row ? rowToSlackMessage(row) : null;
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
export async function updateSlackMessageRecord(
|
|
1923
|
-
id: string,
|
|
1924
|
-
updates: {
|
|
1925
|
-
permalink?: string;
|
|
1926
|
-
finalized?: boolean;
|
|
1927
|
-
streamChunksAppended?: number;
|
|
1928
|
-
conclusionKind?: SlackConclusionKind;
|
|
1929
|
-
actorId?: string;
|
|
1930
|
-
touchUpdatedAt?: boolean;
|
|
1931
|
-
},
|
|
1932
|
-
): Promise<SlackMessageRecord | null> {
|
|
603
|
+
): Promise<SlackMessageRecord | null> {
|
|
1933
604
|
const now = new Date().toISOString();
|
|
1934
605
|
const row = await getDbClient().get<SlackMessageRow>(
|
|
1935
606
|
`UPDATE slack_messages SET
|
|
@@ -1938,1769 +609,202 @@ export async function updateSlackMessageRecord(
|
|
|
1938
609
|
stream_chunks_appended = COALESCE(?, stream_chunks_appended),
|
|
1939
610
|
conclusion_kind = COALESCE(?, conclusion_kind),
|
|
1940
611
|
updated_at = CASE WHEN ? = 1 THEN ? ELSE updated_at END,
|
|
1941
|
-
updated_by = COALESCE(?, updated_by)
|
|
1942
|
-
WHERE id = ?
|
|
1943
|
-
RETURNING *`,
|
|
1944
|
-
[
|
|
1945
|
-
updates.permalink ?? null,
|
|
1946
|
-
updates.finalized ? 1 : 0,
|
|
1947
|
-
now,
|
|
1948
|
-
updates.streamChunksAppended ?? null,
|
|
1949
|
-
updates.conclusionKind ?? null,
|
|
1950
|
-
updates.touchUpdatedAt === false ? 0 : 1,
|
|
1951
|
-
now,
|
|
1952
|
-
updates.actorId ?? null,
|
|
1953
|
-
id,
|
|
1954
|
-
],
|
|
1955
|
-
);
|
|
1956
|
-
return row ? rowToSlackMessage(row) : null;
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
export async function markSlackTreeRendered(
|
|
1960
|
-
id: string,
|
|
1961
|
-
renderedThrough: string,
|
|
1962
|
-
): Promise<SlackMessageRecord | null> {
|
|
1963
|
-
const row = await getDbClient().get<SlackMessageRow>(
|
|
1964
|
-
`UPDATE slack_messages SET updated_at = ?
|
|
1965
|
-
WHERE id = ? AND kind = 'tree'
|
|
1966
|
-
RETURNING *`,
|
|
1967
|
-
[renderedThrough, id],
|
|
1968
|
-
);
|
|
1969
|
-
return row ? rowToSlackMessage(row) : null;
|
|
1970
|
-
}
|
|
1971
|
-
|
|
1972
|
-
export async function deleteSlackMessageRecord(id: string): Promise<boolean> {
|
|
1973
|
-
const result = await getDbClient().run(`DELETE FROM slack_messages WHERE id = ?`, [id]);
|
|
1974
|
-
return result.changes > 0;
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
export async function getSlackTreeMessage(contextKey: string): Promise<SlackMessageRecord | null> {
|
|
1978
|
-
const row = await getDbClient().get<SlackMessageRow>(
|
|
1979
|
-
`SELECT * FROM slack_messages WHERE context_key = ? AND kind = 'tree' LIMIT 1`,
|
|
1980
|
-
[contextKey],
|
|
1981
|
-
);
|
|
1982
|
-
return row ? rowToSlackMessage(row) : null;
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
export async function getSlackTreeMessageByThread(
|
|
1986
|
-
channelId: string,
|
|
1987
|
-
threadTs: string,
|
|
1988
|
-
): Promise<SlackMessageRecord | null> {
|
|
1989
|
-
const row = await getDbClient().get<SlackMessageRow>(
|
|
1990
|
-
`SELECT * FROM slack_messages
|
|
1991
|
-
WHERE channel_id = ? AND thread_ts = ? AND kind = 'tree'
|
|
1992
|
-
LIMIT 1`,
|
|
1993
|
-
[channelId, threadTs],
|
|
1994
|
-
);
|
|
1995
|
-
return row ? rowToSlackMessage(row) : null;
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
export async function getSlackOutcomeMessage(taskId: string): Promise<SlackMessageRecord | null> {
|
|
1999
|
-
const row = await getDbClient().get<SlackMessageRow>(
|
|
2000
|
-
`SELECT * FROM slack_messages WHERE task_id = ? AND kind = 'outcome' LIMIT 1`,
|
|
2001
|
-
[taskId],
|
|
2002
|
-
);
|
|
2003
|
-
return row ? rowToSlackMessage(row) : null;
|
|
2004
|
-
}
|
|
2005
|
-
|
|
2006
|
-
export async function getSlackTreeMessages(): Promise<SlackMessageRecord[]> {
|
|
2007
|
-
const rows = await getDbClient().query<SlackMessageRow>(
|
|
2008
|
-
`SELECT tree.*
|
|
2009
|
-
FROM slack_messages tree
|
|
2010
|
-
JOIN slack_render_v2_state state ON state.id = 1
|
|
2011
|
-
WHERE tree.kind = 'tree'
|
|
2012
|
-
AND (
|
|
2013
|
-
(tree.ts LIKE 'pending:%' AND tree.created_at >= state.activated_at)
|
|
2014
|
-
OR EXISTS (
|
|
2015
|
-
SELECT 1 FROM agent_tasks task
|
|
2016
|
-
WHERE task.slackChannelId = tree.channel_id
|
|
2017
|
-
AND task.slackThreadTs = tree.thread_ts
|
|
2018
|
-
AND task.source = 'slack'
|
|
2019
|
-
AND task.createdAt >= state.activated_at
|
|
2020
|
-
AND task.status IN ('completed', 'failed', 'cancelled')
|
|
2021
|
-
AND NOT EXISTS (
|
|
2022
|
-
SELECT 1 FROM slack_messages outcome
|
|
2023
|
-
WHERE outcome.kind = 'outcome'
|
|
2024
|
-
AND outcome.task_id = task.id
|
|
2025
|
-
AND outcome.finalized_at IS NOT NULL
|
|
2026
|
-
)
|
|
2027
|
-
)
|
|
2028
|
-
OR
|
|
2029
|
-
EXISTS (
|
|
2030
|
-
SELECT 1 FROM agent_tasks task
|
|
2031
|
-
WHERE task.slackChannelId = tree.channel_id
|
|
2032
|
-
AND task.slackThreadTs = tree.thread_ts
|
|
2033
|
-
AND task.createdAt >= state.activated_at
|
|
2034
|
-
AND task.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
|
|
2035
|
-
)
|
|
2036
|
-
OR EXISTS (
|
|
2037
|
-
SELECT 1 FROM agent_tasks task
|
|
2038
|
-
WHERE task.slackChannelId = tree.channel_id
|
|
2039
|
-
AND task.slackThreadTs = tree.thread_ts
|
|
2040
|
-
AND task.createdAt >= state.activated_at
|
|
2041
|
-
AND task.lastUpdatedAt > tree.updated_at
|
|
2042
|
-
)
|
|
2043
|
-
OR EXISTS (
|
|
2044
|
-
SELECT 1 FROM slack_messages outcome
|
|
2045
|
-
JOIN agent_tasks task ON task.id = outcome.task_id
|
|
2046
|
-
WHERE outcome.kind = 'outcome'
|
|
2047
|
-
AND outcome.channel_id = tree.channel_id
|
|
2048
|
-
AND outcome.thread_ts = tree.thread_ts
|
|
2049
|
-
AND task.createdAt >= state.activated_at
|
|
2050
|
-
AND (
|
|
2051
|
-
outcome.finalized_at IS NULL
|
|
2052
|
-
OR outcome.updated_at > tree.updated_at
|
|
2053
|
-
)
|
|
2054
|
-
)
|
|
2055
|
-
)
|
|
2056
|
-
ORDER BY tree.created_at ASC`,
|
|
2057
|
-
);
|
|
2058
|
-
return rows.map(rowToSlackMessage);
|
|
2059
|
-
}
|
|
2060
|
-
|
|
2061
|
-
export async function getSlackMessageByChannelTs(
|
|
2062
|
-
channelId: string,
|
|
2063
|
-
ts: string,
|
|
2064
|
-
): Promise<SlackMessageRecord | null> {
|
|
2065
|
-
const row = await getDbClient().get<SlackMessageRow>(
|
|
2066
|
-
`SELECT * FROM slack_messages WHERE channel_id = ? AND ts = ? LIMIT 1`,
|
|
2067
|
-
[channelId, ts],
|
|
2068
|
-
);
|
|
2069
|
-
return row ? rowToSlackMessage(row) : null;
|
|
2070
|
-
}
|
|
2071
|
-
|
|
2072
|
-
export async function getSlackTasksInThread(
|
|
2073
|
-
channelId: string,
|
|
2074
|
-
threadTs: string,
|
|
2075
|
-
): Promise<AgentTask[]> {
|
|
2076
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
2077
|
-
`SELECT * FROM agent_tasks
|
|
2078
|
-
WHERE slackChannelId = ? AND slackThreadTs = ?
|
|
2079
|
-
ORDER BY createdAt ASC, rowid ASC`,
|
|
2080
|
-
[channelId, threadTs],
|
|
2081
|
-
);
|
|
2082
|
-
return rows.map(rowToAgentTask);
|
|
2083
|
-
}
|
|
2084
|
-
|
|
2085
|
-
export async function markTaskSlackReplySent(taskId: string): Promise<void> {
|
|
2086
|
-
await getDbClient().run(`UPDATE agent_tasks SET slackReplySent = 1 WHERE id = ?`, [taskId]);
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
|
-
export async function setSlackMessageTracking(
|
|
2090
|
-
taskId: string,
|
|
2091
|
-
fields: {
|
|
2092
|
-
slackProgressMessageTs?: string | null;
|
|
2093
|
-
slackTreeRootMessageTs?: string | null;
|
|
2094
|
-
},
|
|
2095
|
-
): Promise<void> {
|
|
2096
|
-
const sets: string[] = [];
|
|
2097
|
-
const args: (string | null)[] = [];
|
|
2098
|
-
|
|
2099
|
-
if (Object.hasOwn(fields, "slackProgressMessageTs")) {
|
|
2100
|
-
sets.push("slackProgressMessageTs = ?");
|
|
2101
|
-
args.push(fields.slackProgressMessageTs ?? null);
|
|
2102
|
-
}
|
|
2103
|
-
if (Object.hasOwn(fields, "slackTreeRootMessageTs")) {
|
|
2104
|
-
sets.push("slackTreeRootMessageTs = ?");
|
|
2105
|
-
args.push(fields.slackTreeRootMessageTs ?? null);
|
|
2106
|
-
}
|
|
2107
|
-
if (sets.length === 0) return;
|
|
2108
|
-
|
|
2109
|
-
args.push(taskId);
|
|
2110
|
-
await getDbClient().run(`UPDATE agent_tasks SET ${sets.join(", ")} WHERE id = ?`, args);
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
export async function getChildTasks(parentTaskId: string): Promise<AgentTask[]> {
|
|
2114
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
2115
|
-
`SELECT * FROM agent_tasks WHERE parentTaskId = ? ORDER BY createdAt ASC, rowid ASC`,
|
|
2116
|
-
[parentTaskId],
|
|
2117
|
-
);
|
|
2118
|
-
return rows.map(rowToAgentTask);
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
/**
|
|
2122
|
-
* Returns true if `parentId` has at least one non-terminal child task with
|
|
2123
|
-
* `taskType = 'resume'`. Used by the heartbeat sweep as an idempotency guard:
|
|
2124
|
-
* if a prior sweep tick already created a resume follow-up for this parent,
|
|
2125
|
-
* don't create a duplicate.
|
|
2126
|
-
*
|
|
2127
|
-
* **Filters by taskType = 'resume'** specifically. A parent task can also
|
|
2128
|
-
* have ordinary non-terminal delegation children (`send-task` auto-defaults
|
|
2129
|
-
* `parentTaskId` to the caller's current task — see src/tools/send-task.ts).
|
|
2130
|
-
* Treating those as "already resumed" would incorrectly skip the resume
|
|
2131
|
-
* path for a crashed worker that had delegated subtasks (PR #594 review).
|
|
2132
|
-
*/
|
|
2133
|
-
export async function hasNonTerminalResumeChild(parentId: string): Promise<boolean> {
|
|
2134
|
-
const row = await getDbClient().get(
|
|
2135
|
-
`SELECT 1 FROM agent_tasks
|
|
2136
|
-
WHERE parentTaskId = ?
|
|
2137
|
-
AND taskType = 'resume'
|
|
2138
|
-
AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
|
|
2139
|
-
LIMIT 1`,
|
|
2140
|
-
[parentId],
|
|
2141
|
-
);
|
|
2142
|
-
return row !== undefined && row !== null;
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
/**
|
|
2146
|
-
* True when a non-terminal `reroute-decision` child exists for `parentId`.
|
|
2147
|
-
*
|
|
2148
|
-
* Mirrors {@link hasNonTerminalResumeChild} but filters on
|
|
2149
|
-
* `taskType = 'reroute-decision'` — the Lead-owned re-delegation decision
|
|
2150
|
-
* created when a pinned crash-recovery resume is never reclaimed (DES-523).
|
|
2151
|
-
* Makes escalation idempotent: a later heartbeat sweep must not create a second
|
|
2152
|
-
* decision for the same original task. We filter on the taskType marker
|
|
2153
|
-
* specifically (not any child) so ordinary delegation / completion follow-up
|
|
2154
|
-
* children of the original cannot suppress a needed decision, and nothing else
|
|
2155
|
-
* is mistaken for one.
|
|
2156
|
-
*/
|
|
2157
|
-
export async function hasNonTerminalRerouteDecisionChild(parentId: string): Promise<boolean> {
|
|
2158
|
-
const row = await getDbClient().get<Record<string, number>>(
|
|
2159
|
-
`SELECT 1 FROM agent_tasks
|
|
2160
|
-
WHERE parentTaskId = ?
|
|
2161
|
-
AND taskType = 'reroute-decision'
|
|
2162
|
-
AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
|
|
2163
|
-
LIMIT 1`,
|
|
2164
|
-
[parentId],
|
|
2165
|
-
);
|
|
2166
|
-
return row !== undefined && row !== null;
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
export async function updateTaskClaudeSessionId(
|
|
2170
|
-
taskId: string,
|
|
2171
|
-
claudeSessionId: string,
|
|
2172
|
-
provider?: ProviderName,
|
|
2173
|
-
providerMeta?: Record<string, unknown>,
|
|
2174
|
-
model?: string,
|
|
2175
|
-
harnessVariant?: string,
|
|
2176
|
-
harnessVariantMeta?: Record<string, unknown>,
|
|
2177
|
-
): Promise<AgentTask | null> {
|
|
2178
|
-
const setClauses = ["claudeSessionId = ?", "lastUpdatedAt = ?"];
|
|
2179
|
-
const params: (string | null)[] = [claudeSessionId, new Date().toISOString()];
|
|
2180
|
-
|
|
2181
|
-
if (provider !== undefined) {
|
|
2182
|
-
setClauses.push("provider = ?");
|
|
2183
|
-
params.push(provider);
|
|
2184
|
-
}
|
|
2185
|
-
if (providerMeta !== undefined) {
|
|
2186
|
-
setClauses.push("providerMeta = ?");
|
|
2187
|
-
params.push(JSON.stringify(providerMeta));
|
|
2188
|
-
}
|
|
2189
|
-
if (model !== undefined) {
|
|
2190
|
-
setClauses.push("model = ?");
|
|
2191
|
-
params.push(model);
|
|
2192
|
-
}
|
|
2193
|
-
if (harnessVariant !== undefined) {
|
|
2194
|
-
setClauses.push("harnessVariant = ?");
|
|
2195
|
-
params.push(harnessVariant);
|
|
2196
|
-
}
|
|
2197
|
-
if (harnessVariantMeta !== undefined) {
|
|
2198
|
-
setClauses.push("harnessVariantMeta = ?");
|
|
2199
|
-
params.push(JSON.stringify(harnessVariantMeta));
|
|
2200
|
-
}
|
|
2201
|
-
|
|
2202
|
-
params.push(taskId);
|
|
2203
|
-
|
|
2204
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
2205
|
-
`UPDATE agent_tasks SET ${setClauses.join(", ")} WHERE id = ? RETURNING *`,
|
|
2206
|
-
params,
|
|
2207
|
-
);
|
|
2208
|
-
return row ? rowToAgentTask(row) : null;
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
/**
|
|
2212
|
-
* Sets or clears a task's display title (session rename). Trims the input and
|
|
2213
|
-
* normalizes an empty string to NULL (clear). Deliberately does NOT touch
|
|
2214
|
-
* `lastUpdatedAt` — a rename is not activity, and the sessions sidebar sorts
|
|
2215
|
-
* on chain-wide max `lastUpdatedAt`, so bumping it here would reorder the list.
|
|
2216
|
-
*/
|
|
2217
|
-
export async function updateTaskTitle(
|
|
2218
|
-
taskId: string,
|
|
2219
|
-
title: string | null,
|
|
2220
|
-
): Promise<AgentTask | null> {
|
|
2221
|
-
const normalized = title === null ? null : title.trim() || null;
|
|
2222
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
2223
|
-
"UPDATE agent_tasks SET title = ? WHERE id = ? RETURNING *",
|
|
2224
|
-
[normalized, taskId],
|
|
2225
|
-
);
|
|
2226
|
-
return row ? rowToAgentTask(row) : null;
|
|
2227
|
-
}
|
|
2228
|
-
|
|
2229
|
-
export async function updateTaskVcs(
|
|
2230
|
-
taskId: string,
|
|
2231
|
-
vcs: {
|
|
2232
|
-
vcsProvider: "github" | "gitlab";
|
|
2233
|
-
vcsRepo: string;
|
|
2234
|
-
vcsNumber: number;
|
|
2235
|
-
vcsUrl: string;
|
|
2236
|
-
},
|
|
2237
|
-
): Promise<AgentTask | null> {
|
|
2238
|
-
const row = await getDbClient().transaction(async () => {
|
|
2239
|
-
const updated = await getDbClient().get<AgentTaskRow>(
|
|
2240
|
-
`UPDATE agent_tasks
|
|
2241
|
-
SET vcsProvider = ?, vcsRepo = ?, vcsNumber = ?, vcsUrl = ?, lastUpdatedAt = ?
|
|
2242
|
-
WHERE id = ? RETURNING *`,
|
|
2243
|
-
[vcs.vcsProvider, vcs.vcsRepo, vcs.vcsNumber, vcs.vcsUrl, new Date().toISOString(), taskId],
|
|
2244
|
-
);
|
|
2245
|
-
if (updated) {
|
|
2246
|
-
await reconcileTaskPullRequestAttachments(taskId, updated.agentId, [
|
|
2247
|
-
updated.output,
|
|
2248
|
-
vcs.vcsProvider === "github" ? vcs.vcsUrl : null,
|
|
2249
|
-
]);
|
|
2250
|
-
}
|
|
2251
|
-
return updated;
|
|
2252
|
-
});
|
|
2253
|
-
return row ? rowToAgentTask(row) : null;
|
|
2254
|
-
}
|
|
2255
|
-
|
|
2256
|
-
export async function getTasksByAgentId(agentId: string): Promise<AgentTask[]> {
|
|
2257
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
2258
|
-
"SELECT * FROM agent_tasks WHERE agentId = ? ORDER BY createdAt DESC",
|
|
2259
|
-
[agentId],
|
|
2260
|
-
);
|
|
2261
|
-
return rows.map(rowToAgentTask);
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
|
-
/**
|
|
2265
|
-
* Get the most recently updated in-progress task for an agent.
|
|
2266
|
-
* Used as a fallback when X-Source-Task-Id header is missing (e.g. lead agent HITL requests).
|
|
2267
|
-
*
|
|
2268
|
-
* Note: if agent has multiple in-progress tasks, returns the most recently
|
|
2269
|
-
* updated one. This is a best-effort fallback — the X-Source-Task-Id header
|
|
2270
|
-
* is the authoritative source when available.
|
|
2271
|
-
*/
|
|
2272
|
-
export async function getAgentCurrentTask(agentId: string): Promise<AgentTask | null> {
|
|
2273
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
2274
|
-
"SELECT * FROM agent_tasks WHERE agentId = ? AND status = 'in_progress' ORDER BY lastUpdatedAt DESC LIMIT 1",
|
|
2275
|
-
[agentId],
|
|
2276
|
-
);
|
|
2277
|
-
return row ? rowToAgentTask(row) : null;
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
export async function getTasksByStatus(status: AgentTaskStatus): Promise<AgentTask[]> {
|
|
2281
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
2282
|
-
"SELECT * FROM agent_tasks WHERE status = ? ORDER BY createdAt DESC",
|
|
2283
|
-
[status],
|
|
2284
|
-
);
|
|
2285
|
-
return rows.map(rowToAgentTask);
|
|
2286
|
-
}
|
|
2287
|
-
|
|
2288
|
-
/**
|
|
2289
|
-
* Find a task by VCS repo and issue/PR/MR number.
|
|
2290
|
-
* Returns the most recent non-terminal task for this VCS entity.
|
|
2291
|
-
*
|
|
2292
|
-
* Terminal exclusion MUST stay in lock-step with `TERMINAL_TASK_STATUSES`
|
|
2293
|
-
* in `src/types.ts`. SQL strings can't import a TS const — if you add a
|
|
2294
|
-
* new terminal status, grep for `NOT IN ('completed'` across this file.
|
|
2295
|
-
*/
|
|
2296
|
-
export async function findTaskByVcs(vcsRepo: string, vcsNumber: number): Promise<AgentTask | null> {
|
|
2297
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
2298
|
-
`SELECT * FROM agent_tasks
|
|
2299
|
-
WHERE vcsRepo = ? AND vcsNumber = ?
|
|
2300
|
-
AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
|
|
2301
|
-
ORDER BY createdAt DESC
|
|
2302
|
-
LIMIT 1`,
|
|
2303
|
-
[vcsRepo, vcsNumber],
|
|
2304
|
-
);
|
|
2305
|
-
return row ? rowToAgentTask(row) : null;
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
/** @deprecated Use findTaskByVcs instead */
|
|
2309
|
-
export const findTaskByGitHub = findTaskByVcs;
|
|
2310
|
-
|
|
2311
|
-
export interface TaskFilters {
|
|
2312
|
-
/** Single status (back-compat) OR array of statuses (multi-status filter). */
|
|
2313
|
-
status?: AgentTaskStatus | AgentTaskStatus[];
|
|
2314
|
-
agentId?: string;
|
|
2315
|
-
search?: string;
|
|
2316
|
-
// New filters
|
|
2317
|
-
unassigned?: boolean;
|
|
2318
|
-
offeredTo?: string;
|
|
2319
|
-
readyOnly?: boolean;
|
|
2320
|
-
taskType?: string;
|
|
2321
|
-
tags?: string[];
|
|
2322
|
-
scheduleId?: string;
|
|
2323
|
-
/** Exact canonical asset namespace. */
|
|
2324
|
-
key?: string;
|
|
2325
|
-
/** Canonical namespace subtree prefix. */
|
|
2326
|
-
keyPrefix?: string;
|
|
2327
|
-
/** Filter to tasks whose `source` is in this list. Empty/undefined → no filter. */
|
|
2328
|
-
source?: AgentTaskSource[];
|
|
2329
|
-
/** ISO 8601 timestamp; only return tasks where createdAt >= this. */
|
|
2330
|
-
createdAfter?: string;
|
|
2331
|
-
/** ISO 8601 timestamp; only return tasks where createdAt < this. */
|
|
2332
|
-
createdBefore?: string;
|
|
2333
|
-
/** Only return tasks requested by this canonical user. NULL rows are excluded. */
|
|
2334
|
-
requestedByUserId?: string;
|
|
2335
|
-
/** When set, restrict to rows where `requestedByUserId` IS NULL. Takes priority over `requestedByUserId`. */
|
|
2336
|
-
requestedByUserIdIsNull?: boolean;
|
|
2337
|
-
/** Sort list rows for either table freshness or timeline paging. */
|
|
2338
|
-
orderBy?: "lastUpdatedAt" | "createdAt";
|
|
2339
|
-
limit?: number;
|
|
2340
|
-
offset?: number;
|
|
2341
|
-
includeHeartbeat?: boolean;
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
export function getAllTasks(filters?: TaskFilters): Promise<AgentTask[]>;
|
|
2345
|
-
export function getAllTasks(
|
|
2346
|
-
filters: TaskFilters | undefined,
|
|
2347
|
-
opts: { slim: true },
|
|
2348
|
-
): Promise<AgentTaskSummary[]>;
|
|
2349
|
-
export async function getAllTasks(
|
|
2350
|
-
filters?: TaskFilters,
|
|
2351
|
-
opts?: { slim?: boolean },
|
|
2352
|
-
): Promise<AgentTask[] | AgentTaskSummary[]> {
|
|
2353
|
-
const conditions: string[] = [];
|
|
2354
|
-
const params: (string | AgentTaskStatus)[] = [];
|
|
2355
|
-
|
|
2356
|
-
if (filters?.status) {
|
|
2357
|
-
if (Array.isArray(filters.status)) {
|
|
2358
|
-
if (filters.status.length === 1) {
|
|
2359
|
-
conditions.push("status = ?");
|
|
2360
|
-
params.push(filters.status[0]!);
|
|
2361
|
-
} else if (filters.status.length > 1) {
|
|
2362
|
-
const placeholders = filters.status.map(() => "?").join(", ");
|
|
2363
|
-
conditions.push(`status IN (${placeholders})`);
|
|
2364
|
-
for (const s of filters.status) params.push(s);
|
|
2365
|
-
}
|
|
2366
|
-
} else {
|
|
2367
|
-
conditions.push("status = ?");
|
|
2368
|
-
params.push(filters.status);
|
|
2369
|
-
}
|
|
2370
|
-
}
|
|
2371
|
-
|
|
2372
|
-
if (filters?.agentId) {
|
|
2373
|
-
conditions.push("agentId = ?");
|
|
2374
|
-
params.push(filters.agentId);
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
if (filters?.search) {
|
|
2378
|
-
conditions.push("(task LIKE ? OR id LIKE ?)");
|
|
2379
|
-
params.push(`%${filters.search}%`, `%${filters.search}%`);
|
|
2380
|
-
}
|
|
2381
|
-
|
|
2382
|
-
// New filters
|
|
2383
|
-
if (filters?.unassigned) {
|
|
2384
|
-
conditions.push("(agentId IS NULL OR status = 'unassigned')");
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
if (filters?.offeredTo) {
|
|
2388
|
-
conditions.push("offeredTo = ?");
|
|
2389
|
-
params.push(filters.offeredTo);
|
|
2390
|
-
}
|
|
2391
|
-
|
|
2392
|
-
if (filters?.taskType) {
|
|
2393
|
-
conditions.push("taskType = ?");
|
|
2394
|
-
params.push(filters.taskType);
|
|
2395
|
-
}
|
|
2396
|
-
|
|
2397
|
-
if (filters?.tags && filters.tags.length > 0) {
|
|
2398
|
-
// Match any of the tags
|
|
2399
|
-
const tagConditions = filters.tags.map(() => "tags LIKE ?");
|
|
2400
|
-
conditions.push(`(${tagConditions.join(" OR ")})`);
|
|
2401
|
-
for (const tag of filters.tags) {
|
|
2402
|
-
params.push(`%"${tag}"%`);
|
|
2403
|
-
}
|
|
2404
|
-
}
|
|
2405
|
-
|
|
2406
|
-
if (filters?.scheduleId) {
|
|
2407
|
-
conditions.push("scheduleId = ?");
|
|
2408
|
-
params.push(filters.scheduleId);
|
|
2409
|
-
}
|
|
2410
|
-
|
|
2411
|
-
if (filters?.key) {
|
|
2412
|
-
conditions.push('"key" = ?');
|
|
2413
|
-
params.push(normalizeAssetKey(filters.key));
|
|
2414
|
-
} else if (filters?.keyPrefix) {
|
|
2415
|
-
conditions.push(`"key" LIKE ? ESCAPE '\\'`);
|
|
2416
|
-
params.push(assetKeyPrefixPattern(filters.keyPrefix));
|
|
2417
|
-
}
|
|
2418
|
-
|
|
2419
|
-
if (filters?.source && filters.source.length > 0) {
|
|
2420
|
-
const placeholders = filters.source.map(() => "?").join(", ");
|
|
2421
|
-
conditions.push(`source IN (${placeholders})`);
|
|
2422
|
-
for (const s of filters.source) params.push(s);
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
|
-
if (filters?.createdAfter) {
|
|
2426
|
-
conditions.push("createdAt >= ?");
|
|
2427
|
-
params.push(filters.createdAfter);
|
|
2428
|
-
}
|
|
2429
|
-
|
|
2430
|
-
if (filters?.createdBefore) {
|
|
2431
|
-
conditions.push("createdAt < ?");
|
|
2432
|
-
params.push(filters.createdBefore);
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
|
-
if (filters?.requestedByUserIdIsNull) {
|
|
2436
|
-
conditions.push("requestedByUserId IS NULL");
|
|
2437
|
-
} else if (filters?.requestedByUserId) {
|
|
2438
|
-
conditions.push("requestedByUserId = ?");
|
|
2439
|
-
params.push(filters.requestedByUserId);
|
|
2440
|
-
}
|
|
2441
|
-
|
|
2442
|
-
// Exclude system/heartbeat tasks by default. The flag is still called
|
|
2443
|
-
// `includeHeartbeat` for backward compat with existing API callers, but we
|
|
2444
|
-
// also gate boot-triage + heartbeat-checklist behind it since those are
|
|
2445
|
-
// equally noisy in the dashboard task list.
|
|
2446
|
-
if (!filters?.includeHeartbeat) {
|
|
2447
|
-
conditions.push(
|
|
2448
|
-
"(IFNULL(taskType, '') NOT IN ('heartbeat', 'heartbeat-checklist', 'boot-triage') AND tags NOT LIKE '%\"heartbeat\"%')",
|
|
2449
|
-
);
|
|
2450
|
-
}
|
|
2451
|
-
|
|
2452
|
-
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
2453
|
-
const limit = filters?.limit ?? 25;
|
|
2454
|
-
const offset = filters?.offset ?? 0;
|
|
2455
|
-
const orderBy =
|
|
2456
|
-
filters?.orderBy === "createdAt"
|
|
2457
|
-
? "createdAt DESC, rowid DESC"
|
|
2458
|
-
: "lastUpdatedAt DESC, priority DESC";
|
|
2459
|
-
const query = `SELECT agent_tasks.*,
|
|
2460
|
-
(SELECT SUM(totalCostUsd) FROM session_costs WHERE session_costs.taskId = agent_tasks.id) AS totalCostUsd
|
|
2461
|
-
FROM agent_tasks ${whereClause}
|
|
2462
|
-
ORDER BY ${orderBy} LIMIT ${limit} OFFSET ${offset}`;
|
|
2463
|
-
|
|
2464
|
-
const rows = await getDbClient().query<AgentTaskRow>(query, params);
|
|
2465
|
-
|
|
2466
|
-
// Filter for ready tasks (dependencies met) if requested. Both the full and
|
|
2467
|
-
// the slim row shapes carry `id` + `dependsOn`, so the same predicate works.
|
|
2468
|
-
const filterReady = async <T extends { id: string; dependsOn: string[] }>(
|
|
2469
|
-
items: T[],
|
|
2470
|
-
): Promise<T[]> => {
|
|
2471
|
-
const readyFlags = await Promise.all(
|
|
2472
|
-
items.map(async (task) => {
|
|
2473
|
-
if (!task.dependsOn || task.dependsOn.length === 0) return true;
|
|
2474
|
-
return (await checkDependencies(task.id)).ready;
|
|
2475
|
-
}),
|
|
2476
|
-
);
|
|
2477
|
-
return items.filter((_, i) => readyFlags[i]);
|
|
2478
|
-
};
|
|
2479
|
-
|
|
2480
|
-
if (opts?.slim) {
|
|
2481
|
-
let tasks = rows.map(rowToAgentTaskSummary);
|
|
2482
|
-
if (filters?.readyOnly) tasks = await filterReady(tasks);
|
|
2483
|
-
return tasks;
|
|
2484
|
-
}
|
|
2485
|
-
|
|
2486
|
-
let tasks = rows.map(rowToAgentTask);
|
|
2487
|
-
if (filters?.readyOnly) tasks = await filterReady(tasks);
|
|
2488
|
-
return tasks;
|
|
2489
|
-
}
|
|
2490
|
-
|
|
2491
|
-
/**
|
|
2492
|
-
* Get total count of tasks matching the given filters (ignoring limit).
|
|
2493
|
-
* Used alongside getAllTasks to display accurate total counts in UI.
|
|
2494
|
-
*/
|
|
2495
|
-
export async function getTasksCount(
|
|
2496
|
-
filters?: Omit<TaskFilters, "limit" | "readyOnly">,
|
|
2497
|
-
): Promise<number> {
|
|
2498
|
-
const conditions: string[] = [];
|
|
2499
|
-
const params: (string | AgentTaskStatus)[] = [];
|
|
2500
|
-
|
|
2501
|
-
if (filters?.status) {
|
|
2502
|
-
if (Array.isArray(filters.status)) {
|
|
2503
|
-
if (filters.status.length === 1) {
|
|
2504
|
-
conditions.push("status = ?");
|
|
2505
|
-
params.push(filters.status[0]!);
|
|
2506
|
-
} else if (filters.status.length > 1) {
|
|
2507
|
-
const placeholders = filters.status.map(() => "?").join(", ");
|
|
2508
|
-
conditions.push(`status IN (${placeholders})`);
|
|
2509
|
-
for (const s of filters.status) params.push(s);
|
|
2510
|
-
}
|
|
2511
|
-
} else {
|
|
2512
|
-
conditions.push("status = ?");
|
|
2513
|
-
params.push(filters.status);
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
2516
|
-
|
|
2517
|
-
if (filters?.agentId) {
|
|
2518
|
-
conditions.push("agentId = ?");
|
|
2519
|
-
params.push(filters.agentId);
|
|
2520
|
-
}
|
|
2521
|
-
|
|
2522
|
-
if (filters?.search) {
|
|
2523
|
-
conditions.push("(task LIKE ? OR id LIKE ?)");
|
|
2524
|
-
params.push(`%${filters.search}%`, `%${filters.search}%`);
|
|
2525
|
-
}
|
|
2526
|
-
|
|
2527
|
-
if (filters?.unassigned) {
|
|
2528
|
-
conditions.push("(agentId IS NULL OR status = 'unassigned')");
|
|
2529
|
-
}
|
|
2530
|
-
|
|
2531
|
-
if (filters?.offeredTo) {
|
|
2532
|
-
conditions.push("offeredTo = ?");
|
|
2533
|
-
params.push(filters.offeredTo);
|
|
2534
|
-
}
|
|
2535
|
-
|
|
2536
|
-
if (filters?.taskType) {
|
|
2537
|
-
conditions.push("taskType = ?");
|
|
2538
|
-
params.push(filters.taskType);
|
|
2539
|
-
}
|
|
2540
|
-
|
|
2541
|
-
if (filters?.tags && filters.tags.length > 0) {
|
|
2542
|
-
const tagConditions = filters.tags.map(() => "tags LIKE ?");
|
|
2543
|
-
conditions.push(`(${tagConditions.join(" OR ")})`);
|
|
2544
|
-
for (const tag of filters.tags) {
|
|
2545
|
-
params.push(`%"${tag}"%`);
|
|
2546
|
-
}
|
|
2547
|
-
}
|
|
2548
|
-
|
|
2549
|
-
if (filters?.scheduleId) {
|
|
2550
|
-
conditions.push("scheduleId = ?");
|
|
2551
|
-
params.push(filters.scheduleId);
|
|
2552
|
-
}
|
|
2553
|
-
|
|
2554
|
-
if (filters?.key) {
|
|
2555
|
-
conditions.push('"key" = ?');
|
|
2556
|
-
params.push(normalizeAssetKey(filters.key));
|
|
2557
|
-
} else if (filters?.keyPrefix) {
|
|
2558
|
-
conditions.push(`"key" LIKE ? ESCAPE '\\'`);
|
|
2559
|
-
params.push(assetKeyPrefixPattern(filters.keyPrefix));
|
|
2560
|
-
}
|
|
2561
|
-
|
|
2562
|
-
if (filters?.source && filters.source.length > 0) {
|
|
2563
|
-
const placeholders = filters.source.map(() => "?").join(", ");
|
|
2564
|
-
conditions.push(`source IN (${placeholders})`);
|
|
2565
|
-
for (const s of filters.source) params.push(s);
|
|
2566
|
-
}
|
|
2567
|
-
|
|
2568
|
-
if (filters?.createdAfter) {
|
|
2569
|
-
conditions.push("createdAt >= ?");
|
|
2570
|
-
params.push(filters.createdAfter);
|
|
2571
|
-
}
|
|
2572
|
-
|
|
2573
|
-
if (filters?.createdBefore) {
|
|
2574
|
-
conditions.push("createdAt < ?");
|
|
2575
|
-
params.push(filters.createdBefore);
|
|
2576
|
-
}
|
|
2577
|
-
|
|
2578
|
-
if (filters?.requestedByUserIdIsNull) {
|
|
2579
|
-
conditions.push("requestedByUserId IS NULL");
|
|
2580
|
-
} else if (filters?.requestedByUserId) {
|
|
2581
|
-
conditions.push("requestedByUserId = ?");
|
|
2582
|
-
params.push(filters.requestedByUserId);
|
|
2583
|
-
}
|
|
2584
|
-
|
|
2585
|
-
// Exclude system/heartbeat tasks by default. The flag is still called
|
|
2586
|
-
// `includeHeartbeat` for backward compat with existing API callers, but we
|
|
2587
|
-
// also gate boot-triage + heartbeat-checklist behind it since those are
|
|
2588
|
-
// equally noisy in the dashboard task list.
|
|
2589
|
-
if (!filters?.includeHeartbeat) {
|
|
2590
|
-
conditions.push(
|
|
2591
|
-
"(IFNULL(taskType, '') NOT IN ('heartbeat', 'heartbeat-checklist', 'boot-triage') AND tags NOT LIKE '%\"heartbeat\"%')",
|
|
2592
|
-
);
|
|
2593
|
-
}
|
|
2594
|
-
|
|
2595
|
-
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
2596
|
-
const query = `SELECT COUNT(*) as count FROM agent_tasks ${whereClause}`;
|
|
2597
|
-
|
|
2598
|
-
const result = await getDbClient().get<{ count: number }>(query, params);
|
|
2599
|
-
|
|
2600
|
-
return result?.count ?? 0;
|
|
2601
|
-
}
|
|
2602
|
-
|
|
2603
|
-
/**
|
|
2604
|
-
* Get task statistics (counts by status) without any limit.
|
|
2605
|
-
* This is more efficient than fetching all tasks for stats purposes.
|
|
2606
|
-
*/
|
|
2607
|
-
export async function getTaskStats(): Promise<{
|
|
2608
|
-
total: number;
|
|
2609
|
-
unassigned: number;
|
|
2610
|
-
offered: number;
|
|
2611
|
-
reviewing: number;
|
|
2612
|
-
pending: number;
|
|
2613
|
-
in_progress: number;
|
|
2614
|
-
paused: number;
|
|
2615
|
-
completed: number;
|
|
2616
|
-
failed: number;
|
|
2617
|
-
}> {
|
|
2618
|
-
const row = await getDbClient().get<{
|
|
2619
|
-
total: number;
|
|
2620
|
-
unassigned: number;
|
|
2621
|
-
offered: number;
|
|
2622
|
-
reviewing: number;
|
|
2623
|
-
pending: number;
|
|
2624
|
-
in_progress: number;
|
|
2625
|
-
paused: number;
|
|
2626
|
-
completed: number;
|
|
2627
|
-
failed: number;
|
|
2628
|
-
}>(
|
|
2629
|
-
`SELECT
|
|
2630
|
-
COUNT(*) as total,
|
|
2631
|
-
SUM(CASE WHEN status = 'unassigned' THEN 1 ELSE 0 END) as unassigned,
|
|
2632
|
-
SUM(CASE WHEN status = 'offered' THEN 1 ELSE 0 END) as offered,
|
|
2633
|
-
SUM(CASE WHEN status = 'reviewing' THEN 1 ELSE 0 END) as reviewing,
|
|
2634
|
-
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
2635
|
-
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
|
2636
|
-
SUM(CASE WHEN status = 'paused' THEN 1 ELSE 0 END) as paused,
|
|
2637
|
-
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
2638
|
-
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
|
|
2639
|
-
FROM agent_tasks`,
|
|
2640
|
-
);
|
|
2641
|
-
|
|
2642
|
-
return (
|
|
2643
|
-
row ?? {
|
|
2644
|
-
total: 0,
|
|
2645
|
-
unassigned: 0,
|
|
2646
|
-
offered: 0,
|
|
2647
|
-
reviewing: 0,
|
|
2648
|
-
pending: 0,
|
|
2649
|
-
in_progress: 0,
|
|
2650
|
-
paused: 0,
|
|
2651
|
-
completed: 0,
|
|
2652
|
-
failed: 0,
|
|
2653
|
-
}
|
|
2654
|
-
);
|
|
2655
|
-
}
|
|
2656
|
-
|
|
2657
|
-
export async function getCompletedSlackTasks(): Promise<AgentTask[]> {
|
|
2658
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
2659
|
-
`SELECT * FROM agent_tasks
|
|
2660
|
-
WHERE slackChannelId IS NOT NULL
|
|
2661
|
-
AND status IN ('completed', 'failed')
|
|
2662
|
-
ORDER BY lastUpdatedAt DESC
|
|
2663
|
-
LIMIT 200`,
|
|
2664
|
-
);
|
|
2665
|
-
return rows.map(rowToAgentTask);
|
|
2666
|
-
}
|
|
2667
|
-
|
|
2668
|
-
/**
|
|
2669
|
-
* Return terminal Slack-rooted tasks whose durable relay obligation is pending.
|
|
2670
|
-
* The obligation is inserted by a DB trigger in the same transaction as the
|
|
2671
|
-
* terminal status transition, so a process restart cannot lose the send.
|
|
2672
|
-
*/
|
|
2673
|
-
export async function getPendingSlackRelayTasks(): Promise<AgentTask[]> {
|
|
2674
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
2675
|
-
`SELECT task.* FROM slack_relay_obligations obligation
|
|
2676
|
-
JOIN agent_tasks task ON task.id = obligation.task_id
|
|
2677
|
-
WHERE obligation.delivered_at IS NULL
|
|
2678
|
-
AND task.status IN ('completed', 'failed', 'cancelled')
|
|
2679
|
-
ORDER BY obligation.last_attempt_at IS NOT NULL,
|
|
2680
|
-
obligation.last_attempt_at ASC,
|
|
2681
|
-
obligation.created_at ASC
|
|
2682
|
-
LIMIT 200`,
|
|
2683
|
-
);
|
|
2684
|
-
return rows.map(rowToAgentTask);
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
/** Rotate attempted rows behind fresh obligations so poison rows cannot starve the queue. */
|
|
2688
|
-
export async function markSlackRelayAttempted(taskId: string): Promise<boolean> {
|
|
2689
|
-
const now = new Date().toISOString();
|
|
2690
|
-
const result = await getDbClient().run(
|
|
2691
|
-
`UPDATE slack_relay_obligations
|
|
2692
|
-
SET attempt_count = attempt_count + 1,
|
|
2693
|
-
last_attempt_at = ?,
|
|
2694
|
-
updated_at = ?
|
|
2695
|
-
WHERE task_id = ? AND delivered_at IS NULL`,
|
|
2696
|
-
[now, now, taskId],
|
|
2697
|
-
);
|
|
2698
|
-
return result.changes > 0;
|
|
2699
|
-
}
|
|
2700
|
-
|
|
2701
|
-
/** Mark a relay obligation delivered only after Slack accepted the final result. */
|
|
2702
|
-
export async function markSlackRelayDelivered(taskId: string): Promise<boolean> {
|
|
2703
|
-
const now = new Date().toISOString();
|
|
2704
|
-
const result = await getDbClient().run(
|
|
2705
|
-
`UPDATE slack_relay_obligations
|
|
2706
|
-
SET delivered_at = ?, updated_at = ?
|
|
2707
|
-
WHERE task_id = ? AND delivered_at IS NULL`,
|
|
2708
|
-
[now, now, taskId],
|
|
2709
|
-
);
|
|
2710
|
-
return result.changes > 0;
|
|
2711
|
-
}
|
|
2712
|
-
|
|
2713
|
-
/** Discharge obligations already fulfilled by renderer v2's durable outcome record. */
|
|
2714
|
-
export async function markFinalizedSlackRelaysDelivered(): Promise<number> {
|
|
2715
|
-
const now = new Date().toISOString();
|
|
2716
|
-
const result = await getDbClient().run(
|
|
2717
|
-
`UPDATE slack_relay_obligations AS obligation
|
|
2718
|
-
SET delivered_at = ?, updated_at = ?
|
|
2719
|
-
WHERE obligation.delivered_at IS NULL
|
|
2720
|
-
AND EXISTS (
|
|
2721
|
-
SELECT 1 FROM slack_messages message
|
|
2722
|
-
WHERE message.kind = 'outcome'
|
|
2723
|
-
AND message.task_id = obligation.task_id
|
|
2724
|
-
AND message.finalized_at IS NOT NULL
|
|
2725
|
-
)`,
|
|
2726
|
-
[now, now],
|
|
2727
|
-
);
|
|
2728
|
-
return result.changes;
|
|
2729
|
-
}
|
|
2730
|
-
|
|
2731
|
-
/**
|
|
2732
|
-
* Get tasks that were recently finished (completed/failed) by workers (non-lead agents).
|
|
2733
|
-
* Used by leads to know when workers complete tasks.
|
|
2734
|
-
*/
|
|
2735
|
-
export async function getRecentlyFinishedWorkerTasks(): Promise<AgentTask[]> {
|
|
2736
|
-
// Query for finished tasks that haven't been notified yet
|
|
2737
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
2738
|
-
`SELECT t.* FROM agent_tasks t
|
|
2739
|
-
LEFT JOIN agents a ON t.agentId = a.id
|
|
2740
|
-
WHERE t.status IN ('completed', 'failed')
|
|
2741
|
-
AND t.finishedAt IS NOT NULL
|
|
2742
|
-
AND t.notifiedAt IS NULL
|
|
2743
|
-
AND (a.isLead = 0 OR a.isLead IS NULL)
|
|
2744
|
-
ORDER BY t.finishedAt DESC LIMIT 50`,
|
|
2745
|
-
);
|
|
2746
|
-
return rows.map(rowToAgentTask);
|
|
2747
|
-
}
|
|
2748
|
-
|
|
2749
|
-
/**
|
|
2750
|
-
* Atomically mark finished tasks as notified.
|
|
2751
|
-
* Sets notifiedAt timestamp to prevent returning them in future polls.
|
|
2752
|
-
*/
|
|
2753
|
-
export async function markTasksNotified(taskIds: string[]): Promise<number> {
|
|
2754
|
-
if (taskIds.length === 0) return 0;
|
|
2755
|
-
|
|
2756
|
-
const now = new Date().toISOString();
|
|
2757
|
-
const placeholders = taskIds.map(() => "?").join(",");
|
|
2758
|
-
|
|
2759
|
-
const result = await getDbClient().run(
|
|
2760
|
-
`UPDATE agent_tasks SET notifiedAt = ?
|
|
2761
|
-
WHERE id IN (${placeholders}) AND notifiedAt IS NULL`,
|
|
2762
|
-
[now, ...taskIds],
|
|
2763
|
-
);
|
|
2764
|
-
|
|
2765
|
-
return result.changes;
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2768
|
-
/**
|
|
2769
|
-
* Reset notifiedAt for tasks, allowing them to be re-delivered on next poll.
|
|
2770
|
-
* Used when a trigger was consumed but the session that should process it failed.
|
|
2771
|
-
* This prevents permanent notification loss from the mark-before-process race.
|
|
2772
|
-
*/
|
|
2773
|
-
export async function resetTasksNotified(taskIds: string[]): Promise<number> {
|
|
2774
|
-
if (taskIds.length === 0) return 0;
|
|
2775
|
-
|
|
2776
|
-
const placeholders = taskIds.map(() => "?").join(",");
|
|
2777
|
-
|
|
2778
|
-
const result = await getDbClient().run(
|
|
2779
|
-
`UPDATE agent_tasks SET notifiedAt = NULL
|
|
2780
|
-
WHERE id IN (${placeholders}) AND notifiedAt IS NOT NULL`,
|
|
2781
|
-
taskIds,
|
|
2782
|
-
);
|
|
2783
|
-
|
|
2784
|
-
return result.changes;
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
export async function getInProgressSlackTasks(): Promise<AgentTask[]> {
|
|
2788
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
2789
|
-
`SELECT * FROM agent_tasks
|
|
2790
|
-
WHERE slackChannelId IS NOT NULL
|
|
2791
|
-
AND status = 'in_progress'
|
|
2792
|
-
ORDER BY lastUpdatedAt DESC
|
|
2793
|
-
LIMIT 200`,
|
|
2794
|
-
);
|
|
2795
|
-
return rows.map(rowToAgentTask);
|
|
2796
|
-
}
|
|
2797
|
-
|
|
2798
|
-
export async function getSlackTasksMissingTree(): Promise<AgentTask[]> {
|
|
2799
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
2800
|
-
`SELECT task.* FROM agent_tasks task
|
|
2801
|
-
JOIN slack_render_v2_state state ON state.id = 1
|
|
2802
|
-
WHERE task.source = 'slack'
|
|
2803
|
-
AND task.slackChannelId IS NOT NULL
|
|
2804
|
-
AND task.slackThreadTs IS NOT NULL
|
|
2805
|
-
AND task.createdAt >= state.activated_at
|
|
2806
|
-
AND task.status NOT IN ('backlog', 'unassigned', 'superseded')
|
|
2807
|
-
AND NOT EXISTS (
|
|
2808
|
-
SELECT 1 FROM agent_tasks earlier
|
|
2809
|
-
WHERE earlier.source = 'slack'
|
|
2810
|
-
AND earlier.slackChannelId = task.slackChannelId
|
|
2811
|
-
AND earlier.slackThreadTs = task.slackThreadTs
|
|
2812
|
-
AND earlier.createdAt < state.activated_at
|
|
2813
|
-
)
|
|
2814
|
-
AND NOT EXISTS (
|
|
2815
|
-
SELECT 1 FROM slack_messages tree
|
|
2816
|
-
WHERE tree.kind = 'tree'
|
|
2817
|
-
AND tree.channel_id = task.slackChannelId
|
|
2818
|
-
AND tree.thread_ts = task.slackThreadTs
|
|
2819
|
-
)
|
|
2820
|
-
ORDER BY task.lastUpdatedAt DESC
|
|
2821
|
-
LIMIT 200`,
|
|
2822
|
-
);
|
|
2823
|
-
return rows.map(rowToAgentTask);
|
|
2824
|
-
}
|
|
2825
|
-
|
|
2826
|
-
/**
|
|
2827
|
-
* Return sibling tasks for a given cross-ingress context key, optionally
|
|
2828
|
-
* filtered by status. The returned shape mirrors getInProgressSlackTasks for
|
|
2829
|
-
* consistency; callers can narrow further in TypeScript.
|
|
2830
|
-
*
|
|
2831
|
-
* See src/tasks/context-key.ts for the key schema.
|
|
2832
|
-
*/
|
|
2833
|
-
export async function getInProgressTasksByContextKey(
|
|
2834
|
-
contextKey: string,
|
|
2835
|
-
statuses: AgentTaskStatus[] = ["pending", "in_progress", "offered", "paused"],
|
|
2836
|
-
): Promise<AgentTask[]> {
|
|
2837
|
-
if (!contextKey || statuses.length === 0) return [];
|
|
2838
|
-
const placeholders = statuses.map(() => "?").join(",");
|
|
2839
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
2840
|
-
`SELECT * FROM agent_tasks
|
|
2841
|
-
WHERE contextKey = ?
|
|
2842
|
-
AND status IN (${placeholders})
|
|
2843
|
-
ORDER BY lastUpdatedAt DESC
|
|
2844
|
-
LIMIT 200`,
|
|
2845
|
-
[contextKey, ...statuses],
|
|
2846
|
-
);
|
|
2847
|
-
return rows.map(rowToAgentTask);
|
|
2848
|
-
}
|
|
2849
|
-
|
|
2850
|
-
export type ExistingTrackerContextWorkReason = "active_task" | "linked_open_pr";
|
|
2851
|
-
|
|
2852
|
-
export type ExistingTrackerContextWork = {
|
|
2853
|
-
task: AgentTask;
|
|
2854
|
-
reason: ExistingTrackerContextWorkReason;
|
|
2855
|
-
};
|
|
2856
|
-
|
|
2857
|
-
const LINEAR_TRACKER_CONTEXT_KEY_PREFIX = "task:trackers:linear:";
|
|
2858
|
-
|
|
2859
|
-
function isLinearTrackerContextKey(contextKey: string | null | undefined): contextKey is string {
|
|
2860
|
-
return !!contextKey && contextKey.startsWith(LINEAR_TRACKER_CONTEXT_KEY_PREFIX);
|
|
2861
|
-
}
|
|
2862
|
-
|
|
2863
|
-
/**
|
|
2864
|
-
* Return existing work for a Linear tracker key before creating another task.
|
|
2865
|
-
*
|
|
2866
|
-
* Active means any non-terminal task. A completed task with persisted VCS PR/MR
|
|
2867
|
-
* metadata is also treated as existing work because the task can be complete
|
|
2868
|
-
* while the PR is still awaiting review/merge.
|
|
2869
|
-
*/
|
|
2870
|
-
export async function findExistingLinearTrackerContextWork(
|
|
2871
|
-
contextKey: string | null | undefined,
|
|
2872
|
-
): Promise<ExistingTrackerContextWork | null> {
|
|
2873
|
-
if (!isLinearTrackerContextKey(contextKey)) return null;
|
|
2874
|
-
|
|
2875
|
-
const activeRow = await getDbClient().get<AgentTaskRow>(
|
|
2876
|
-
`SELECT * FROM agent_tasks
|
|
2877
|
-
WHERE contextKey = ?
|
|
2878
|
-
AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
|
|
2879
|
-
ORDER BY lastUpdatedAt DESC
|
|
2880
|
-
LIMIT 1`,
|
|
2881
|
-
[contextKey],
|
|
2882
|
-
);
|
|
2883
|
-
if (activeRow) {
|
|
2884
|
-
return { task: rowToAgentTask(activeRow), reason: "active_task" };
|
|
2885
|
-
}
|
|
2886
|
-
|
|
2887
|
-
const linkedPrRow = await getDbClient().get<AgentTaskRow>(
|
|
2888
|
-
`SELECT * FROM agent_tasks
|
|
2889
|
-
WHERE contextKey = ?
|
|
2890
|
-
AND status = 'completed'
|
|
2891
|
-
AND vcsProvider IS NOT NULL
|
|
2892
|
-
AND vcsRepo IS NOT NULL
|
|
2893
|
-
AND vcsNumber IS NOT NULL
|
|
2894
|
-
AND vcsUrl IS NOT NULL
|
|
2895
|
-
ORDER BY lastUpdatedAt DESC
|
|
2896
|
-
LIMIT 1`,
|
|
2897
|
-
[contextKey],
|
|
2898
|
-
);
|
|
2899
|
-
if (linkedPrRow) {
|
|
2900
|
-
return { task: rowToAgentTask(linkedPrRow), reason: "linked_open_pr" };
|
|
2901
|
-
}
|
|
2902
|
-
|
|
2903
|
-
return null;
|
|
2904
|
-
}
|
|
2905
|
-
|
|
2906
|
-
export async function getLatestTaskByContextKey(contextKey: string): Promise<AgentTask | null> {
|
|
2907
|
-
if (!contextKey) return null;
|
|
2908
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
2909
|
-
`SELECT * FROM agent_tasks
|
|
2910
|
-
WHERE contextKey = ?
|
|
2911
|
-
ORDER BY createdAt DESC
|
|
2912
|
-
LIMIT 1`,
|
|
2913
|
-
[contextKey],
|
|
2914
|
-
);
|
|
2915
|
-
return row ? rowToAgentTask(row) : null;
|
|
2916
|
-
}
|
|
2917
|
-
|
|
2918
|
-
export async function getLatestScriptRunStepTaskByContextKey(
|
|
2919
|
-
contextKey: string,
|
|
2920
|
-
): Promise<AgentTask | null> {
|
|
2921
|
-
if (!contextKey) return null;
|
|
2922
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
2923
|
-
`SELECT * FROM agent_tasks
|
|
2924
|
-
WHERE contextKey = ?
|
|
2925
|
-
AND taskType = 'script-run-step'
|
|
2926
|
-
ORDER BY createdAt DESC, rowid DESC
|
|
2927
|
-
LIMIT 1`,
|
|
2928
|
-
[contextKey],
|
|
2929
|
-
);
|
|
2930
|
-
return row ? rowToAgentTask(row) : null;
|
|
2931
|
-
}
|
|
2932
|
-
|
|
2933
|
-
/**
|
|
2934
|
-
* Find the most recent agent associated with a specific Slack thread.
|
|
2935
|
-
* No status filter — returns the last agent that touched this thread regardless of task state.
|
|
2936
|
-
* This is intentional: follow-up messages should route to the same agent even after task completion.
|
|
2937
|
-
* Callers (e.g. assistant.ts) apply their own status checks (e.g. agent.status !== "offline").
|
|
2938
|
-
*/
|
|
2939
|
-
export async function getAgentWorkingOnThread(
|
|
2940
|
-
channelId: string,
|
|
2941
|
-
threadTs: string,
|
|
2942
|
-
): Promise<Agent | null> {
|
|
2943
|
-
const taskRow = await getDbClient().get<AgentTaskRow>(
|
|
2944
|
-
`SELECT * FROM agent_tasks
|
|
2945
|
-
WHERE source = 'slack'
|
|
2946
|
-
AND slackChannelId = ?
|
|
2947
|
-
AND slackThreadTs = ?
|
|
2948
|
-
ORDER BY createdAt DESC
|
|
2949
|
-
LIMIT 1`,
|
|
2950
|
-
[channelId, threadTs],
|
|
2951
|
-
);
|
|
2952
|
-
|
|
2953
|
-
if (taskRow?.agentId) return getAgentById(taskRow.agentId);
|
|
2954
|
-
|
|
2955
|
-
return null;
|
|
2956
|
-
}
|
|
2957
|
-
|
|
2958
|
-
/**
|
|
2959
|
-
* Find the latest active (in_progress or pending) task in a specific Slack thread.
|
|
2960
|
-
* Used for dependency chaining in additive Slack buffer.
|
|
2961
|
-
*/
|
|
2962
|
-
export async function getLatestActiveTaskInThread(
|
|
2963
|
-
channelId: string,
|
|
2964
|
-
threadTs: string,
|
|
2965
|
-
): Promise<AgentTask | null> {
|
|
2966
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
2967
|
-
`SELECT * FROM agent_tasks
|
|
2968
|
-
WHERE source = 'slack'
|
|
2969
|
-
AND slackChannelId = ?
|
|
2970
|
-
AND slackThreadTs = ?
|
|
2971
|
-
AND status IN ('in_progress', 'pending')
|
|
2972
|
-
ORDER BY createdAt DESC, rowid DESC
|
|
2973
|
-
LIMIT 1`,
|
|
2974
|
-
[channelId, threadTs],
|
|
2975
|
-
);
|
|
2976
|
-
|
|
2977
|
-
return row ? rowToAgentTask(row) : null;
|
|
2978
|
-
}
|
|
2979
|
-
|
|
2980
|
-
/**
|
|
2981
|
-
* Find the latest task assigned to a lead agent in a specific Slack thread.
|
|
2982
|
-
* Source is deliberately restricted to Slack ingress so inherited worker-task
|
|
2983
|
-
* metadata cannot become the thread's steering target.
|
|
2984
|
-
*/
|
|
2985
|
-
export async function getLatestLeadTaskInThread(
|
|
2986
|
-
channelId: string,
|
|
2987
|
-
threadTs: string,
|
|
2988
|
-
): Promise<AgentTask | null> {
|
|
2989
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
2990
|
-
`SELECT t.*
|
|
2991
|
-
FROM agent_tasks t
|
|
2992
|
-
JOIN agents a ON a.id = t.agentId
|
|
2993
|
-
WHERE t.source = 'slack'
|
|
2994
|
-
AND t.slackChannelId = ?
|
|
2995
|
-
AND t.slackThreadTs = ?
|
|
2996
|
-
AND a.isLead = 1
|
|
2997
|
-
ORDER BY t.createdAt DESC, t.rowid DESC
|
|
2998
|
-
LIMIT 1`,
|
|
2999
|
-
[channelId, threadTs],
|
|
3000
|
-
);
|
|
3001
|
-
|
|
3002
|
-
return row ? rowToAgentTask(row) : null;
|
|
3003
|
-
}
|
|
3004
|
-
|
|
3005
|
-
/**
|
|
3006
|
-
* Find the most recent task in a Slack thread, regardless of source or status.
|
|
3007
|
-
* Unlike getAgentWorkingOnThread (which filters source='slack'), this finds ALL tasks
|
|
3008
|
-
* including worker tasks that inherited Slack metadata via parentTaskId.
|
|
3009
|
-
*/
|
|
3010
|
-
export async function getMostRecentTaskInThread(
|
|
3011
|
-
channelId: string,
|
|
3012
|
-
threadTs: string,
|
|
3013
|
-
): Promise<AgentTask | null> {
|
|
3014
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
3015
|
-
`SELECT * FROM agent_tasks
|
|
3016
|
-
WHERE slackChannelId = ?
|
|
3017
|
-
AND slackThreadTs = ?
|
|
3018
|
-
ORDER BY createdAt DESC
|
|
3019
|
-
LIMIT 1`,
|
|
3020
|
-
[channelId, threadTs],
|
|
3021
|
-
);
|
|
3022
|
-
return row ? rowToAgentTask(row) : null;
|
|
3023
|
-
}
|
|
3024
|
-
|
|
3025
|
-
export async function findCompletedTaskInThread(
|
|
3026
|
-
channelId: string,
|
|
3027
|
-
threadTs: string,
|
|
3028
|
-
windowMinutes: number,
|
|
3029
|
-
): Promise<AgentTask | null> {
|
|
3030
|
-
const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
|
|
3031
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
3032
|
-
`SELECT * FROM agent_tasks
|
|
3033
|
-
WHERE slackChannelId = ?
|
|
3034
|
-
AND slackThreadTs = ?
|
|
3035
|
-
AND status = 'completed'
|
|
3036
|
-
AND lastUpdatedAt > ?
|
|
3037
|
-
ORDER BY lastUpdatedAt DESC
|
|
3038
|
-
LIMIT 1`,
|
|
3039
|
-
[channelId, threadTs, since],
|
|
3040
|
-
);
|
|
3041
|
-
return row ? rowToAgentTask(row) : null;
|
|
3042
|
-
}
|
|
3043
|
-
|
|
3044
|
-
/**
|
|
3045
|
-
* Find the most recent CANCELLED task in a Slack thread. Used by the
|
|
3046
|
-
* follow-up re-delegation guard so a cancellation (worker SIGTERM,
|
|
3047
|
-
* runner-side abort, swarm-events tool-loop abort) doesn't permanently
|
|
3048
|
-
* jam re-dispatch when an earlier sibling task in the same thread also
|
|
3049
|
-
* completed.
|
|
3050
|
-
*
|
|
3051
|
-
* Matches both:
|
|
3052
|
-
* - `status = 'cancelled'` (the canonical terminal state from cancelTask)
|
|
3053
|
-
* - `status = 'failed'` with a failureReason that starts with "cancelled"
|
|
3054
|
-
* or "exit 130" or contains "cancelled" (the codex-adapter abort path
|
|
3055
|
-
* emits `failureReason: "cancelled"` and exits 130).
|
|
3056
|
-
*/
|
|
3057
|
-
export async function findRecentCancelledTaskInThread(
|
|
3058
|
-
channelId: string,
|
|
3059
|
-
threadTs: string,
|
|
3060
|
-
windowMinutes: number,
|
|
3061
|
-
): Promise<AgentTask | null> {
|
|
3062
|
-
const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
|
|
3063
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
3064
|
-
`SELECT * FROM agent_tasks
|
|
3065
|
-
WHERE slackChannelId = ?
|
|
3066
|
-
AND slackThreadTs = ?
|
|
3067
|
-
AND lastUpdatedAt > ?
|
|
3068
|
-
AND (
|
|
3069
|
-
status = 'cancelled'
|
|
3070
|
-
OR (
|
|
3071
|
-
status = 'failed'
|
|
3072
|
-
AND failureReason IS NOT NULL
|
|
3073
|
-
AND (
|
|
3074
|
-
failureReason LIKE 'cancelled%'
|
|
3075
|
-
OR failureReason LIKE 'exit 130%'
|
|
3076
|
-
OR failureReason LIKE '%cancelled%'
|
|
3077
|
-
)
|
|
3078
|
-
)
|
|
3079
|
-
)
|
|
3080
|
-
ORDER BY lastUpdatedAt DESC
|
|
3081
|
-
LIMIT 1`,
|
|
3082
|
-
[channelId, threadTs, since],
|
|
3083
|
-
);
|
|
3084
|
-
return row ? rowToAgentTask(row) : null;
|
|
3085
|
-
}
|
|
3086
|
-
|
|
3087
|
-
export async function completeTask(id: string, output?: string): Promise<AgentTask | null> {
|
|
3088
|
-
const oldTask = await getTaskById(id);
|
|
3089
|
-
if (!oldTask) return null;
|
|
3090
|
-
|
|
3091
|
-
// Idempotency guard: don't re-complete a task already in a terminal state.
|
|
3092
|
-
// Mirrors cancelTask. Prevents duplicate task.completed events, duplicate
|
|
3093
|
-
// log entries, and duplicate follow-up tasks when multiple sessions race.
|
|
3094
|
-
if (isTerminalTaskStatus(oldTask.status)) {
|
|
3095
|
-
return null;
|
|
3096
|
-
}
|
|
3097
|
-
|
|
3098
|
-
const row = await getDbClient().transaction(async () => {
|
|
3099
|
-
const finishedAt = new Date().toISOString();
|
|
3100
|
-
// The status predicate re-checks the idempotency guard atomically: the
|
|
3101
|
-
// await between the guard read above and this write lets a racing
|
|
3102
|
-
// terminal transition (e.g. heartbeat failTask) land first.
|
|
3103
|
-
let completed = await getDbClient().get<AgentTaskRow>(
|
|
3104
|
-
`UPDATE agent_tasks SET status = ?, finishedAt = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
3105
|
-
WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
|
|
3106
|
-
["completed", finishedAt, id],
|
|
3107
|
-
);
|
|
3108
|
-
if (!completed) return null;
|
|
3109
|
-
|
|
3110
|
-
if (output) {
|
|
3111
|
-
completed = await getDbClient().get<AgentTaskRow>(
|
|
3112
|
-
"UPDATE agent_tasks SET output = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
|
|
3113
|
-
[scrubSecrets(output), id],
|
|
3114
|
-
);
|
|
3115
|
-
}
|
|
3116
|
-
if (completed) {
|
|
3117
|
-
await reconcileTaskPullRequestAttachments(id, completed.agentId, [
|
|
3118
|
-
completed.output,
|
|
3119
|
-
completed.vcsProvider === "github" ? completed.vcsUrl : null,
|
|
3120
|
-
]);
|
|
3121
|
-
}
|
|
3122
|
-
return completed;
|
|
3123
|
-
});
|
|
3124
|
-
if (!row) return null;
|
|
3125
|
-
|
|
3126
|
-
if (row && oldTask) {
|
|
3127
|
-
emitTaskLifecycleTelemetryAfterCommit(
|
|
3128
|
-
"completed",
|
|
3129
|
-
{
|
|
3130
|
-
taskId: id,
|
|
3131
|
-
source: oldTask.source,
|
|
3132
|
-
...taskContextForTelemetry(oldTask),
|
|
3133
|
-
agentId: row.agentId ?? undefined,
|
|
3134
|
-
durationMs: row.createdAt ? Date.now() - new Date(row.createdAt).getTime() : undefined,
|
|
3135
|
-
},
|
|
3136
|
-
(task) => task?.status === "completed",
|
|
3137
|
-
);
|
|
3138
|
-
|
|
3139
|
-
try {
|
|
3140
|
-
await createLogEntry({
|
|
3141
|
-
eventType: "task_status_change",
|
|
3142
|
-
taskId: id,
|
|
3143
|
-
agentId: row.agentId ?? undefined,
|
|
3144
|
-
oldValue: oldTask.status,
|
|
3145
|
-
newValue: "completed",
|
|
3146
|
-
});
|
|
3147
|
-
} catch {}
|
|
3148
|
-
getDbClient().afterCommit(() => {
|
|
3149
|
-
import("../workflows/event-bus")
|
|
3150
|
-
.then(({ workflowEventBus }) => {
|
|
3151
|
-
workflowEventBus.emit("task.completed", {
|
|
3152
|
-
taskId: id,
|
|
3153
|
-
output,
|
|
3154
|
-
agentId: row.agentId,
|
|
3155
|
-
workflowRunId: row.workflowRunId,
|
|
3156
|
-
workflowRunStepId: row.workflowRunStepId,
|
|
3157
|
-
});
|
|
3158
|
-
})
|
|
3159
|
-
.catch((err) =>
|
|
3160
|
-
console.error(
|
|
3161
|
-
"[db] task.completed event not emitted:",
|
|
3162
|
-
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
3163
|
-
),
|
|
3164
|
-
);
|
|
3165
|
-
});
|
|
3166
|
-
try {
|
|
3167
|
-
await promotePendingSteeringForTask(id, "Task completed before steering was delivered");
|
|
3168
|
-
} catch (error) {
|
|
3169
|
-
console.error(
|
|
3170
|
-
"[completeTask] pending steering promotion error:",
|
|
3171
|
-
scrubSecrets(error instanceof Error ? error.message : String(error)),
|
|
3172
|
-
);
|
|
3173
|
-
}
|
|
3174
|
-
}
|
|
3175
|
-
|
|
3176
|
-
return row ? rowToAgentTask(row) : null;
|
|
3177
|
-
}
|
|
3178
|
-
|
|
3179
|
-
export async function failTask(id: string, reason: string): Promise<AgentTask | null> {
|
|
3180
|
-
const oldTask = await getTaskById(id);
|
|
3181
|
-
if (!oldTask) return null;
|
|
3182
|
-
|
|
3183
|
-
// Idempotency guard: don't re-fail a task already in a terminal state.
|
|
3184
|
-
// Mirrors cancelTask / completeTask. Prevents duplicate task.failed events
|
|
3185
|
-
// and duplicate follow-up tasks when multiple sessions race.
|
|
3186
|
-
if (isTerminalTaskStatus(oldTask.status)) {
|
|
3187
|
-
return null;
|
|
3188
|
-
}
|
|
3189
|
-
|
|
3190
|
-
const finishedAt = new Date().toISOString();
|
|
3191
|
-
const scrubbedReason = scrubSecrets(reason);
|
|
3192
|
-
// Status predicate re-checks the idempotency guard atomically (a racing
|
|
3193
|
-
// terminal transition can land during the await above).
|
|
3194
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
3195
|
-
`UPDATE agent_tasks SET status = 'failed', failureReason = ?, finishedAt = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
3196
|
-
WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
|
|
3197
|
-
[scrubbedReason, finishedAt, id],
|
|
3198
|
-
);
|
|
3199
|
-
if (row && oldTask) {
|
|
3200
|
-
emitTaskLifecycleTelemetryAfterCommit(
|
|
3201
|
-
"failed",
|
|
3202
|
-
{
|
|
3203
|
-
taskId: id,
|
|
3204
|
-
source: oldTask.source,
|
|
3205
|
-
...taskContextForTelemetry(oldTask),
|
|
3206
|
-
agentId: row.agentId ?? undefined,
|
|
3207
|
-
durationMs: row.createdAt ? Date.now() - new Date(row.createdAt).getTime() : undefined,
|
|
3208
|
-
},
|
|
3209
|
-
(task) => task?.status === "failed",
|
|
3210
|
-
);
|
|
3211
|
-
|
|
3212
|
-
try {
|
|
3213
|
-
await createLogEntry({
|
|
3214
|
-
eventType: "task_status_change",
|
|
3215
|
-
taskId: id,
|
|
3216
|
-
agentId: row.agentId ?? undefined,
|
|
3217
|
-
oldValue: oldTask.status,
|
|
3218
|
-
newValue: "failed",
|
|
3219
|
-
metadata: { reason: scrubbedReason },
|
|
3220
|
-
});
|
|
3221
|
-
} catch {}
|
|
3222
|
-
getDbClient().afterCommit(() => {
|
|
3223
|
-
import("../workflows/event-bus")
|
|
3224
|
-
.then(({ workflowEventBus }) => {
|
|
3225
|
-
workflowEventBus.emit("task.failed", {
|
|
3226
|
-
taskId: id,
|
|
3227
|
-
failureReason: reason,
|
|
3228
|
-
agentId: row.agentId,
|
|
3229
|
-
workflowRunId: row.workflowRunId,
|
|
3230
|
-
workflowRunStepId: row.workflowRunStepId,
|
|
3231
|
-
});
|
|
3232
|
-
})
|
|
3233
|
-
.catch((err) =>
|
|
3234
|
-
console.error(
|
|
3235
|
-
"[db] task.failed event not emitted:",
|
|
3236
|
-
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
3237
|
-
),
|
|
3238
|
-
);
|
|
3239
|
-
});
|
|
3240
|
-
try {
|
|
3241
|
-
await promotePendingSteeringForTask(id, "Task failed before steering was delivered");
|
|
3242
|
-
} catch (error) {
|
|
3243
|
-
console.error(
|
|
3244
|
-
"[failTask] pending steering promotion error:",
|
|
3245
|
-
scrubSecrets(error instanceof Error ? error.message : String(error)),
|
|
3246
|
-
);
|
|
3247
|
-
}
|
|
3248
|
-
|
|
3249
|
-
// Cascade-fail any non-terminal tasks that depend on this one.
|
|
3250
|
-
// The cascade is recursive (transitive closure) and cycle-safe.
|
|
3251
|
-
try {
|
|
3252
|
-
await cascadeFailDependents(id, "failed");
|
|
3253
|
-
} catch (err) {
|
|
3254
|
-
console.error("[failTask] cascade-fail dependents error:", err);
|
|
3255
|
-
}
|
|
3256
|
-
}
|
|
3257
|
-
return row ? rowToAgentTask(row) : null;
|
|
3258
|
-
}
|
|
3259
|
-
|
|
3260
|
-
/**
|
|
3261
|
-
* Replace result text on an already-terminal task without replaying terminal
|
|
3262
|
-
* side effects or moving any lifecycle timestamps. Callers must opt in to
|
|
3263
|
-
* this narrow escape hatch; ordinary completion remains first-call-wins.
|
|
3264
|
-
*/
|
|
3265
|
-
export async function overwriteTerminalTaskResultText(
|
|
3266
|
-
id: string,
|
|
3267
|
-
patch: { output?: string; failureReason?: string },
|
|
3268
|
-
): Promise<AgentTask | null> {
|
|
3269
|
-
const task = await getTaskById(id);
|
|
3270
|
-
if (!task || !isTerminalTaskStatus(task.status)) return null;
|
|
3271
|
-
|
|
3272
|
-
const output = patch.output !== undefined ? scrubSecrets(patch.output) : (task.output ?? null);
|
|
3273
|
-
const failureReason =
|
|
3274
|
-
patch.failureReason !== undefined
|
|
3275
|
-
? scrubSecrets(patch.failureReason)
|
|
3276
|
-
: (task.failureReason ?? null);
|
|
3277
|
-
const row = await getDbClient().transaction(async () => {
|
|
3278
|
-
const updated =
|
|
3279
|
-
(await getDbClient().get<AgentTaskRow>(
|
|
3280
|
-
`UPDATE agent_tasks SET output = ?, failureReason = ?
|
|
3281
|
-
WHERE id = ? AND status IN ('completed', 'failed', 'cancelled', 'superseded')
|
|
3282
|
-
RETURNING *`,
|
|
3283
|
-
[output, failureReason, id],
|
|
3284
|
-
)) ?? null;
|
|
3285
|
-
if (updated && patch.output !== undefined) {
|
|
3286
|
-
await reconcileTaskPullRequestAttachments(id, updated.agentId, [
|
|
3287
|
-
updated.output,
|
|
3288
|
-
updated.vcsProvider === "github" ? updated.vcsUrl : null,
|
|
3289
|
-
]);
|
|
3290
|
-
}
|
|
3291
|
-
return updated;
|
|
3292
|
-
});
|
|
3293
|
-
|
|
3294
|
-
return row ? rowToAgentTask(row) : task;
|
|
3295
|
-
}
|
|
3296
|
-
|
|
3297
|
-
export async function cancelTask(id: string, reason?: string): Promise<AgentTask | null> {
|
|
3298
|
-
const oldTask = await getTaskById(id);
|
|
3299
|
-
if (!oldTask) return null;
|
|
3300
|
-
|
|
3301
|
-
// Only cancel tasks that are not already in a terminal state
|
|
3302
|
-
if (isTerminalTaskStatus(oldTask.status)) {
|
|
3303
|
-
return null;
|
|
3304
|
-
}
|
|
3305
|
-
|
|
3306
|
-
const finishedAt = new Date().toISOString();
|
|
3307
|
-
const cancelReason = reason ?? "Cancelled by user";
|
|
3308
|
-
// Status predicate re-checks the idempotency guard atomically (a racing
|
|
3309
|
-
// terminal transition can land during the await above).
|
|
3310
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
3311
|
-
`UPDATE agent_tasks SET status = 'cancelled', failureReason = ?, finishedAt = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
3312
|
-
WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
|
|
3313
|
-
[cancelReason, finishedAt, id],
|
|
3314
|
-
);
|
|
3315
|
-
|
|
3316
|
-
if (row && oldTask) {
|
|
3317
|
-
emitTaskLifecycleTelemetryAfterCommit(
|
|
3318
|
-
"cancelled",
|
|
3319
|
-
{
|
|
3320
|
-
taskId: id,
|
|
3321
|
-
source: oldTask.source,
|
|
3322
|
-
agentId: oldTask.agentId ?? undefined,
|
|
3323
|
-
previousStatus: oldTask.status,
|
|
3324
|
-
durationMs: oldTask.createdAt
|
|
3325
|
-
? Date.now() - new Date(oldTask.createdAt).getTime()
|
|
3326
|
-
: undefined,
|
|
3327
|
-
},
|
|
3328
|
-
(task) => task?.status === "cancelled",
|
|
3329
|
-
);
|
|
3330
|
-
|
|
3331
|
-
try {
|
|
3332
|
-
await createLogEntry({
|
|
3333
|
-
eventType: "task_status_change",
|
|
3334
|
-
taskId: id,
|
|
3335
|
-
agentId: row.agentId ?? undefined,
|
|
3336
|
-
oldValue: oldTask.status,
|
|
3337
|
-
newValue: "cancelled",
|
|
3338
|
-
metadata: reason ? { reason } : undefined,
|
|
3339
|
-
});
|
|
3340
|
-
} catch {}
|
|
3341
|
-
getDbClient().afterCommit(() => {
|
|
3342
|
-
import("../workflows/event-bus")
|
|
3343
|
-
.then(({ workflowEventBus }) => {
|
|
3344
|
-
workflowEventBus.emit("task.cancelled", {
|
|
3345
|
-
taskId: id,
|
|
3346
|
-
agentId: row.agentId,
|
|
3347
|
-
workflowRunId: row.workflowRunId,
|
|
3348
|
-
workflowRunStepId: row.workflowRunStepId,
|
|
3349
|
-
});
|
|
3350
|
-
})
|
|
3351
|
-
.catch((err) =>
|
|
3352
|
-
console.error(
|
|
3353
|
-
"[db] task.cancelled event not emitted:",
|
|
3354
|
-
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
3355
|
-
),
|
|
3356
|
-
);
|
|
3357
|
-
});
|
|
3358
|
-
try {
|
|
3359
|
-
await promotePendingSteeringForTask(id, "Task was cancelled before steering was delivered");
|
|
3360
|
-
} catch (error) {
|
|
3361
|
-
console.error(
|
|
3362
|
-
"[cancelTask] pending steering promotion error:",
|
|
3363
|
-
scrubSecrets(error instanceof Error ? error.message : String(error)),
|
|
3364
|
-
);
|
|
3365
|
-
}
|
|
3366
|
-
|
|
3367
|
-
try {
|
|
3368
|
-
await cascadeFailDependents(id, "cancelled");
|
|
3369
|
-
} catch (err) {
|
|
3370
|
-
console.error("[cancelTask] cascade-fail dependents error:", err);
|
|
3371
|
-
}
|
|
3372
|
-
}
|
|
3373
|
-
|
|
3374
|
-
return row ? rowToAgentTask(row) : null;
|
|
3375
|
-
}
|
|
3376
|
-
|
|
3377
|
-
/**
|
|
3378
|
-
* Supersede a task: mark it as `superseded` (terminal) so a fresh "resume"
|
|
3379
|
-
* follow-up task can pick up where it left off. Used by the graceful-shutdown
|
|
3380
|
-
* path and the `POST /api/tasks/:id/supersede` route. Returns null if the task
|
|
3381
|
-
* is already terminal (mirrors `completeTask` / `cancelTask` idempotency).
|
|
3382
|
-
*
|
|
3383
|
-
* Writes a `task_superseded` agent_log with `{ reason, resumeTaskId }` payload
|
|
3384
|
-
* and emits a `task.superseded` workflow event. The caller is responsible for
|
|
3385
|
-
* creating the resume follow-up (via `createResumeFollowUp`) and passing the
|
|
3386
|
-
* resulting id as `resumeTaskId`.
|
|
3387
|
-
*/
|
|
3388
|
-
export async function supersedeTask(
|
|
3389
|
-
id: string,
|
|
3390
|
-
args: { reason: string; resumeTaskId: string | null },
|
|
3391
|
-
): Promise<AgentTask | null> {
|
|
3392
|
-
const oldTask = await getTaskById(id);
|
|
3393
|
-
if (!oldTask) return null;
|
|
3394
|
-
|
|
3395
|
-
// Idempotency guard: don't re-supersede a task already in a terminal state.
|
|
3396
|
-
if (isTerminalTaskStatus(oldTask.status)) {
|
|
3397
|
-
return null;
|
|
3398
|
-
}
|
|
3399
|
-
|
|
3400
|
-
const finishedAt = new Date().toISOString();
|
|
3401
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
3402
|
-
`UPDATE agent_tasks
|
|
3403
|
-
SET status = 'superseded',
|
|
3404
|
-
finishedAt = ?,
|
|
3405
|
-
lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
3406
|
-
WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
|
|
3407
|
-
RETURNING *`,
|
|
3408
|
-
[finishedAt, id],
|
|
3409
|
-
);
|
|
3410
|
-
|
|
3411
|
-
if (row && oldTask) {
|
|
3412
|
-
emitTaskLifecycleTelemetryAfterCommit(
|
|
3413
|
-
"superseded",
|
|
3414
|
-
{
|
|
3415
|
-
taskId: id,
|
|
3416
|
-
source: oldTask.source,
|
|
3417
|
-
...taskContextForTelemetry(oldTask),
|
|
3418
|
-
agentId: row.agentId ?? undefined,
|
|
3419
|
-
reason: args.reason,
|
|
3420
|
-
durationMs: oldTask.createdAt
|
|
3421
|
-
? Date.now() - new Date(oldTask.createdAt).getTime()
|
|
3422
|
-
: undefined,
|
|
3423
|
-
},
|
|
3424
|
-
(task) => task?.status === "superseded",
|
|
3425
|
-
);
|
|
3426
|
-
|
|
3427
|
-
try {
|
|
3428
|
-
await createLogEntry({
|
|
3429
|
-
eventType: "task_superseded",
|
|
3430
|
-
taskId: id,
|
|
3431
|
-
agentId: row.agentId ?? undefined,
|
|
3432
|
-
oldValue: oldTask.status,
|
|
3433
|
-
newValue: "superseded",
|
|
3434
|
-
metadata: { reason: args.reason, resumeTaskId: args.resumeTaskId },
|
|
3435
|
-
});
|
|
3436
|
-
} catch {}
|
|
3437
|
-
getDbClient().afterCommit(() => {
|
|
3438
|
-
import("../workflows/event-bus")
|
|
3439
|
-
.then(({ workflowEventBus }) => {
|
|
3440
|
-
workflowEventBus.emit("task.superseded", {
|
|
3441
|
-
taskId: id,
|
|
3442
|
-
reason: args.reason,
|
|
3443
|
-
resumeTaskId: args.resumeTaskId,
|
|
3444
|
-
agentId: row.agentId,
|
|
3445
|
-
workflowRunId: row.workflowRunId,
|
|
3446
|
-
workflowRunStepId: row.workflowRunStepId,
|
|
3447
|
-
});
|
|
3448
|
-
})
|
|
3449
|
-
.catch((err) =>
|
|
3450
|
-
console.error(
|
|
3451
|
-
"[db] task.superseded event not emitted:",
|
|
3452
|
-
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
3453
|
-
),
|
|
3454
|
-
);
|
|
3455
|
-
});
|
|
3456
|
-
|
|
3457
|
-
try {
|
|
3458
|
-
await cascadeFailDependents(id, "superseded");
|
|
3459
|
-
} catch (err) {
|
|
3460
|
-
console.error("[supersedeTask] cascade-fail dependents error:", err);
|
|
3461
|
-
}
|
|
3462
|
-
}
|
|
3463
|
-
|
|
3464
|
-
return row ? rowToAgentTask(row) : null;
|
|
3465
|
-
}
|
|
3466
|
-
|
|
3467
|
-
export async function backfillSupersedeTaskResumeTaskId(
|
|
3468
|
-
taskId: string,
|
|
3469
|
-
resumeTaskId: string,
|
|
3470
|
-
): Promise<boolean> {
|
|
3471
|
-
const row = await getDbClient().get<{ id: string; metadata: string | null }>(
|
|
3472
|
-
`SELECT id, metadata
|
|
3473
|
-
FROM agent_log
|
|
3474
|
-
WHERE taskId = ? AND eventType = 'task_superseded'
|
|
3475
|
-
ORDER BY createdAt DESC
|
|
3476
|
-
LIMIT 1`,
|
|
3477
|
-
[taskId],
|
|
3478
|
-
);
|
|
3479
|
-
if (!row) return false;
|
|
3480
|
-
|
|
3481
|
-
let metadata: Record<string, unknown> = {};
|
|
3482
|
-
if (row.metadata) {
|
|
3483
|
-
try {
|
|
3484
|
-
metadata = JSON.parse(row.metadata) as Record<string, unknown>;
|
|
3485
|
-
} catch {
|
|
3486
|
-
metadata = {};
|
|
3487
|
-
}
|
|
3488
|
-
}
|
|
3489
|
-
metadata.resumeTaskId = resumeTaskId;
|
|
3490
|
-
|
|
3491
|
-
const result = await getDbClient().run("UPDATE agent_log SET metadata = ? WHERE id = ?", [
|
|
3492
|
-
JSON.stringify(metadata),
|
|
3493
|
-
row.id,
|
|
3494
|
-
]);
|
|
3495
|
-
return result.changes > 0;
|
|
3496
|
-
}
|
|
3497
|
-
|
|
3498
|
-
/**
|
|
3499
|
-
* Pause a task that is currently in progress.
|
|
3500
|
-
* Used during graceful shutdown to allow tasks to resume after container restart.
|
|
3501
|
-
* Unlike failTask, paused tasks retain their agent assignment and can be resumed.
|
|
3502
|
-
*/
|
|
3503
|
-
export async function pauseTask(id: string): Promise<AgentTask | null> {
|
|
3504
|
-
const oldTask = await getTaskById(id);
|
|
3505
|
-
if (!oldTask) return null;
|
|
3506
|
-
|
|
3507
|
-
// Only pause tasks that are in progress
|
|
3508
|
-
if (oldTask.status !== "in_progress") {
|
|
3509
|
-
return null;
|
|
3510
|
-
}
|
|
3511
|
-
|
|
3512
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
3513
|
-
`UPDATE agent_tasks
|
|
3514
|
-
SET status = 'paused',
|
|
3515
|
-
was_paused = 1,
|
|
3516
|
-
lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
3517
|
-
WHERE id = ? AND status = 'in_progress'
|
|
612
|
+
updated_by = COALESCE(?, updated_by)
|
|
613
|
+
WHERE id = ?
|
|
3518
614
|
RETURNING *`,
|
|
3519
|
-
[
|
|
615
|
+
[
|
|
616
|
+
updates.permalink ?? null,
|
|
617
|
+
updates.finalized ? 1 : 0,
|
|
618
|
+
now,
|
|
619
|
+
updates.streamChunksAppended ?? null,
|
|
620
|
+
updates.conclusionKind ?? null,
|
|
621
|
+
updates.touchUpdatedAt === false ? 0 : 1,
|
|
622
|
+
now,
|
|
623
|
+
updates.actorId ?? null,
|
|
624
|
+
id,
|
|
625
|
+
],
|
|
3520
626
|
);
|
|
3521
|
-
|
|
3522
|
-
if (row && oldTask) {
|
|
3523
|
-
try {
|
|
3524
|
-
await createLogEntry({
|
|
3525
|
-
eventType: "task_status_change",
|
|
3526
|
-
taskId: id,
|
|
3527
|
-
agentId: row.agentId ?? undefined,
|
|
3528
|
-
oldValue: oldTask.status,
|
|
3529
|
-
newValue: "paused",
|
|
3530
|
-
metadata: { pausedForShutdown: true },
|
|
3531
|
-
});
|
|
3532
|
-
} catch {}
|
|
3533
|
-
}
|
|
3534
|
-
|
|
3535
|
-
return row ? rowToAgentTask(row) : null;
|
|
627
|
+
return row ? rowToSlackMessage(row) : null;
|
|
3536
628
|
}
|
|
3537
629
|
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
const row = await getDbClient().get<AgentTaskRow>(
|
|
3547
|
-
`UPDATE agent_tasks
|
|
3548
|
-
SET status = 'in_progress',
|
|
3549
|
-
was_paused = 1,
|
|
3550
|
-
lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
3551
|
-
WHERE id = ? AND status = 'paused'
|
|
630
|
+
export async function markSlackTreeRendered(
|
|
631
|
+
id: string,
|
|
632
|
+
renderedThrough: string,
|
|
633
|
+
): Promise<SlackMessageRecord | null> {
|
|
634
|
+
const row = await getDbClient().get<SlackMessageRow>(
|
|
635
|
+
`UPDATE slack_messages SET updated_at = ?
|
|
636
|
+
WHERE id = ? AND kind = 'tree'
|
|
3552
637
|
RETURNING *`,
|
|
3553
|
-
[
|
|
638
|
+
[renderedThrough, id],
|
|
3554
639
|
);
|
|
640
|
+
return row ? rowToSlackMessage(row) : null;
|
|
641
|
+
}
|
|
3555
642
|
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
taskId,
|
|
3561
|
-
agentId: row.agentId ?? undefined,
|
|
3562
|
-
oldValue: "paused",
|
|
3563
|
-
newValue: "in_progress",
|
|
3564
|
-
metadata: { resumed: true },
|
|
3565
|
-
});
|
|
3566
|
-
} catch {}
|
|
3567
|
-
}
|
|
643
|
+
export async function deleteSlackMessageRecord(id: string): Promise<boolean> {
|
|
644
|
+
const result = await getDbClient().run(`DELETE FROM slack_messages WHERE id = ?`, [id]);
|
|
645
|
+
return result.changes > 0;
|
|
646
|
+
}
|
|
3568
647
|
|
|
3569
|
-
|
|
648
|
+
export async function getSlackTreeMessage(contextKey: string): Promise<SlackMessageRecord | null> {
|
|
649
|
+
const row = await getDbClient().get<SlackMessageRow>(
|
|
650
|
+
`SELECT * FROM slack_messages WHERE context_key = ? AND kind = 'tree' LIMIT 1`,
|
|
651
|
+
[contextKey],
|
|
652
|
+
);
|
|
653
|
+
return row ? rowToSlackMessage(row) : null;
|
|
3570
654
|
}
|
|
3571
655
|
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
ORDER BY createdAt ASC, rowid ASC`,
|
|
3582
|
-
[agentId],
|
|
656
|
+
export async function getSlackTreeMessageByThread(
|
|
657
|
+
channelId: string,
|
|
658
|
+
threadTs: string,
|
|
659
|
+
): Promise<SlackMessageRecord | null> {
|
|
660
|
+
const row = await getDbClient().get<SlackMessageRow>(
|
|
661
|
+
`SELECT * FROM slack_messages
|
|
662
|
+
WHERE channel_id = ? AND thread_ts = ? AND kind = 'tree'
|
|
663
|
+
LIMIT 1`,
|
|
664
|
+
[channelId, threadTs],
|
|
3583
665
|
);
|
|
3584
|
-
return
|
|
666
|
+
return row ? rowToSlackMessage(row) : null;
|
|
3585
667
|
}
|
|
3586
668
|
|
|
3587
|
-
export async function
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
const cutoff = new Date(Date.now() - minAgeSeconds * 1000).toISOString();
|
|
3592
|
-
const rows = await getDbClient().query<AgentTaskRow>(
|
|
3593
|
-
`SELECT t.* FROM agent_tasks t
|
|
3594
|
-
LEFT JOIN active_sessions s ON s.taskId = t.id
|
|
3595
|
-
WHERE t.agentId = ?
|
|
3596
|
-
AND t.status = 'in_progress'
|
|
3597
|
-
AND t.claudeSessionId IS NULL
|
|
3598
|
-
AND t.lastUpdatedAt < ?
|
|
3599
|
-
AND s.id IS NULL
|
|
3600
|
-
AND t.finishedAt IS NULL
|
|
3601
|
-
ORDER BY t.createdAt ASC, t.rowid ASC`,
|
|
3602
|
-
[agentId, cutoff],
|
|
669
|
+
export async function getSlackOutcomeMessage(taskId: string): Promise<SlackMessageRecord | null> {
|
|
670
|
+
const row = await getDbClient().get<SlackMessageRow>(
|
|
671
|
+
`SELECT * FROM slack_messages WHERE task_id = ? AND kind = 'outcome' LIMIT 1`,
|
|
672
|
+
[taskId],
|
|
3603
673
|
);
|
|
3604
|
-
return
|
|
674
|
+
return row ? rowToSlackMessage(row) : null;
|
|
3605
675
|
}
|
|
3606
676
|
|
|
3607
|
-
export async function
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
AND
|
|
3621
|
-
AND
|
|
3622
|
-
AND
|
|
3623
|
-
|
|
3624
|
-
|
|
677
|
+
export async function getSlackTreeMessages(): Promise<SlackMessageRecord[]> {
|
|
678
|
+
const rows = await getDbClient().query<SlackMessageRow>(
|
|
679
|
+
`SELECT tree.*
|
|
680
|
+
FROM slack_messages tree
|
|
681
|
+
JOIN slack_render_v2_state state ON state.id = 1
|
|
682
|
+
WHERE tree.kind = 'tree'
|
|
683
|
+
AND (
|
|
684
|
+
(tree.ts LIKE 'pending:%' AND tree.created_at >= state.activated_at)
|
|
685
|
+
OR EXISTS (
|
|
686
|
+
SELECT 1 FROM agent_tasks task
|
|
687
|
+
WHERE task.slackChannelId = tree.channel_id
|
|
688
|
+
AND task.slackThreadTs = tree.thread_ts
|
|
689
|
+
AND task.source = 'slack'
|
|
690
|
+
AND task.createdAt >= state.activated_at
|
|
691
|
+
AND task.status IN ('completed', 'failed', 'cancelled')
|
|
692
|
+
AND NOT EXISTS (
|
|
693
|
+
SELECT 1 FROM slack_messages outcome
|
|
694
|
+
WHERE outcome.kind = 'outcome'
|
|
695
|
+
AND outcome.task_id = task.id
|
|
696
|
+
AND outcome.finalized_at IS NOT NULL
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
OR
|
|
700
|
+
EXISTS (
|
|
701
|
+
SELECT 1 FROM agent_tasks task
|
|
702
|
+
WHERE task.slackChannelId = tree.channel_id
|
|
703
|
+
AND task.slackThreadTs = tree.thread_ts
|
|
704
|
+
AND task.createdAt >= state.activated_at
|
|
705
|
+
AND task.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
|
|
706
|
+
)
|
|
707
|
+
OR EXISTS (
|
|
708
|
+
SELECT 1 FROM agent_tasks task
|
|
709
|
+
WHERE task.slackChannelId = tree.channel_id
|
|
710
|
+
AND task.slackThreadTs = tree.thread_ts
|
|
711
|
+
AND task.createdAt >= state.activated_at
|
|
712
|
+
AND task.lastUpdatedAt > tree.updated_at
|
|
713
|
+
)
|
|
714
|
+
OR EXISTS (
|
|
715
|
+
SELECT 1 FROM slack_messages outcome
|
|
716
|
+
JOIN agent_tasks task ON task.id = outcome.task_id
|
|
717
|
+
WHERE outcome.kind = 'outcome'
|
|
718
|
+
AND outcome.channel_id = tree.channel_id
|
|
719
|
+
AND outcome.thread_ts = tree.thread_ts
|
|
720
|
+
AND task.createdAt >= state.activated_at
|
|
721
|
+
AND (
|
|
722
|
+
outcome.finalized_at IS NULL
|
|
723
|
+
OR outcome.updated_at > tree.updated_at
|
|
724
|
+
)
|
|
725
|
+
)
|
|
3625
726
|
)
|
|
3626
|
-
|
|
3627
|
-
[agentId, cutoff],
|
|
727
|
+
ORDER BY tree.created_at ASC`,
|
|
3628
728
|
);
|
|
729
|
+
return rows.map(rowToSlackMessage);
|
|
730
|
+
}
|
|
3629
731
|
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
});
|
|
3640
|
-
} catch {}
|
|
3641
|
-
}
|
|
3642
|
-
|
|
3643
|
-
return rows.map(rowToAgentTask);
|
|
732
|
+
export async function getSlackMessageByChannelTs(
|
|
733
|
+
channelId: string,
|
|
734
|
+
ts: string,
|
|
735
|
+
): Promise<SlackMessageRecord | null> {
|
|
736
|
+
const row = await getDbClient().get<SlackMessageRow>(
|
|
737
|
+
`SELECT * FROM slack_messages WHERE channel_id = ? AND ts = ? LIMIT 1`,
|
|
738
|
+
[channelId, ts],
|
|
739
|
+
);
|
|
740
|
+
return row ? rowToSlackMessage(row) : null;
|
|
3644
741
|
}
|
|
3645
742
|
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
*/
|
|
3651
|
-
export async function getRecentlyCancelledTasksForAgent(agentId: string): Promise<AgentTask[]> {
|
|
3652
|
-
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
743
|
+
export async function getSlackTasksInThread(
|
|
744
|
+
channelId: string,
|
|
745
|
+
threadTs: string,
|
|
746
|
+
): Promise<AgentTask[]> {
|
|
3653
747
|
const rows = await getDbClient().query<AgentTaskRow>(
|
|
3654
748
|
`SELECT * FROM agent_tasks
|
|
3655
|
-
WHERE
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
ORDER BY finishedAt DESC`,
|
|
3659
|
-
[agentId, fiveMinutesAgo],
|
|
749
|
+
WHERE slackChannelId = ? AND slackThreadTs = ?
|
|
750
|
+
ORDER BY createdAt ASC, rowid ASC`,
|
|
751
|
+
[channelId, threadTs],
|
|
3660
752
|
);
|
|
3661
753
|
return rows.map(rowToAgentTask);
|
|
3662
754
|
}
|
|
3663
755
|
|
|
3664
|
-
export async function
|
|
3665
|
-
|
|
3666
|
-
return result.changes > 0;
|
|
756
|
+
export async function markTaskSlackReplySent(taskId: string): Promise<void> {
|
|
757
|
+
await getDbClient().run(`UPDATE agent_tasks SET slackReplySent = 1 WHERE id = ?`, [taskId]);
|
|
3667
758
|
}
|
|
3668
759
|
|
|
3669
|
-
export async function
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
);
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
} catch {}
|
|
3687
|
-
getDbClient().afterCommit(() => {
|
|
3688
|
-
import("../workflows/event-bus")
|
|
3689
|
-
.then(({ workflowEventBus }) => {
|
|
3690
|
-
workflowEventBus.emit("task.progress", {
|
|
3691
|
-
taskId: id,
|
|
3692
|
-
progress: scrubbedProgress,
|
|
3693
|
-
agentId: row.agentId,
|
|
3694
|
-
});
|
|
3695
|
-
})
|
|
3696
|
-
.catch((err) =>
|
|
3697
|
-
console.error(
|
|
3698
|
-
"[db] task.progress event not emitted:",
|
|
3699
|
-
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
3700
|
-
),
|
|
3701
|
-
);
|
|
3702
|
-
});
|
|
760
|
+
export async function setSlackMessageTracking(
|
|
761
|
+
taskId: string,
|
|
762
|
+
fields: {
|
|
763
|
+
slackProgressMessageTs?: string | null;
|
|
764
|
+
slackTreeRootMessageTs?: string | null;
|
|
765
|
+
},
|
|
766
|
+
): Promise<void> {
|
|
767
|
+
const sets: string[] = [];
|
|
768
|
+
const args: (string | null)[] = [];
|
|
769
|
+
|
|
770
|
+
if (Object.hasOwn(fields, "slackProgressMessageTs")) {
|
|
771
|
+
sets.push("slackProgressMessageTs = ?");
|
|
772
|
+
args.push(fields.slackProgressMessageTs ?? null);
|
|
773
|
+
}
|
|
774
|
+
if (Object.hasOwn(fields, "slackTreeRootMessageTs")) {
|
|
775
|
+
sets.push("slackTreeRootMessageTs = ?");
|
|
776
|
+
args.push(fields.slackTreeRootMessageTs ?? null);
|
|
3703
777
|
}
|
|
778
|
+
if (sets.length === 0) return;
|
|
779
|
+
|
|
780
|
+
args.push(taskId);
|
|
781
|
+
await getDbClient().run(`UPDATE agent_tasks SET ${sets.join(", ")} WHERE id = ?`, args);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export async function updateTaskVcs(
|
|
785
|
+
taskId: string,
|
|
786
|
+
vcs: {
|
|
787
|
+
vcsProvider: "github" | "gitlab";
|
|
788
|
+
vcsRepo: string;
|
|
789
|
+
vcsNumber: number;
|
|
790
|
+
vcsUrl: string;
|
|
791
|
+
},
|
|
792
|
+
): Promise<AgentTask | null> {
|
|
793
|
+
const row = await getDbClient().transaction(async () => {
|
|
794
|
+
const updated = await getDbClient().get<AgentTaskRow>(
|
|
795
|
+
`UPDATE agent_tasks
|
|
796
|
+
SET vcsProvider = ?, vcsRepo = ?, vcsNumber = ?, vcsUrl = ?, lastUpdatedAt = ?
|
|
797
|
+
WHERE id = ? RETURNING *`,
|
|
798
|
+
[vcs.vcsProvider, vcs.vcsRepo, vcs.vcsNumber, vcs.vcsUrl, new Date().toISOString(), taskId],
|
|
799
|
+
);
|
|
800
|
+
if (updated) {
|
|
801
|
+
await reconcileTaskPullRequestAttachments(taskId, updated.agentId, [
|
|
802
|
+
updated.output,
|
|
803
|
+
vcs.vcsProvider === "github" ? vcs.vcsUrl : null,
|
|
804
|
+
]);
|
|
805
|
+
}
|
|
806
|
+
return updated;
|
|
807
|
+
});
|
|
3704
808
|
return row ? rowToAgentTask(row) : null;
|
|
3705
809
|
}
|
|
3706
810
|
|
|
@@ -5709,6 +2813,18 @@ export async function promoteDraftTask(taskId: string): Promise<AgentTask | null
|
|
|
5709
2813
|
return row ? rowToAgentTask(row) : null;
|
|
5710
2814
|
}
|
|
5711
2815
|
|
|
2816
|
+
/**
|
|
2817
|
+
* Mark a draft as still being worked on, so `promoteAbandonedDraftTasks`
|
|
2818
|
+
* (which goes by `lastUpdatedAt`) doesn't promote it while an upload batch
|
|
2819
|
+
* that outlasts the sweep window is still running. No-op once promoted.
|
|
2820
|
+
*/
|
|
2821
|
+
export async function refreshDraftTaskLease(taskId: string): Promise<void> {
|
|
2822
|
+
await getDbClient().run(
|
|
2823
|
+
"UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ? AND status = 'draft'",
|
|
2824
|
+
[new Date().toISOString(), taskId],
|
|
2825
|
+
);
|
|
2826
|
+
}
|
|
2827
|
+
|
|
5712
2828
|
/**
|
|
5713
2829
|
* Heartbeat sweep: promote drafts abandoned mid-upload (tab closed, network
|
|
5714
2830
|
* dropped) instead of leaving them permanently dispatch-ineligible — the
|
|
@@ -6040,6 +3156,12 @@ export async function updateAgentProfile(
|
|
|
6040
3156
|
avatar?: AgentAvatar | null;
|
|
6041
3157
|
},
|
|
6042
3158
|
meta?: VersionMeta,
|
|
3159
|
+
guard?: {
|
|
3160
|
+
/** Per-field compare-and-set token; see {@link ProfileExpectedHashes}. */
|
|
3161
|
+
expectedHashes?: ProfileExpectedHashes;
|
|
3162
|
+
/** Called once per field dropped because its expected hash was stale. */
|
|
3163
|
+
onConflict?: (conflict: ProfileSyncConflict) => void;
|
|
3164
|
+
},
|
|
6043
3165
|
): Promise<Agent | null> {
|
|
6044
3166
|
return await getDbClient().transaction(async (tx) => {
|
|
6045
3167
|
// Get current agent state for version comparison
|
|
@@ -6078,8 +3200,39 @@ export async function updateAgentProfile(
|
|
|
6078
3200
|
}
|
|
6079
3201
|
}
|
|
6080
3202
|
|
|
3203
|
+
// Compare-and-set. A writer that edits a copy of a field (a session's
|
|
3204
|
+
// materialized CLAUDE.md) sends the hash that copy was based on. If the DB
|
|
3205
|
+
// moved since — a sibling session's sync, an `update-profile` — the copy is
|
|
3206
|
+
// stale and writing it would revert the newer value, so the field is dropped
|
|
3207
|
+
// (no version, no column write). An edit on top of the current value applies,
|
|
3208
|
+
// including a deliberate revert to an earlier version. Without a token the
|
|
3209
|
+
// write is unconditional, as before.
|
|
3210
|
+
//
|
|
3211
|
+
// Runs BEFORE the budget check: a stale copy of an older, longer value must be
|
|
3212
|
+
// dropped, not rejected — a budget rejection throws and would also roll back
|
|
3213
|
+
// the other fields of the same update.
|
|
3214
|
+
const writable = { ...updates };
|
|
3215
|
+
for (const field of VERSIONABLE_FIELDS) {
|
|
3216
|
+
const expectedHash = guard?.expectedHashes?.[field];
|
|
3217
|
+
if (expectedHash === undefined) continue;
|
|
3218
|
+
if (writable[field] === undefined || writable[field] === null) continue;
|
|
3219
|
+
|
|
3220
|
+
const currentHash = computeContentHash(current[field] ?? "");
|
|
3221
|
+
if (currentHash === expectedHash) continue;
|
|
3222
|
+
|
|
3223
|
+
console.warn(
|
|
3224
|
+
`[profile-sync] agent ${id}: ${field} dropped — based on ${expectedHash.slice(0, 12)}, DB is at ${currentHash.slice(0, 12)}`,
|
|
3225
|
+
);
|
|
3226
|
+
delete writable[field];
|
|
3227
|
+
guard?.onConflict?.({ field, expectedHash, currentHash });
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
if (writable.heartbeatMd !== undefined) {
|
|
3231
|
+
validateHeartbeatExpiry(current.heartbeatMd ?? "", writable.heartbeatMd);
|
|
3232
|
+
}
|
|
3233
|
+
|
|
6081
3234
|
for (const field of BUDGETED_IDENTITY_FIELDS) {
|
|
6082
|
-
const nextValue =
|
|
3235
|
+
const nextValue = writable[field];
|
|
6083
3236
|
if (nextValue === undefined) continue;
|
|
6084
3237
|
|
|
6085
3238
|
const result = checkIdentityFieldBudget({
|
|
@@ -6104,9 +3257,9 @@ export async function updateAgentProfile(
|
|
|
6104
3257
|
if (existingAgent) throw new Error("Agent name already exists");
|
|
6105
3258
|
}
|
|
6106
3259
|
|
|
6107
|
-
// Create context versions for changed fields
|
|
3260
|
+
// Create context versions for changed fields (stale copies already dropped)
|
|
6108
3261
|
for (const field of VERSIONABLE_FIELDS) {
|
|
6109
|
-
const newValue =
|
|
3262
|
+
const newValue = writable[field];
|
|
6110
3263
|
if (newValue === undefined || newValue === null) continue;
|
|
6111
3264
|
|
|
6112
3265
|
const currentValue = current[field] ?? "";
|
|
@@ -6163,12 +3316,12 @@ export async function updateAgentProfile(
|
|
|
6163
3316
|
updates.description ?? null,
|
|
6164
3317
|
updates.role ?? null,
|
|
6165
3318
|
updates.capabilities ? JSON.stringify(updates.capabilities) : null,
|
|
6166
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
|
|
3319
|
+
writable.claudeMd ?? null,
|
|
3320
|
+
writable.soulMd ?? null,
|
|
3321
|
+
writable.identityMd ?? null,
|
|
3322
|
+
writable.setupScript ?? null,
|
|
3323
|
+
writable.toolsMd ?? null,
|
|
3324
|
+
writable.heartbeatMd ?? null,
|
|
6172
3325
|
avatarProvided ? 1 : 0,
|
|
6173
3326
|
avatarJson,
|
|
6174
3327
|
now,
|
|
@@ -8188,6 +5341,7 @@ type ScheduledTaskRow = {
|
|
|
8188
5341
|
lastRunAt: string | null;
|
|
8189
5342
|
nextRunAt: string | null;
|
|
8190
5343
|
createdByAgentId: string | null;
|
|
5344
|
+
parentTaskId: string | null;
|
|
8191
5345
|
timezone: string;
|
|
8192
5346
|
consecutiveErrors: number | null;
|
|
8193
5347
|
lastErrorAt: string | null;
|
|
@@ -8214,9 +5368,6 @@ type ScheduledTaskRow = {
|
|
|
8214
5368
|
// while keeping list payloads small.
|
|
8215
5369
|
/** Preview length for a schedule's `taskTemplate`. */
|
|
8216
5370
|
const SCHEDULE_TEMPLATE_PREVIEW_LENGTH = 280;
|
|
8217
|
-
/** Preview length for a task's `task` text (pool-triage needs to read it). */
|
|
8218
|
-
const TASK_PREVIEW_LENGTH = 300;
|
|
8219
|
-
|
|
8220
5371
|
/** Truncate text for a list-row preview. Appends an ellipsis when clipped. */
|
|
8221
5372
|
function previewText(text: string | null | undefined, maxChars: number): string {
|
|
8222
5373
|
const s = text ?? "";
|
|
@@ -8240,6 +5391,7 @@ function rowToScheduledTask(row: ScheduledTaskRow): ScheduledTask {
|
|
|
8240
5391
|
lastRunAt: normalizeDate(row.lastRunAt) ?? undefined,
|
|
8241
5392
|
nextRunAt: normalizeDate(row.nextRunAt) ?? undefined,
|
|
8242
5393
|
createdByAgentId: row.createdByAgentId ?? undefined,
|
|
5394
|
+
parentTaskId: row.parentTaskId ?? undefined,
|
|
8243
5395
|
timezone: row.timezone,
|
|
8244
5396
|
consecutiveErrors: row.consecutiveErrors ?? 0,
|
|
8245
5397
|
lastErrorAt: normalizeDate(row.lastErrorAt) ?? undefined,
|
|
@@ -8389,6 +5541,8 @@ export interface CreateScheduledTaskData {
|
|
|
8389
5541
|
enabled?: boolean;
|
|
8390
5542
|
nextRunAt?: string;
|
|
8391
5543
|
createdByAgentId?: string;
|
|
5544
|
+
/** Only `defer-task` sets this — the task the schedule's run continues. */
|
|
5545
|
+
parentTaskId?: string;
|
|
8392
5546
|
timezone?: string;
|
|
8393
5547
|
model?: string;
|
|
8394
5548
|
modelTier?: ModelTier;
|
|
@@ -8411,11 +5565,11 @@ export async function createScheduledTask(data: CreateScheduledTaskData): Promis
|
|
|
8411
5565
|
`INSERT INTO scheduled_tasks (
|
|
8412
5566
|
id, "key", name, description, cronExpression, intervalMs, taskTemplate,
|
|
8413
5567
|
taskType, tags, priority, targetAgentId, enabled, nextRunAt,
|
|
8414
|
-
createdByAgentId, timezone, model, modelTier, scheduleType,
|
|
8415
|
-
workflowId, scriptName, scriptArgs, params, requiredParams, requires,
|
|
5568
|
+
createdByAgentId, parentTaskId, timezone, model, modelTier, scheduleType,
|
|
5569
|
+
targetType, workflowId, scriptName, scriptArgs, params, requiredParams, requires,
|
|
8416
5570
|
createdAt, lastUpdatedAt,
|
|
8417
5571
|
created_by, updated_by
|
|
8418
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
|
5572
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
|
8419
5573
|
[
|
|
8420
5574
|
id,
|
|
8421
5575
|
normalizeAssetKey(data.key ?? defaultAssetKey("schedule", id)),
|
|
@@ -8431,6 +5585,7 @@ export async function createScheduledTask(data: CreateScheduledTaskData): Promis
|
|
|
8431
5585
|
data.enabled !== false ? 1 : 0,
|
|
8432
5586
|
data.nextRunAt ?? null,
|
|
8433
5587
|
data.createdByAgentId ?? null,
|
|
5588
|
+
data.parentTaskId ?? null,
|
|
8434
5589
|
data.timezone ?? "UTC",
|
|
8435
5590
|
data.model ?? null,
|
|
8436
5591
|
data.modelTier ?? null,
|
|
@@ -8867,7 +6022,7 @@ export async function getInjectableGlobalConfigs(): Promise<SwarmConfig[]> {
|
|
|
8867
6022
|
const rows = await getDbClient().query<SwarmConfigRow>(
|
|
8868
6023
|
`SELECT * FROM swarm_config
|
|
8869
6024
|
WHERE scope = 'global'
|
|
8870
|
-
AND UPPER(key) NOT IN ('API_KEY', 'SECRETS_ENCRYPTION_KEY')
|
|
6025
|
+
AND UPPER(key) NOT IN ('API_KEY', 'SECRETS_ENCRYPTION_KEY', 'CORS_ALLOW_ANY_ORIGIN')
|
|
8871
6026
|
ORDER BY key ASC`,
|
|
8872
6027
|
);
|
|
8873
6028
|
return rows.map(rowToSwarmConfig);
|