@desplega.ai/agent-swarm 1.86.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 (47) hide show
  1. package/openapi.json +72 -1
  2. package/package.json +3 -1
  3. package/src/be/db-queries/tracker.ts +21 -0
  4. package/src/be/db.ts +235 -14
  5. package/src/be/migrations/079_task_followup_config.sql +1 -0
  6. package/src/be/modelsdev-cache.json +77663 -74073
  7. package/src/cli.tsx +26 -0
  8. package/src/commands/context-preamble.ts +272 -0
  9. package/src/commands/e2b.ts +728 -0
  10. package/src/commands/resume-session.ts +35 -78
  11. package/src/commands/runner.ts +125 -13
  12. package/src/e2b/dispatch.ts +429 -0
  13. package/src/e2b/env.ts +206 -0
  14. package/src/heartbeat/heartbeat.ts +145 -30
  15. package/src/heartbeat/templates.ts +11 -7
  16. package/src/http/session-data.ts +8 -1
  17. package/src/http/tasks.ts +152 -3
  18. package/src/jira/sync.ts +4 -4
  19. package/src/linear/sync.ts +6 -5
  20. package/src/providers/claude-adapter.ts +10 -76
  21. package/src/providers/claude-managed-adapter.ts +61 -75
  22. package/src/providers/codex-adapter.ts +15 -18
  23. package/src/providers/codex-oauth/auth-json.ts +18 -1
  24. package/src/providers/codex-oauth/flow.ts +24 -1
  25. package/src/providers/types.ts +6 -0
  26. package/src/tasks/worker-follow-up.ts +162 -2
  27. package/src/telemetry.ts +11 -1
  28. package/src/tests/claude-adapter.test.ts +5 -27
  29. package/src/tests/claude-managed-adapter.test.ts +38 -52
  30. package/src/tests/codex-adapter.test.ts +6 -31
  31. package/src/tests/codex-oauth.test.ts +149 -3
  32. package/src/tests/codex-pool.test.ts +14 -3
  33. package/src/tests/e2b-dispatch.test.ts +330 -0
  34. package/src/tests/heartbeat-supersede-resume.test.ts +285 -0
  35. package/src/tests/heartbeat.test.ts +26 -16
  36. package/src/tests/prompt-template-remaining.test.ts +4 -0
  37. package/src/tests/resume-session.test.ts +42 -50
  38. package/src/tests/structured-output.test.ts +69 -0
  39. package/src/tests/task-completion-idempotency.test.ts +185 -2
  40. package/src/tests/task-supersede-resume.test.ts +722 -0
  41. package/src/tests/telemetry-init.test.ts +69 -0
  42. package/src/tests/vcs-tracking.test.ts +39 -0
  43. package/src/tools/send-task.ts +12 -1
  44. package/src/tools/store-progress.ts +2 -2
  45. package/src/tools/templates.ts +14 -2
  46. package/src/types.ts +46 -1
  47. package/src/workflows/executors/agent-task.ts +3 -0
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.86.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.86.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,7 @@
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",
48
49
  "sync-chart-version": "bun scripts/sync-chart-version.ts",
49
50
  "check-chart-version": "bun scripts/sync-chart-version.ts --check-if-package-version-changed",
50
51
  "cli": "bun src/cli.tsx",
@@ -130,6 +131,7 @@
130
131
  "ai": "^6.0.116",
131
132
  "cron-parser": "^5.4.0",
132
133
  "date-fns": "^4.1.0",
134
+ "e2b": "2.26.0",
133
135
  "hono": "^4.12.3",
134
136
  "ink": "^6.5.1",
135
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
  )
@@ -1926,7 +1980,7 @@ export function completeTask(id: string, output?: string): AgentTask | null {
1926
1980
  // Idempotency guard: don't re-complete a task already in a terminal state.
1927
1981
  // Mirrors cancelTask. Prevents duplicate task.completed events, duplicate
1928
1982
  // log entries, and duplicate follow-up tasks when multiple sessions race.
1929
- if (["completed", "failed", "cancelled"].includes(oldTask.status)) {
1983
+ if (isTerminalTaskStatus(oldTask.status)) {
1930
1984
  return null;
1931
1985
  }
1932
1986
 
@@ -1971,7 +2025,7 @@ export function failTask(id: string, reason: string): AgentTask | null {
1971
2025
  // Idempotency guard: don't re-fail a task already in a terminal state.
1972
2026
  // Mirrors cancelTask / completeTask. Prevents duplicate task.failed events
1973
2027
  // and duplicate follow-up tasks when multiple sessions race.
1974
- if (["completed", "failed", "cancelled"].includes(oldTask.status)) {
2028
+ if (isTerminalTaskStatus(oldTask.status)) {
1975
2029
  return null;
1976
2030
  }
1977
2031
 
@@ -2008,8 +2062,7 @@ export function cancelTask(id: string, reason?: string): AgentTask | null {
2008
2062
  if (!oldTask) return null;
2009
2063
 
2010
2064
  // Only cancel tasks that are not already in a terminal state
2011
- const terminalStatuses = ["completed", "failed", "cancelled"];
2012
- if (terminalStatuses.includes(oldTask.status)) {
2065
+ if (isTerminalTaskStatus(oldTask.status)) {
2013
2066
  return null;
2014
2067
  }
2015
2068
 
@@ -2043,6 +2096,69 @@ export function cancelTask(id: string, reason?: string): AgentTask | null {
2043
2096
  return row ? rowToAgentTask(row) : null;
2044
2097
  }
2045
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
+
2046
2162
  /**
2047
2163
  * Pause a task that is currently in progress.
2048
2164
  * Used during graceful shutdown to allow tasks to resume after container restart.
@@ -2560,6 +2676,7 @@ export interface CreateTaskOptions {
2560
2676
  * a schema'd task should be defensive about JSON parsing.
2561
2677
  */
2562
2678
  outputSchema?: Record<string, unknown>;
2679
+ followUpConfig?: FollowUpConfig;
2563
2680
  requestedByUserId?: string;
2564
2681
  contextKey?: string;
2565
2682
  }
@@ -2578,8 +2695,9 @@ export function findRecentSimilarTasks(opts: {
2578
2695
  const conditions: string[] = ["createdAt > ?"];
2579
2696
  const params: (string | number)[] = [since];
2580
2697
 
2581
- // Exclude completed/failed/cancelled tasks — only active or recently created
2582
- 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')");
2583
2701
 
2584
2702
  if (opts.creatorAgentId) {
2585
2703
  conditions.push("creatorAgentId = ?");
@@ -2614,6 +2732,16 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2614
2732
  if (options?.parentTaskId) {
2615
2733
  const parent = getTaskById(options.parentTaskId);
2616
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
2617
2745
  if (parent.slackChannelId && !options.slackChannelId) {
2618
2746
  options.slackChannelId = parent.slackChannelId;
2619
2747
  }
@@ -2623,18 +2751,98 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2623
2751
  if (parent.slackUserId && !options.slackUserId) {
2624
2752
  options.slackUserId = parent.slackUserId;
2625
2753
  }
2754
+
2755
+ // AgentMail context
2626
2756
  if (parent.agentmailInboxId && !options.agentmailInboxId) {
2627
2757
  options.agentmailInboxId = parent.agentmailInboxId;
2628
2758
  }
2759
+ if (parent.agentmailMessageId && !options.agentmailMessageId) {
2760
+ options.agentmailMessageId = parent.agentmailMessageId;
2761
+ }
2629
2762
  if (parent.agentmailThreadId && !options.agentmailThreadId) {
2630
2763
  options.agentmailThreadId = parent.agentmailThreadId;
2631
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
2632
2837
  if (parent.requestedByUserId && !options.requestedByUserId) {
2633
2838
  options.requestedByUserId = parent.requestedByUserId;
2634
2839
  }
2635
2840
  if (parent.contextKey && !options.contextKey) {
2636
2841
  options.contextKey = parent.contextKey;
2637
2842
  }
2843
+ if (parent.followUpConfig && !options.followUpConfig) {
2844
+ options.followUpConfig = parent.followUpConfig;
2845
+ }
2638
2846
  }
2639
2847
  }
2640
2848
 
@@ -2660,8 +2868,8 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2660
2868
  vcsInstallationId, vcsNodeId,
2661
2869
  agentmailInboxId, agentmailMessageId, agentmailThreadId,
2662
2870
  mentionMessageId, mentionChannelId, dir, parentTaskId, model, scheduleId,
2663
- workflowRunId, workflowRunStepId, outputSchema, requestedByUserId, contextKey, swarmVersion, createdAt, lastUpdatedAt
2664
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
2871
+ workflowRunId, workflowRunStepId, outputSchema, followUpConfig, requestedByUserId, contextKey, swarmVersion, createdAt, lastUpdatedAt
2872
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
2665
2873
  )
2666
2874
  .get(
2667
2875
  id,
@@ -2700,6 +2908,7 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2700
2908
  options?.workflowRunId ?? null,
2701
2909
  options?.workflowRunStepId ?? null,
2702
2910
  options?.outputSchema ? JSON.stringify(options.outputSchema) : null,
2911
+ options?.followUpConfig ? JSON.stringify(options.followUpConfig) : null,
2703
2912
  options?.requestedByUserId ?? null,
2704
2913
  options?.contextKey ?? null,
2705
2914
  pkg.version,
@@ -4017,6 +4226,15 @@ export const sessionLogQueries = {
4017
4226
  "SELECT * FROM session_logs WHERE taskId = ? ORDER BY iteration ASC, lineNumber ASC",
4018
4227
  ),
4019
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
+
4020
4238
  getBySessionId: () =>
4021
4239
  getDb().prepare<SessionLogRow, [string, number]>(
4022
4240
  "SELECT * FROM session_logs WHERE sessionId = ? AND iteration = ? ORDER BY lineNumber ASC",
@@ -4052,7 +4270,10 @@ export function createSessionLogs(logs: {
4052
4270
  })();
4053
4271
  }
4054
4272
 
4055
- 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
+ }
4056
4277
  return sessionLogQueries.getByTaskId().all(taskId).map(rowToSessionLog);
4057
4278
  }
4058
4279
 
@@ -0,0 +1 @@
1
+ ALTER TABLE agent_tasks ADD COLUMN followUpConfig TEXT;