@desplega.ai/agent-swarm 1.85.0 → 1.87.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.
Files changed (129) hide show
  1. package/README.md +1 -0
  2. package/openapi.json +72 -1
  3. package/package.json +10 -6
  4. package/src/be/db-queries/tracker.ts +21 -0
  5. package/src/be/db.ts +279 -14
  6. package/src/be/migrations/078_backfill_gpt_5_5_pricing.sql +15 -0
  7. package/src/be/migrations/079_task_followup_config.sql +1 -0
  8. package/src/be/modelsdev-cache.json +155618 -0
  9. package/src/be/modelsdev-cache.ts +46 -0
  10. package/src/be/seed-pricing.ts +7 -44
  11. package/src/cli.tsx +38 -2
  12. package/src/commands/codex-session-runner.ts +132 -0
  13. package/src/commands/context-preamble.ts +272 -0
  14. package/src/commands/credential-wait.ts +2 -2
  15. package/src/commands/e2b.ts +728 -0
  16. package/src/commands/provider-credentials.ts +10 -5
  17. package/src/commands/resume-session.ts +35 -78
  18. package/src/commands/runner.ts +128 -16
  19. package/src/e2b/dispatch.ts +429 -0
  20. package/src/e2b/env.ts +206 -0
  21. package/src/heartbeat/heartbeat.ts +145 -30
  22. package/src/heartbeat/templates.ts +11 -7
  23. package/src/http/session-data.ts +8 -1
  24. package/src/http/tasks.ts +152 -3
  25. package/src/jira/sync.ts +4 -4
  26. package/src/linear/sync.ts +6 -5
  27. package/src/prompts/base-prompt.ts +49 -3
  28. package/src/providers/claude-adapter.ts +76 -61
  29. package/src/providers/claude-managed-adapter.ts +61 -75
  30. package/src/providers/claude-managed-models.ts +18 -2
  31. package/src/providers/codex-adapter.ts +429 -112
  32. package/src/providers/codex-models.ts +9 -2
  33. package/src/providers/codex-oauth/auth-json.ts +18 -1
  34. package/src/providers/codex-oauth/flow.ts +24 -1
  35. package/src/providers/index.ts +28 -19
  36. package/src/providers/pricing-sources.md +7 -4
  37. package/src/providers/swarm-events-shared.ts +14 -0
  38. package/src/providers/types.ts +6 -0
  39. package/src/slack/HEURISTICS.md +5 -1
  40. package/src/slack/handlers.test.ts +35 -0
  41. package/src/slack/handlers.ts +79 -2
  42. package/src/tasks/worker-follow-up.ts +162 -2
  43. package/src/telemetry.ts +11 -1
  44. package/src/tests/base-prompt.test.ts +46 -8
  45. package/src/tests/claude-adapter.test.ts +5 -27
  46. package/src/tests/claude-managed-adapter.test.ts +42 -56
  47. package/src/tests/codex-adapter-otel.test.ts +4 -4
  48. package/src/tests/codex-adapter.test.ts +25 -37
  49. package/src/tests/codex-oauth.test.ts +149 -3
  50. package/src/tests/codex-pool.test.ts +14 -3
  51. package/src/tests/codex-swarm-events.test.ts +35 -0
  52. package/src/tests/context-window.test.ts +1 -0
  53. package/src/tests/credential-check.test.ts +48 -29
  54. package/src/tests/e2b-dispatch.test.ts +330 -0
  55. package/src/tests/entrypoint-config-env-export.test.ts +81 -0
  56. package/src/tests/follow-up-redelivery-guard.test.ts +165 -0
  57. package/src/tests/heartbeat-supersede-resume.test.ts +285 -0
  58. package/src/tests/heartbeat.test.ts +26 -16
  59. package/src/tests/migration-046-budgets.test.ts +6 -5
  60. package/src/tests/pricing-routes.test.ts +6 -5
  61. package/src/tests/prompt-template-remaining.test.ts +4 -0
  62. package/src/tests/provider-adapter.test.ts +10 -10
  63. package/src/tests/provider-command-format.test.ts +4 -4
  64. package/src/tests/resume-session.test.ts +42 -50
  65. package/src/tests/session-costs-codex-recompute.test.ts +25 -0
  66. package/src/tests/structured-output.test.ts +69 -0
  67. package/src/tests/task-completion-idempotency.test.ts +185 -2
  68. package/src/tests/task-supersede-resume.test.ts +722 -0
  69. package/src/tests/telemetry-init.test.ts +69 -0
  70. package/src/tests/vcs-tracking.test.ts +39 -0
  71. package/src/tools/send-task.ts +42 -10
  72. package/src/tools/store-progress.ts +2 -2
  73. package/src/tools/templates.ts +14 -2
  74. package/src/types.ts +46 -1
  75. package/src/utils/context-window.ts +1 -0
  76. package/src/workflows/executors/agent-task.ts +3 -0
  77. package/templates/schedules/daily-blocker-digest/config.json +13 -0
  78. package/templates/schedules/daily-blocker-digest/content.md +150 -0
  79. package/templates/schedules/daily-compounding-reflection/config.json +21 -0
  80. package/templates/schedules/daily-compounding-reflection/content.md +210 -0
  81. package/templates/schedules/daily-hn-briefing/config.json +13 -0
  82. package/templates/schedules/daily-hn-briefing/content.md +97 -0
  83. package/templates/schedules/daily-workflow-health-audit/config.json +13 -0
  84. package/templates/schedules/daily-workflow-health-audit/content.md +189 -0
  85. package/templates/schedules/gtm-weekly-review/config.json +13 -0
  86. package/templates/schedules/gtm-weekly-review/content.md +58 -0
  87. package/templates/schedules/weekly-dependabot-triage/config.json +13 -0
  88. package/templates/schedules/weekly-dependabot-triage/content.md +45 -0
  89. package/templates/schema.ts +26 -0
  90. package/templates/skills/agentmail-sending/config.json +13 -0
  91. package/templates/skills/agentmail-sending/content.md +48 -0
  92. package/templates/skills/artifacts/config.json +13 -0
  93. package/templates/skills/artifacts/content.md +87 -0
  94. package/templates/skills/browser-use-cloud/config.json +13 -0
  95. package/templates/skills/browser-use-cloud/content.md +155 -0
  96. package/templates/skills/desloppify/config.json +13 -0
  97. package/templates/skills/desloppify/content.md +201 -0
  98. package/templates/skills/exa-search/config.json +13 -0
  99. package/templates/skills/exa-search/content.md +106 -0
  100. package/templates/skills/jira-interaction/config.json +13 -0
  101. package/templates/skills/jira-interaction/content.md +252 -0
  102. package/templates/skills/kapso-whatsapp/config.json +13 -0
  103. package/templates/skills/kapso-whatsapp/content.md +369 -0
  104. package/templates/skills/kv-storage/config.json +13 -0
  105. package/templates/skills/kv-storage/content.md +111 -0
  106. package/templates/skills/linear-interaction/config.json +20 -0
  107. package/templates/skills/linear-interaction/content.md +230 -0
  108. package/templates/skills/pages/config.json +18 -0
  109. package/templates/skills/pages/content.md +85 -0
  110. package/templates/skills/profile-corruption-escalation/config.json +13 -0
  111. package/templates/skills/profile-corruption-escalation/content.md +105 -0
  112. package/templates/skills/scheduled-task-resilience/config.json +13 -0
  113. package/templates/skills/scheduled-task-resilience/content.md +95 -0
  114. package/templates/skills/sprite-cli/config.json +13 -0
  115. package/templates/skills/sprite-cli/content.md +133 -0
  116. package/templates/skills/turso-interaction/config.json +13 -0
  117. package/templates/skills/turso-interaction/content.md +192 -0
  118. package/templates/skills/workflow-iterate/config.json +18 -0
  119. package/templates/skills/workflow-iterate/content.md +399 -0
  120. package/templates/skills/workflow-structured-output/config.json +13 -0
  121. package/templates/skills/workflow-structured-output/content.md +101 -0
  122. package/templates/skills/x-api-interactions/config.json +13 -0
  123. package/templates/skills/x-api-interactions/content.md +109 -0
  124. package/templates/workflows/autopilot/config.json +13 -0
  125. package/templates/workflows/autopilot/content.md +58 -0
  126. package/templates/workflows/linear-drain-loop/config.json +21 -0
  127. package/templates/workflows/linear-drain-loop/content.md +72 -0
  128. package/templates/workflows/ralph-loop/config.json +13 -0
  129. package/templates/workflows/ralph-loop/content.md +75 -0
package/README.md CHANGED
@@ -124,6 +124,7 @@ Check [our templates](https://templates.agent-swarm.dev) for a quick start.
124
124
  - **Workflow engine with Human-in-the-Loop** — DAG-based automation with approval gates, retries, and structured I/O. [Workflows →](https://docs.agent-swarm.dev/docs/concepts/workflows)
125
125
  - **Scheduled & recurring tasks** — cron-based automation for standing work. [Scheduling →](https://docs.agent-swarm.dev/docs/concepts/scheduling)
126
126
  - **Harness & LLM agnostic** — run with Claude Code, OpenAI Codex, pi-mono, Devin, Claude Managed Agents, raw LLMs, or opencode. [Harness config →](https://docs.agent-swarm.dev/docs/guides/harness-configuration) · [Add a new provider →](https://docs.agent-swarm.dev/docs/guides/harness-providers)
127
+ - **Follow-up continuity across all harnesses** — child tasks inherit bounded prior-task context even on providers without native session resume, while resumable providers still reuse prior sessions when possible. [Task lifecycle →](https://docs.agent-swarm.dev/docs/concepts/task-lifecycle)
127
128
  - **Skills & MCP servers** — reusable procedural knowledge and per-agent MCP servers with scope cascade. [MCP tools →](https://docs.agent-swarm.dev/docs/reference/mcp-tools)
128
129
  - **DB-backed pages** — agents publish HTML or JSON pages (reports, dashboards, action specs) via the `create_page` MCP tool with public / authed / password modes, version history, view counters, diff helpers, and PDF export. [MCP tools → Pages](https://docs.agent-swarm.dev/docs/reference/mcp-tools#pages-tools)
129
130
  - **KV store** — Redis-like namespaced key/value store with auto-scoped context (Slack thread / PR / Linear issue / page). [MCP tools → KV](https://docs.agent-swarm.dev/docs/reference/mcp-tools#kv-tools)
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.85.0",
5
+ "version": "1.87.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -7139,6 +7139,16 @@
7139
7139
  "required": true,
7140
7140
  "name": "taskId",
7141
7141
  "in": "path"
7142
+ },
7143
+ {
7144
+ "schema": {
7145
+ "type": "integer",
7146
+ "minimum": 1,
7147
+ "maximum": 1000
7148
+ },
7149
+ "required": false,
7150
+ "name": "limit",
7151
+ "in": "query"
7142
7152
  }
7143
7153
  ],
7144
7154
  "responses": {
@@ -10114,6 +10124,67 @@
10114
10124
  }
10115
10125
  }
10116
10126
  },
10127
+ "/api/tasks/{id}/supersede": {
10128
+ "post": {
10129
+ "summary": "Supersede an in-progress task (terminate + spawn resume follow-up)",
10130
+ "description": "Marks the original task `superseded` (terminal) and creates a fresh `taskType=\"resume\"` follow-up so a worker can pick up the work in a new provider session. Workflow-step tasks (those with `workflowRunStepId`) are carved out: the original is marked `failed` with reason `superseded_workflow_task` and no follow-up is created — the workflow engine's retry/failure policy applies.",
10131
+ "tags": [
10132
+ "Tasks"
10133
+ ],
10134
+ "security": [
10135
+ {
10136
+ "bearerAuth": []
10137
+ }
10138
+ ],
10139
+ "parameters": [
10140
+ {
10141
+ "schema": {
10142
+ "type": "string"
10143
+ },
10144
+ "required": true,
10145
+ "name": "id",
10146
+ "in": "path"
10147
+ }
10148
+ ],
10149
+ "requestBody": {
10150
+ "content": {
10151
+ "application/json": {
10152
+ "schema": {
10153
+ "type": "object",
10154
+ "properties": {
10155
+ "reason": {
10156
+ "type": "string",
10157
+ "enum": [
10158
+ "graceful_shutdown",
10159
+ "context_limits",
10160
+ "manual_supersede",
10161
+ "crash_recovery"
10162
+ ]
10163
+ }
10164
+ },
10165
+ "required": [
10166
+ "reason"
10167
+ ]
10168
+ }
10169
+ }
10170
+ }
10171
+ },
10172
+ "responses": {
10173
+ "200": {
10174
+ "description": "Task superseded (or workflow-failed)"
10175
+ },
10176
+ "400": {
10177
+ "description": "Task not in_progress"
10178
+ },
10179
+ "403": {
10180
+ "description": "Task belongs to another agent"
10181
+ },
10182
+ "404": {
10183
+ "description": "Task not found"
10184
+ }
10185
+ }
10186
+ }
10187
+ },
10117
10188
  "/api/tasks/{id}/vcs": {
10118
10189
  "patch": {
10119
10190
  "summary": "Update VCS (PR/MR) info for a task",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.85.0",
3
+ "version": "1.87.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -45,6 +45,9 @@
45
45
  "tsc:check": "bun tsc --noEmit",
46
46
  "check:db-boundary": "bash scripts/check-db-boundary.sh",
47
47
  "check:api-key-boundary": "bash scripts/check-api-key-boundary.sh",
48
+ "prepare-release": "bun scripts/prepare-release.ts",
49
+ "sync-chart-version": "bun scripts/sync-chart-version.ts",
50
+ "check-chart-version": "bun scripts/sync-chart-version.ts --check-if-package-version-changed",
48
51
  "cli": "bun src/cli.tsx",
49
52
  "hook": "bun src/hooks/hook.ts",
50
53
  "claude": "bun src/cli.tsx claude",
@@ -108,12 +111,12 @@
108
111
  "@desplega.ai/localtunnel": "^2.2.0",
109
112
  "@inkjs/ui": "^2.0.0",
110
113
  "@linear/sdk": "^77.0.0",
111
- "@earendil-works/pi-agent-core": "^0.75.5",
112
- "@earendil-works/pi-ai": "^0.75.5",
113
- "@earendil-works/pi-coding-agent": "^0.75.5",
114
+ "@earendil-works/pi-agent-core": "^0.76.0",
115
+ "@earendil-works/pi-ai": "^0.76.0",
116
+ "@earendil-works/pi-coding-agent": "^0.76.0",
114
117
  "@modelcontextprotocol/sdk": "^1.25.1",
115
- "@openai/codex-sdk": "^0.133.0",
116
- "@opencode-ai/sdk": "^1.15.10",
118
+ "@openai/codex-sdk": "^0.135.0",
119
+ "@opencode-ai/sdk": "^1.15.12",
117
120
  "@openfort/openfort-node": "^0.9.1",
118
121
  "@opentelemetry/api": "^1.9.1",
119
122
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
@@ -128,6 +131,7 @@
128
131
  "ai": "^6.0.116",
129
132
  "cron-parser": "^5.4.0",
130
133
  "date-fns": "^4.1.0",
134
+ "e2b": "2.26.0",
131
135
  "hono": "^4.12.3",
132
136
  "ink": "^6.5.1",
133
137
  "oauth4webapi": "^3.8.5",
@@ -110,6 +110,27 @@ export function updateTrackerSyncSwarmId(id: string, swarmId: string): void {
110
110
  getDb().query("UPDATE tracker_sync SET swarmId = ? WHERE id = ?").run(swarmId, id);
111
111
  }
112
112
 
113
+ /**
114
+ * Repoint ALL `tracker_sync` rows currently keyed to `oldSwarmId` to
115
+ * `newSwarmId`. Returns the number of rows updated.
116
+ *
117
+ * Used when a task is superseded (PR #594): the supersede parent becomes
118
+ * terminal but the Linear/Jira issue is still active, and outbound
119
+ * completion posts + inbound webhooks lookup by swarmId. Without
120
+ * repointing, the resume child's completion never makes it back to the
121
+ * tracker and subsequent inbound events load the terminal parent and
122
+ * create duplicates.
123
+ *
124
+ * Safe to call when no rows match (no-op, returns 0). Repoints across
125
+ * all providers (Linear AND Jira) and all entity types in one call.
126
+ */
127
+ export function repointTrackerSyncBySwarmId(oldSwarmId: string, newSwarmId: string): number {
128
+ const result = getDb()
129
+ .query("UPDATE tracker_sync SET swarmId = ? WHERE swarmId = ?")
130
+ .run(newSwarmId, oldSwarmId);
131
+ return Number(result.changes ?? 0);
132
+ }
133
+
113
134
  export function createTrackerSync(data: {
114
135
  provider: string;
115
136
  entityType: "task";
package/src/be/db.ts CHANGED
@@ -29,6 +29,7 @@ import type {
29
29
  ContextSnapshotEventType,
30
30
  ContextVersion,
31
31
  CooldownConfig,
32
+ FollowUpConfig,
32
33
  InboxItemState,
33
34
  InboxItemStatus,
34
35
  InboxItemType,
@@ -87,6 +88,7 @@ import type {
87
88
  WorkflowSummary,
88
89
  WorkflowVersion,
89
90
  } from "../types";
91
+ import { FollowUpConfigSchema, isTerminalTaskStatus } from "../types";
90
92
  import { deriveProviderFromKeyType } from "../utils/credentials";
91
93
  import { scrubSecrets } from "../utils/secret-scrubber";
92
94
  import { decryptSecret, encryptSecret, getEncryptionKey, resolveEncryptionKey } from "./crypto";
@@ -993,6 +995,7 @@ type AgentTaskRow = {
993
995
  workflowRunId: string | null;
994
996
  workflowRunStepId: string | null;
995
997
  outputSchema: string | null;
998
+ followUpConfig: string | null;
996
999
  contextKey: string | null;
997
1000
  createdAt: string;
998
1001
  lastUpdatedAt: string;
@@ -1016,6 +1019,27 @@ type AgentTaskRow = {
1016
1019
  };
1017
1020
 
1018
1021
  function rowToAgentTask(row: AgentTaskRow): AgentTask {
1022
+ let followUpConfig: FollowUpConfig | undefined;
1023
+ if (row.followUpConfig) {
1024
+ try {
1025
+ const parsed = FollowUpConfigSchema.safeParse(JSON.parse(row.followUpConfig));
1026
+ if (parsed.success) {
1027
+ followUpConfig = parsed.data;
1028
+ } else {
1029
+ console.warn(
1030
+ `[db] Ignoring invalid agent_tasks.followUpConfig for task ${row.id}:`,
1031
+ parsed.error.message,
1032
+ );
1033
+ }
1034
+ } catch (error) {
1035
+ console.warn(
1036
+ `[db] Ignoring malformed agent_tasks.followUpConfig for task ${row.id}:`,
1037
+ error instanceof Error ? error.message : String(error),
1038
+ );
1039
+ followUpConfig = undefined;
1040
+ }
1041
+ }
1042
+
1019
1043
  return {
1020
1044
  id: row.id,
1021
1045
  agentId: row.agentId,
@@ -1057,6 +1081,7 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
1057
1081
  workflowRunId: row.workflowRunId ?? undefined,
1058
1082
  workflowRunStepId: row.workflowRunStepId ?? undefined,
1059
1083
  outputSchema: row.outputSchema ? JSON.parse(row.outputSchema) : undefined,
1084
+ followUpConfig,
1060
1085
  contextKey: row.contextKey ?? undefined,
1061
1086
  compactionCount: row.compactionCount ?? undefined,
1062
1087
  peakContextPercent: row.peakContextPercent ?? undefined,
@@ -1173,7 +1198,7 @@ export const taskQueries = {
1173
1198
  setProgress: () =>
1174
1199
  getDb().prepare<AgentTaskRow, [string, string]>(
1175
1200
  `UPDATE agent_tasks SET progress = ?,
1176
- status = CASE WHEN status IN ('completed', 'failed', 'cancelled') THEN status ELSE 'in_progress' END,
1201
+ status = CASE WHEN status IN ('completed', 'failed', 'cancelled', 'superseded') THEN status ELSE 'in_progress' END,
1177
1202
  lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
1178
1203
  WHERE id = ? RETURNING *`,
1179
1204
  ),
@@ -1244,14 +1269,14 @@ export function startTask(taskId: string): AgentTask | null {
1244
1269
  if (!oldTask) return null;
1245
1270
 
1246
1271
  // Guard: never revive tasks that are already in a terminal state
1247
- if (["completed", "failed", "cancelled"].includes(oldTask.status)) {
1272
+ if (isTerminalTaskStatus(oldTask.status)) {
1248
1273
  return null;
1249
1274
  }
1250
1275
 
1251
1276
  const row = getDb()
1252
1277
  .prepare<AgentTaskRow, [string]>(
1253
1278
  `UPDATE agent_tasks SET status = 'in_progress', lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
1254
- WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled') RETURNING *`,
1279
+ WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
1255
1280
  )
1256
1281
  .get(taskId);
1257
1282
  if (row && oldTask) {
@@ -1291,6 +1316,31 @@ export function getChildTasks(parentTaskId: string): AgentTask[] {
1291
1316
  .map(rowToAgentTask);
1292
1317
  }
1293
1318
 
1319
+ /**
1320
+ * Returns true if `parentId` has at least one non-terminal child task with
1321
+ * `taskType = 'resume'`. Used by the heartbeat sweep as an idempotency guard:
1322
+ * if a prior sweep tick already created a resume follow-up for this parent,
1323
+ * don't create a duplicate.
1324
+ *
1325
+ * **Filters by taskType = 'resume'** specifically. A parent task can also
1326
+ * have ordinary non-terminal delegation children (`send-task` auto-defaults
1327
+ * `parentTaskId` to the caller's current task — see src/tools/send-task.ts).
1328
+ * Treating those as "already resumed" would incorrectly skip the resume
1329
+ * path for a crashed worker that had delegated subtasks (PR #594 review).
1330
+ */
1331
+ export function hasNonTerminalResumeChild(parentId: string): boolean {
1332
+ const row = getDb()
1333
+ .prepare(
1334
+ `SELECT 1 FROM agent_tasks
1335
+ WHERE parentTaskId = ?
1336
+ AND taskType = 'resume'
1337
+ AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
1338
+ LIMIT 1`,
1339
+ )
1340
+ .get(parentId);
1341
+ return row !== undefined && row !== null;
1342
+ }
1343
+
1294
1344
  export function updateTaskClaudeSessionId(
1295
1345
  taskId: string,
1296
1346
  claudeSessionId: string,
@@ -1370,14 +1420,18 @@ export function getTasksByStatus(status: AgentTaskStatus): AgentTask[] {
1370
1420
 
1371
1421
  /**
1372
1422
  * Find a task by VCS repo and issue/PR/MR number.
1373
- * Returns the most recent non-completed/failed task for this VCS entity.
1423
+ * Returns the most recent non-terminal task for this VCS entity.
1424
+ *
1425
+ * Terminal exclusion MUST stay in lock-step with `TERMINAL_TASK_STATUSES`
1426
+ * in `src/types.ts`. SQL strings can't import a TS const — if you add a
1427
+ * new terminal status, grep for `NOT IN ('completed'` across this file.
1374
1428
  */
1375
1429
  export function findTaskByVcs(vcsRepo: string, vcsNumber: number): AgentTask | null {
1376
1430
  const row = getDb()
1377
1431
  .prepare<AgentTaskRow, [string, number]>(
1378
1432
  `SELECT * FROM agent_tasks
1379
1433
  WHERE vcsRepo = ? AND vcsNumber = ?
1380
- AND status NOT IN ('completed', 'failed')
1434
+ AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
1381
1435
  ORDER BY createdAt DESC
1382
1436
  LIMIT 1`,
1383
1437
  )
@@ -1875,6 +1929,50 @@ export function findCompletedTaskInThread(
1875
1929
  return row ? rowToAgentTask(row) : null;
1876
1930
  }
1877
1931
 
1932
+ /**
1933
+ * Find the most recent CANCELLED task in a Slack thread. Used by the
1934
+ * follow-up re-delegation guard so a cancellation (worker SIGTERM,
1935
+ * runner-side abort, swarm-events tool-loop abort) doesn't permanently
1936
+ * jam re-dispatch when an earlier sibling task in the same thread also
1937
+ * completed.
1938
+ *
1939
+ * Matches both:
1940
+ * - `status = 'cancelled'` (the canonical terminal state from cancelTask)
1941
+ * - `status = 'failed'` with a failureReason that starts with "cancelled"
1942
+ * or "exit 130" or contains "cancelled" (the codex-adapter abort path
1943
+ * emits `failureReason: "cancelled"` and exits 130).
1944
+ */
1945
+ export function findRecentCancelledTaskInThread(
1946
+ channelId: string,
1947
+ threadTs: string,
1948
+ windowMinutes: number,
1949
+ ): AgentTask | null {
1950
+ const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
1951
+ const row = getDb()
1952
+ .prepare<AgentTaskRow, [string, string, string]>(
1953
+ `SELECT * FROM agent_tasks
1954
+ WHERE slackChannelId = ?
1955
+ AND slackThreadTs = ?
1956
+ AND lastUpdatedAt > ?
1957
+ AND (
1958
+ status = 'cancelled'
1959
+ OR (
1960
+ status = 'failed'
1961
+ AND failureReason IS NOT NULL
1962
+ AND (
1963
+ failureReason LIKE 'cancelled%'
1964
+ OR failureReason LIKE 'exit 130%'
1965
+ OR failureReason LIKE '%cancelled%'
1966
+ )
1967
+ )
1968
+ )
1969
+ ORDER BY lastUpdatedAt DESC
1970
+ LIMIT 1`,
1971
+ )
1972
+ .get(channelId, threadTs, since);
1973
+ return row ? rowToAgentTask(row) : null;
1974
+ }
1975
+
1878
1976
  export function completeTask(id: string, output?: string): AgentTask | null {
1879
1977
  const oldTask = getTaskById(id);
1880
1978
  if (!oldTask) return null;
@@ -1882,7 +1980,7 @@ export function completeTask(id: string, output?: string): AgentTask | null {
1882
1980
  // Idempotency guard: don't re-complete a task already in a terminal state.
1883
1981
  // Mirrors cancelTask. Prevents duplicate task.completed events, duplicate
1884
1982
  // log entries, and duplicate follow-up tasks when multiple sessions race.
1885
- if (["completed", "failed", "cancelled"].includes(oldTask.status)) {
1983
+ if (isTerminalTaskStatus(oldTask.status)) {
1886
1984
  return null;
1887
1985
  }
1888
1986
 
@@ -1927,7 +2025,7 @@ export function failTask(id: string, reason: string): AgentTask | null {
1927
2025
  // Idempotency guard: don't re-fail a task already in a terminal state.
1928
2026
  // Mirrors cancelTask / completeTask. Prevents duplicate task.failed events
1929
2027
  // and duplicate follow-up tasks when multiple sessions race.
1930
- if (["completed", "failed", "cancelled"].includes(oldTask.status)) {
2028
+ if (isTerminalTaskStatus(oldTask.status)) {
1931
2029
  return null;
1932
2030
  }
1933
2031
 
@@ -1964,8 +2062,7 @@ export function cancelTask(id: string, reason?: string): AgentTask | null {
1964
2062
  if (!oldTask) return null;
1965
2063
 
1966
2064
  // Only cancel tasks that are not already in a terminal state
1967
- const terminalStatuses = ["completed", "failed", "cancelled"];
1968
- if (terminalStatuses.includes(oldTask.status)) {
2065
+ if (isTerminalTaskStatus(oldTask.status)) {
1969
2066
  return null;
1970
2067
  }
1971
2068
 
@@ -1999,6 +2096,69 @@ export function cancelTask(id: string, reason?: string): AgentTask | null {
1999
2096
  return row ? rowToAgentTask(row) : null;
2000
2097
  }
2001
2098
 
2099
+ /**
2100
+ * Supersede a task: mark it as `superseded` (terminal) so a fresh "resume"
2101
+ * follow-up task can pick up where it left off. Used by the graceful-shutdown
2102
+ * path and the `POST /api/tasks/:id/supersede` route. Returns null if the task
2103
+ * is already terminal (mirrors `completeTask` / `cancelTask` idempotency).
2104
+ *
2105
+ * Writes a `task_superseded` agent_log with `{ reason, resumeTaskId }` payload
2106
+ * and emits a `task.superseded` workflow event. The caller is responsible for
2107
+ * creating the resume follow-up (via `createResumeFollowUp`) and passing the
2108
+ * resulting id as `resumeTaskId`.
2109
+ */
2110
+ export function supersedeTask(
2111
+ id: string,
2112
+ args: { reason: string; resumeTaskId: string | null },
2113
+ ): AgentTask | null {
2114
+ const oldTask = getTaskById(id);
2115
+ if (!oldTask) return null;
2116
+
2117
+ // Idempotency guard: don't re-supersede a task already in a terminal state.
2118
+ if (isTerminalTaskStatus(oldTask.status)) {
2119
+ return null;
2120
+ }
2121
+
2122
+ const finishedAt = new Date().toISOString();
2123
+ const row = getDb()
2124
+ .prepare<AgentTaskRow, [string, string]>(
2125
+ `UPDATE agent_tasks
2126
+ SET status = 'superseded',
2127
+ finishedAt = ?,
2128
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
2129
+ WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
2130
+ RETURNING *`,
2131
+ )
2132
+ .get(finishedAt, id);
2133
+
2134
+ if (row && oldTask) {
2135
+ try {
2136
+ createLogEntry({
2137
+ eventType: "task_superseded",
2138
+ taskId: id,
2139
+ agentId: row.agentId ?? undefined,
2140
+ oldValue: oldTask.status,
2141
+ newValue: "superseded",
2142
+ metadata: { reason: args.reason, resumeTaskId: args.resumeTaskId },
2143
+ });
2144
+ } catch {}
2145
+ try {
2146
+ import("../workflows/event-bus").then(({ workflowEventBus }) => {
2147
+ workflowEventBus.emit("task.superseded", {
2148
+ taskId: id,
2149
+ reason: args.reason,
2150
+ resumeTaskId: args.resumeTaskId,
2151
+ agentId: row.agentId,
2152
+ workflowRunId: row.workflowRunId,
2153
+ workflowRunStepId: row.workflowRunStepId,
2154
+ });
2155
+ });
2156
+ } catch {}
2157
+ }
2158
+
2159
+ return row ? rowToAgentTask(row) : null;
2160
+ }
2161
+
2002
2162
  /**
2003
2163
  * Pause a task that is currently in progress.
2004
2164
  * Used during graceful shutdown to allow tasks to resume after container restart.
@@ -2516,6 +2676,7 @@ export interface CreateTaskOptions {
2516
2676
  * a schema'd task should be defensive about JSON parsing.
2517
2677
  */
2518
2678
  outputSchema?: Record<string, unknown>;
2679
+ followUpConfig?: FollowUpConfig;
2519
2680
  requestedByUserId?: string;
2520
2681
  contextKey?: string;
2521
2682
  }
@@ -2534,8 +2695,9 @@ export function findRecentSimilarTasks(opts: {
2534
2695
  const conditions: string[] = ["createdAt > ?"];
2535
2696
  const params: (string | number)[] = [since];
2536
2697
 
2537
- // Exclude completed/failed/cancelled tasks — only active or recently created
2538
- conditions.push("status NOT IN ('completed', 'failed', 'cancelled')");
2698
+ // Exclude all terminal statuses — only active or recently created.
2699
+ // Keep in lock-step with `TERMINAL_TASK_STATUSES` in src/types.ts.
2700
+ conditions.push("status NOT IN ('completed', 'failed', 'cancelled', 'superseded')");
2539
2701
 
2540
2702
  if (opts.creatorAgentId) {
2541
2703
  conditions.push("creatorAgentId = ?");
@@ -2570,6 +2732,16 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2570
2732
  if (options?.parentTaskId) {
2571
2733
  const parent = getTaskById(options.parentTaskId);
2572
2734
  if (parent) {
2735
+ // Identity & routing — anything that says "what work is this, who asked
2736
+ // for it, where does it run" carries forward to every child (follow-ups,
2737
+ // reboot retries, resume tasks). Explicit options always win.
2738
+ //
2739
+ // When adding a new identity-shaped column to `agent_tasks`, ADD IT HERE
2740
+ // unless you have a specific reason a child should NOT inherit it. This
2741
+ // is the single source of truth — `createResumeFollowUp` and the other
2742
+ // follow-up creators rely on this block instead of re-listing fields.
2743
+
2744
+ // Slack context
2573
2745
  if (parent.slackChannelId && !options.slackChannelId) {
2574
2746
  options.slackChannelId = parent.slackChannelId;
2575
2747
  }
@@ -2579,18 +2751,98 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2579
2751
  if (parent.slackUserId && !options.slackUserId) {
2580
2752
  options.slackUserId = parent.slackUserId;
2581
2753
  }
2754
+
2755
+ // AgentMail context
2582
2756
  if (parent.agentmailInboxId && !options.agentmailInboxId) {
2583
2757
  options.agentmailInboxId = parent.agentmailInboxId;
2584
2758
  }
2759
+ if (parent.agentmailMessageId && !options.agentmailMessageId) {
2760
+ options.agentmailMessageId = parent.agentmailMessageId;
2761
+ }
2585
2762
  if (parent.agentmailThreadId && !options.agentmailThreadId) {
2586
2763
  options.agentmailThreadId = parent.agentmailThreadId;
2587
2764
  }
2765
+
2766
+ // Mention context (Slack @-mentions)
2767
+ if (parent.mentionMessageId && !options.mentionMessageId) {
2768
+ options.mentionMessageId = parent.mentionMessageId;
2769
+ }
2770
+ if (parent.mentionChannelId && !options.mentionChannelId) {
2771
+ options.mentionChannelId = parent.mentionChannelId;
2772
+ }
2773
+
2774
+ // VCS identity (GitHub / GitLab issue / PR / MR + webhook routing)
2775
+ // Webhook handlers locate active work via `findTaskByVcs(repo, number)`,
2776
+ // so a resume / follow-up child MUST carry the full VCS identity or
2777
+ // subsequent review/update events get dropped.
2778
+ if (parent.vcsProvider && !options.vcsProvider) {
2779
+ options.vcsProvider = parent.vcsProvider;
2780
+ }
2781
+ if (parent.vcsRepo && !options.vcsRepo) {
2782
+ options.vcsRepo = parent.vcsRepo;
2783
+ }
2784
+ if (parent.vcsNumber != null && options.vcsNumber == null) {
2785
+ options.vcsNumber = parent.vcsNumber;
2786
+ }
2787
+ if (parent.vcsEventType && !options.vcsEventType) {
2788
+ options.vcsEventType = parent.vcsEventType;
2789
+ }
2790
+ if (parent.vcsCommentId != null && options.vcsCommentId == null) {
2791
+ options.vcsCommentId = parent.vcsCommentId;
2792
+ }
2793
+ if (parent.vcsAuthor && !options.vcsAuthor) {
2794
+ options.vcsAuthor = parent.vcsAuthor;
2795
+ }
2796
+ if (parent.vcsUrl && !options.vcsUrl) {
2797
+ options.vcsUrl = parent.vcsUrl;
2798
+ }
2799
+ if (parent.vcsInstallationId != null && options.vcsInstallationId == null) {
2800
+ options.vcsInstallationId = parent.vcsInstallationId;
2801
+ }
2802
+ if (parent.vcsNodeId && !options.vcsNodeId) {
2803
+ options.vcsNodeId = parent.vcsNodeId;
2804
+ }
2805
+
2806
+ // Execution context (per-task overrides)
2807
+ //
2808
+ // `model` is DELIBERATELY NOT inherited. A parent task's `model` is a
2809
+ // concrete, provider-specific resolved string (e.g. `claude-opus-4-8`,
2810
+ // `openrouter/moonshotai/kimi-k2.6`). Derived tasks (resume follow-ups,
2811
+ // completion/review follow-ups, re-dispatches) routinely land on a
2812
+ // DIFFERENT agent — and therefore a different harness/provider — than the
2813
+ // parent. Carrying the parent's concrete model across that boundary makes
2814
+ // the child die at session-init with a model-incompatibility error before
2815
+ // any worker code runs (e.g. a `claude-opus-4-8` resume claimed by a Codex
2816
+ // worker → `400 model is not supported when using Codex`, or a
2817
+ // `kimi-k2.6` review follow-up routed to a Claude-harness Lead → session
2818
+ // exit 1). Per Taras's directive (2026-05-29): derived tasks must never
2819
+ // set the model — it resolves from the ASSIGNEE agent's own provider /
2820
+ // `MODEL_OVERRIDE` config at session-init (see
2821
+ // `src/commands/runner.ts` — `opts.model || configModel`). A null `model`
2822
+ // here is the correct, intended state. Do NOT re-add inheritance here; if
2823
+ // a same-provider child genuinely needs a specific model, the creator must
2824
+ // pass it explicitly.
2825
+ if (parent.dir && !options.dir) {
2826
+ options.dir = parent.dir;
2827
+ }
2828
+
2829
+ // Contract (schema validation) — `store-progress` validates completion
2830
+ // output against `outputSchema`, runner injects structured-output
2831
+ // instructions only when it's present.
2832
+ if (parent.outputSchema && !options.outputSchema) {
2833
+ options.outputSchema = parent.outputSchema;
2834
+ }
2835
+
2836
+ // Attribution
2588
2837
  if (parent.requestedByUserId && !options.requestedByUserId) {
2589
2838
  options.requestedByUserId = parent.requestedByUserId;
2590
2839
  }
2591
2840
  if (parent.contextKey && !options.contextKey) {
2592
2841
  options.contextKey = parent.contextKey;
2593
2842
  }
2843
+ if (parent.followUpConfig && !options.followUpConfig) {
2844
+ options.followUpConfig = parent.followUpConfig;
2845
+ }
2594
2846
  }
2595
2847
  }
2596
2848
 
@@ -2616,8 +2868,8 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2616
2868
  vcsInstallationId, vcsNodeId,
2617
2869
  agentmailInboxId, agentmailMessageId, agentmailThreadId,
2618
2870
  mentionMessageId, mentionChannelId, dir, parentTaskId, model, scheduleId,
2619
- workflowRunId, workflowRunStepId, outputSchema, requestedByUserId, contextKey, swarmVersion, createdAt, lastUpdatedAt
2620
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
2871
+ workflowRunId, workflowRunStepId, outputSchema, followUpConfig, requestedByUserId, contextKey, swarmVersion, createdAt, lastUpdatedAt
2872
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
2621
2873
  )
2622
2874
  .get(
2623
2875
  id,
@@ -2656,6 +2908,7 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2656
2908
  options?.workflowRunId ?? null,
2657
2909
  options?.workflowRunStepId ?? null,
2658
2910
  options?.outputSchema ? JSON.stringify(options.outputSchema) : null,
2911
+ options?.followUpConfig ? JSON.stringify(options.followUpConfig) : null,
2659
2912
  options?.requestedByUserId ?? null,
2660
2913
  options?.contextKey ?? null,
2661
2914
  pkg.version,
@@ -3973,6 +4226,15 @@ export const sessionLogQueries = {
3973
4226
  "SELECT * FROM session_logs WHERE taskId = ? ORDER BY iteration ASC, lineNumber ASC",
3974
4227
  ),
3975
4228
 
4229
+ getRecentByTaskId: () =>
4230
+ getDb().prepare<SessionLogRow, [string, number]>(
4231
+ `SELECT * FROM (
4232
+ SELECT * FROM session_logs WHERE taskId = ?
4233
+ ORDER BY iteration DESC, lineNumber DESC
4234
+ LIMIT ?
4235
+ ) ORDER BY iteration ASC, lineNumber ASC`,
4236
+ ),
4237
+
3976
4238
  getBySessionId: () =>
3977
4239
  getDb().prepare<SessionLogRow, [string, number]>(
3978
4240
  "SELECT * FROM session_logs WHERE sessionId = ? AND iteration = ? ORDER BY lineNumber ASC",
@@ -4008,7 +4270,10 @@ export function createSessionLogs(logs: {
4008
4270
  })();
4009
4271
  }
4010
4272
 
4011
- export function getSessionLogsByTaskId(taskId: string): SessionLog[] {
4273
+ export function getSessionLogsByTaskId(taskId: string, limit?: number): SessionLog[] {
4274
+ if (typeof limit === "number" && limit > 0) {
4275
+ return sessionLogQueries.getRecentByTaskId().all(taskId, limit).map(rowToSessionLog);
4276
+ }
4012
4277
  return sessionLogQueries.getByTaskId().all(taskId).map(rowToSessionLog);
4013
4278
  }
4014
4279
 
@@ -0,0 +1,15 @@
1
+ -- 078_backfill_gpt_5_5_pricing.sql
2
+ -- Backfill Codex GPT-5.5 pricing into existing databases.
3
+ --
4
+ -- The vendored models.dev cache already contains gpt-5.5, and fresh server
5
+ -- boots seed it from src/be/seed-pricing.ts. Existing long-lived DBs can still
6
+ -- be missing those rows, which makes real gpt-5.5 Codex runs land as
7
+ -- costSource='unpriced'. Keep this migration idempotent so every environment
8
+ -- gets the baseline Standard-tier rates.
9
+
10
+ INSERT OR IGNORE INTO pricing
11
+ (provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt)
12
+ VALUES
13
+ ('codex', 'gpt-5.5', 'input', 0, 5.0, 0, 0),
14
+ ('codex', 'gpt-5.5', 'cached_input', 0, 0.5, 0, 0),
15
+ ('codex', 'gpt-5.5', 'output', 0, 30.0, 0, 0);
@@ -0,0 +1 @@
1
+ ALTER TABLE agent_tasks ADD COLUMN followUpConfig TEXT;