@desplega.ai/agent-swarm 1.111.0 → 1.113.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 (156) hide show
  1. package/dist/{actions-a4crgvtd.js → actions-q4n7cz6e.js} +6 -6
  2. package/dist/{app-va5mr9yw.js → app-es4nzc71.js} +3 -3
  3. package/dist/{assistant-0jm4vy8r.js → assistant-0x8ey0vs.js} +9 -9
  4. package/dist/{boot-reembed-50zzk97q.js → boot-reembed-69agkhv6.js} +4 -4
  5. package/dist/{boot-reembed-0t815mmm.js → boot-reembed-p0ny05t8.js} +3 -3
  6. package/dist/{boot-scrub-logs-r99nbmxh.js → boot-scrub-logs-yybrkmvr.js} +2 -2
  7. package/dist/{cli-h3vhr1gj.js → cli-30bbaveh.js} +2 -2
  8. package/dist/{cli-ee3fkebs.js → cli-36ymmpmp.js} +2 -2
  9. package/dist/{cli-dza5039b.js → cli-4xyj9jtq.js} +1495 -254
  10. package/dist/{cli-3cm8ar1c.js → cli-bgef578c.js} +1 -1
  11. package/dist/{cli-xqzvygxp.js → cli-bgvskydy.js} +11 -2
  12. package/dist/{cli-t8m00q3b.js → cli-bnzbn8j8.js} +3 -3
  13. package/dist/{cli-kavm2cv5.js → cli-c3frqf65.js} +4 -3
  14. package/dist/{cli-2b0ew5v8.js → cli-c9mtm09b.js} +1 -1
  15. package/dist/{cli-0d5xddf1.js → cli-f3qa069v.js} +2 -2
  16. package/dist/{cli-r3y5chyg.js → cli-gtkqc144.js} +4 -4
  17. package/dist/{cli-0d404x64.js → cli-h3k622d0.js} +1 -1
  18. package/dist/{cli-y7v6e7yr.js → cli-hjhvekf4.js} +4 -4
  19. package/dist/{cli-fdnpymsv.js → cli-hsetkkd3.js} +4 -2
  20. package/dist/{cli-8j1h9627.js → cli-mq580e06.js} +61 -14
  21. package/dist/{cli-4ke1amr9.js → cli-ncfgsqbd.js} +63 -7
  22. package/dist/{cli-jrmjxsnk.js → cli-pwc5e83c.js} +3 -3
  23. package/dist/{cli-4f6vw5vy.js → cli-qwgtacd6.js} +2 -2
  24. package/dist/{cli-gchg43wt.js → cli-r5g6ngtn.js} +1 -1
  25. package/dist/{cli-6tfm5fr1.js → cli-tbw13sx8.js} +1 -1
  26. package/dist/{cli-f1f82qgv.js → cli-trmapn9k.js} +5 -5
  27. package/dist/{cli-aqr96mny.js → cli-y4ycrnjc.js} +1 -1
  28. package/dist/{cli-z626gjqj.js → cli-y6mrptcn.js} +1 -1
  29. package/dist/{cli-1aqc46he.js → cli-z46pnksz.js} +5 -5
  30. package/dist/cli.js +11 -10
  31. package/dist/{commands-t3rdvb55.js → commands-z5dbwta3.js} +2 -2
  32. package/dist/{db-mjqc8hj6.js → db-bbgahh4z.js} +2 -2
  33. package/dist/{handlers-mcmg5qgc.js → handlers-6b44317z.js} +9 -9
  34. package/dist/{hook-0p4q1tym.js → hook-sbp5fmps.js} +1 -1
  35. package/dist/{http-gs6fk9sg.js → http-2xz43gz3.js} +356 -94
  36. package/dist/{index-20mf9wvc.js → index-0gzmqj4k.js} +8 -8
  37. package/dist/{index-w15fk28m.js → index-5ghxn8s6.js} +7 -7
  38. package/dist/{index-744yvc8x.js → index-nhat35em.js} +9 -9
  39. package/dist/{index-6zpajd9e.js → index-vfedgdw6.js} +26 -15
  40. package/dist/{keepalive-3gvnbxaj.js → keepalive-62gfj91d.js} +4 -4
  41. package/dist/{lead-yjbmhj3g.js → lead-ag0akn0v.js} +17 -17
  42. package/dist/{maintenance-jbvy2bfg.js → maintenance-xxrrzk5x.js} +4 -4
  43. package/dist/{onboard-sexatejh.js → onboard-64zyfcbs.js} +2 -2
  44. package/dist/{otel-impl-sgqvcw0f.js → otel-impl-b86qseaa.js} +1 -1
  45. package/dist/{pricing-refresh-rat240yg.js → pricing-refresh-cgtkffpa.js} +4 -4
  46. package/dist/{seed-pricing-7yfaf123.js → seed-pricing-1r1y1xkq.js} +3 -3
  47. package/dist/{setup-vm5vcc6c.js → setup-mkcgkjf7.js} +2 -2
  48. package/dist/{worker-02y2r0r2.js → worker-351sze63.js} +17 -17
  49. package/openapi.json +314 -3
  50. package/package.json +4 -3
  51. package/src/be/db.ts +43 -3
  52. package/src/be/memory/providers/sqlite-store.ts +5 -1
  53. package/src/be/migrations/108_rbac_permission_audit.sql +26 -0
  54. package/src/be/rbac-audit.ts +218 -0
  55. package/src/be/scripts/typecheck.ts +2 -0
  56. package/src/commands/runner.ts +154 -8
  57. package/src/github/handlers.ts +128 -25
  58. package/src/http/all-routes.ts +58 -0
  59. package/src/http/config.ts +55 -0
  60. package/src/http/favorites.ts +5 -0
  61. package/src/http/fs.ts +27 -7
  62. package/src/http/index.ts +26 -0
  63. package/src/http/kv.ts +21 -4
  64. package/src/http/route-def.ts +9 -0
  65. package/src/http/schedules.ts +56 -22
  66. package/src/http/scripts.ts +33 -0
  67. package/src/http/workflows.ts +13 -2
  68. package/src/prompts/base-prompt.ts +7 -6
  69. package/src/prompts/session-templates.ts +38 -11
  70. package/src/rbac/can.ts +44 -0
  71. package/src/rbac/index.ts +13 -0
  72. package/src/rbac/legacy-policy.ts +185 -0
  73. package/src/rbac/permissions.ts +182 -0
  74. package/src/rbac/types.ts +45 -0
  75. package/src/scripts-runtime/sdk-allowlist.ts +3 -0
  76. package/src/scripts-runtime/swarm-sdk.ts +81 -0
  77. package/src/scripts-runtime/types/stdlib.d.ts +18 -2
  78. package/src/scripts-runtime/types/swarm-sdk.d.ts +20 -2
  79. package/src/server.ts +6 -0
  80. package/src/slack/message-text.ts +25 -0
  81. package/src/stdio.ts +43 -0
  82. package/src/tests/base-prompt.test.ts +7 -4
  83. package/src/tests/github-event-filter.test.ts +3 -2
  84. package/src/tests/github-handlers-inline-comments.test.ts +114 -24
  85. package/src/tests/harness-provider-resolution.test.ts +5 -0
  86. package/src/tests/http-api-integration.test.ts +8 -1
  87. package/src/tests/prompt-template-session.test.ts +21 -8
  88. package/src/tests/rbac-audit.test.ts +345 -0
  89. package/src/tests/rbac-charact-http.test.ts +272 -0
  90. package/src/tests/rbac-charact-misc-tools.test.ts +428 -0
  91. package/src/tests/rbac-charact-skills.test.ts +492 -0
  92. package/src/tests/rbac-charact-slack.test.ts +283 -0
  93. package/src/tests/rbac-e2e-helpers.ts +305 -0
  94. package/src/tests/rbac-engine.test.ts +433 -0
  95. package/src/tests/rbac-lifecycle-e2e.test.ts +262 -0
  96. package/src/tests/rbac-wire-e2e.test.ts +574 -0
  97. package/src/tests/runner-repo-autostash.test.ts +168 -1
  98. package/src/tests/schedule-http-triage-tools.test.ts +121 -0
  99. package/src/tests/scheduled-tasks.test.ts +34 -0
  100. package/src/tests/scripts-http.test.ts +56 -7
  101. package/src/tests/slack-delete.test.ts +157 -0
  102. package/src/tests/slack-message-text.test.ts +37 -1
  103. package/src/tests/slack-update.test.ts +167 -0
  104. package/src/tests/swarm-config-reserved-keys.test.ts +17 -0
  105. package/src/tests/tool-annotations.test.ts +4 -0
  106. package/src/tests/update-schedule-mcp-tool.test.ts +57 -0
  107. package/src/tests/workflow-http-v2.test.ts +80 -0
  108. package/src/tools/cancel-task.ts +13 -3
  109. package/src/tools/context-diff.ts +12 -1
  110. package/src/tools/context-history.ts +12 -1
  111. package/src/tools/credential-bindings/tool.ts +12 -1
  112. package/src/tools/delete-channel.ts +12 -1
  113. package/src/tools/get-task-details.ts +1 -1
  114. package/src/tools/inject-learning.ts +12 -1
  115. package/src/tools/kv/kv-delete.ts +3 -18
  116. package/src/tools/kv/kv-incr.ts +3 -18
  117. package/src/tools/kv/kv-set.ts +3 -20
  118. package/src/tools/kv/kv-write-auth.ts +40 -0
  119. package/src/tools/manage-user.ts +12 -1
  120. package/src/tools/mcp-servers/mcp-server-create.ts +12 -1
  121. package/src/tools/mcp-servers/mcp-server-delete.ts +12 -1
  122. package/src/tools/mcp-servers/mcp-server-install.ts +12 -1
  123. package/src/tools/mcp-servers/mcp-server-uninstall.ts +12 -1
  124. package/src/tools/mcp-servers/mcp-server-update.ts +12 -1
  125. package/src/tools/memory-delete.ts +12 -4
  126. package/src/tools/register-kapso-number.ts +23 -2
  127. package/src/tools/schedules/create-schedule.ts +4 -3
  128. package/src/tools/schedules/index.ts +1 -0
  129. package/src/tools/schedules/list-schedules.ts +36 -2
  130. package/src/tools/schedules/patch-schedule.ts +405 -0
  131. package/src/tools/schedules/update-schedule.ts +2 -2
  132. package/src/tools/script-connections/tool.ts +12 -1
  133. package/src/tools/skills/skill-create.ts +12 -1
  134. package/src/tools/skills/skill-delete.ts +12 -1
  135. package/src/tools/skills/skill-install-remote.ts +12 -1
  136. package/src/tools/skills/skill-install.ts +12 -1
  137. package/src/tools/skills/skill-uninstall.ts +12 -1
  138. package/src/tools/skills/skill-update.ts +25 -2
  139. package/src/tools/slack-delete.ts +109 -0
  140. package/src/tools/slack-post.ts +8 -1
  141. package/src/tools/slack-read.ts +8 -1
  142. package/src/tools/slack-reply.ts +11 -3
  143. package/src/tools/slack-start-thread.ts +10 -1
  144. package/src/tools/slack-update.ts +134 -0
  145. package/src/tools/slack-upload-file.ts +8 -1
  146. package/src/tools/swarm-config/delete-config.ts +28 -1
  147. package/src/tools/swarm-config/get-config.ts +32 -5
  148. package/src/tools/swarm-config/list-config.ts +31 -5
  149. package/src/tools/swarm-config/set-config.ts +38 -1
  150. package/src/tools/task-action.ts +1 -1
  151. package/src/tools/task-tool-ctx.ts +19 -3
  152. package/src/tools/tool-config.ts +5 -2
  153. package/src/tools/update-profile.ts +8 -1
  154. package/src/tools/workflows/list-workflows.ts +14 -2
  155. package/templates/skills/swarm-scripts/SKILL.md +4 -9
  156. package/templates/skills/swarm-scripts/content.md +20 -9
@@ -0,0 +1,218 @@
1
+ /**
2
+ * RBAC permission-audit batched writer + retention GC (DES-445, Phase 6).
3
+ *
4
+ * `enqueueAuditRow` is the concrete `AuditSink` wired into `can()` at server
5
+ * boot (src/http/index.ts, src/stdio.ts). Rows buffer in memory and flush in
6
+ * a single prepared-statement transaction every FLUSH_INTERVAL_MS (2s) or at
7
+ * FLUSH_MAX_ROWS (200), whichever comes first. Every path is try/caught — a
8
+ * failed flush logs a warning and DROPS the batch; auditing must never throw
9
+ * into the request path. Rows are structured ids/verbs only (no payloads, no
10
+ * secret-bearing content).
11
+ *
12
+ * Kill-switch: `RBAC_AUDIT_DISABLED=true` makes the sink a no-op (checked per
13
+ * call so tests and live config reloads can toggle it).
14
+ *
15
+ * Retention: `startAuditGc` (pattern: startMemoryGc in src/http/memory.ts)
16
+ * ticks daily and deletes rows older than RBAC_AUDIT_RETENTION_DAYS
17
+ * (default 30).
18
+ */
19
+ import type { RbacCheck, RbacDecision } from "../rbac";
20
+ import { getDb } from "./db";
21
+
22
+ type AuditRow = {
23
+ principalType: "agent" | "user" | "operator";
24
+ principalId: string | null;
25
+ originatorUserId: string | null;
26
+ verb: string;
27
+ resourceType: string | null;
28
+ resourceId: string | null;
29
+ decision: "allow" | "deny";
30
+ reason: string | null;
31
+ source: "mcp" | "http";
32
+ };
33
+
34
+ const FLUSH_INTERVAL_MS = 2_000;
35
+ const FLUSH_MAX_ROWS = 200;
36
+ const AUDIT_GC_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily
37
+ const DEFAULT_RETENTION_DAYS = 30;
38
+
39
+ let buffer: AuditRow[] = [];
40
+ let flushTimer: ReturnType<typeof setInterval> | null = null;
41
+ let auditGcTimer: ReturnType<typeof setInterval> | null = null;
42
+ let thresholdFlushScheduled = false;
43
+
44
+ function isAuditDisabled(): boolean {
45
+ return process.env.RBAC_AUDIT_DISABLED === "true";
46
+ }
47
+
48
+ function principalIdOf(principal: RbacCheck["principal"]): string | null {
49
+ switch (principal.kind) {
50
+ case "agent":
51
+ return principal.agentId;
52
+ case "user":
53
+ return principal.userId;
54
+ case "operator":
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function resourceIdOf(resource: RbacCheck["resource"]): string | null {
60
+ if (!resource) return null;
61
+ switch (resource.kind) {
62
+ case "task":
63
+ return resource.taskId;
64
+ case "agent":
65
+ return resource.agentId;
66
+ case "kv-namespace":
67
+ return resource.namespace;
68
+ case "owned":
69
+ return resource.ownerAgentId ?? null;
70
+ case "none":
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function toRow(check: RbacCheck, decision: RbacDecision): AuditRow {
76
+ return {
77
+ principalType: check.principal.kind,
78
+ principalId: principalIdOf(check.principal),
79
+ // Reserved: slice-1 RbacCheck carries no originating-user field yet.
80
+ originatorUserId: null,
81
+ verb: check.verb,
82
+ resourceType: check.resource?.kind ?? null,
83
+ resourceId: resourceIdOf(check.resource),
84
+ decision: decision.allow ? "allow" : "deny",
85
+ reason: decision.allow ? null : decision.reason,
86
+ source: check.source,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * The concrete AuditSink for `can()`. Never throws — `can()` already swallows
92
+ * sink exceptions, but the audit path defends independently.
93
+ */
94
+ export function enqueueAuditRow(check: RbacCheck, decision: RbacDecision): void {
95
+ if (isAuditDisabled()) return;
96
+ try {
97
+ buffer.push(toRow(check, decision));
98
+ // The threshold flush is deferred to a zero-delay timeout so the can()
99
+ // call that happens to be the 200th never pays for the SQLite transaction
100
+ // itself — audit stays fire-and-forget on the request path.
101
+ if (buffer.length >= FLUSH_MAX_ROWS && !thresholdFlushScheduled) {
102
+ thresholdFlushScheduled = true;
103
+ const t = setTimeout(() => {
104
+ thresholdFlushScheduled = false;
105
+ flushAuditBuffer();
106
+ }, 0);
107
+ if (typeof t.unref === "function") t.unref();
108
+ }
109
+ } catch (err) {
110
+ console.warn("[rbac-audit] enqueue failed, dropping row:", (err as Error).message);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Drain the buffer into permission_audit in one transaction. Called by the
116
+ * flush interval, the 200-row threshold, and shutdown. A failed flush logs a
117
+ * warning and drops the batch — never throws.
118
+ */
119
+ export function flushAuditBuffer(): void {
120
+ if (buffer.length === 0) return;
121
+ const rows = buffer;
122
+ buffer = [];
123
+ try {
124
+ const db = getDb();
125
+ const stmt = db.prepare(
126
+ `INSERT INTO permission_audit
127
+ (principalType, principalId, originatorUserId, verb, resourceType, resourceId, decision, reason, source)
128
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
129
+ );
130
+ const insertAll = db.transaction((batch: AuditRow[]) => {
131
+ for (const r of batch) {
132
+ stmt.run(
133
+ r.principalType,
134
+ r.principalId,
135
+ r.originatorUserId,
136
+ r.verb,
137
+ r.resourceType,
138
+ r.resourceId,
139
+ r.decision,
140
+ r.reason,
141
+ r.source,
142
+ );
143
+ }
144
+ });
145
+ insertAll(rows);
146
+ } catch (err) {
147
+ console.warn(
148
+ `[rbac-audit] flush failed, dropping ${rows.length} row(s):`,
149
+ (err as Error).message,
150
+ );
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Test hook, not a production knob: rbac-lifecycle-e2e.test.ts sets a huge
156
+ * interval so the SIGTERM-drain assertion can prove rows were persisted by
157
+ * the shutdown drain rather than a racing timer tick.
158
+ */
159
+ function flushIntervalMs(): number {
160
+ const v = Number(process.env.RBAC_AUDIT_FLUSH_MS);
161
+ return Number.isFinite(v) && v > 0 ? v : FLUSH_INTERVAL_MS;
162
+ }
163
+
164
+ /** Start the periodic flush (2s tick). Idempotent. */
165
+ export function startAuditWriter(intervalMs = flushIntervalMs()): void {
166
+ if (flushTimer) return;
167
+ flushTimer = setInterval(() => flushAuditBuffer(), intervalMs);
168
+ if (typeof flushTimer?.unref === "function") flushTimer.unref();
169
+ }
170
+
171
+ /** Stop the periodic flush. Does NOT flush — shutdown calls flushAuditBuffer(). */
172
+ export function stopAuditWriter(): void {
173
+ if (flushTimer) {
174
+ clearInterval(flushTimer);
175
+ flushTimer = null;
176
+ }
177
+ }
178
+
179
+ /** Delete audit rows older than the retention window. Returns rows deleted. */
180
+ export function purgeExpiredAuditRows(): number {
181
+ try {
182
+ const days = Number(process.env.RBAC_AUDIT_RETENTION_DAYS) || DEFAULT_RETENTION_DAYS;
183
+ // ts is CURRENT_TIMESTAMP format ("YYYY-MM-DD HH:MM:SS"); compute the
184
+ // cutoff in SQLite so the string formats always match.
185
+ const result = getDb()
186
+ .prepare("DELETE FROM permission_audit WHERE ts < datetime('now', ?)")
187
+ .run(`-${days} days`);
188
+ return result.changes;
189
+ } catch (err) {
190
+ console.warn("[rbac-audit] retention purge failed:", (err as Error).message);
191
+ return 0;
192
+ }
193
+ }
194
+
195
+ /** Start the retention GC (daily tick, immediate first run). Idempotent. */
196
+ export function startAuditGc(intervalMs = AUDIT_GC_INTERVAL_MS): void {
197
+ if (auditGcTimer) return;
198
+
199
+ const purged = purgeExpiredAuditRows();
200
+ if (purged > 0) {
201
+ console.log(`[rbac-audit] Initial retention purge removed ${purged} audit row(s)`);
202
+ }
203
+
204
+ auditGcTimer = setInterval(() => {
205
+ const n = purgeExpiredAuditRows();
206
+ if (n > 0) {
207
+ console.log(`[rbac-audit] Retention purge removed ${n} audit row(s)`);
208
+ }
209
+ }, intervalMs);
210
+ if (typeof auditGcTimer?.unref === "function") auditGcTimer.unref();
211
+ }
212
+
213
+ export function stopAuditGc(): void {
214
+ if (auditGcTimer) {
215
+ clearInterval(auditGcTimer);
216
+ auditGcTimer = null;
217
+ }
218
+ }
@@ -137,6 +137,8 @@ export interface SwarmSdk {
137
137
  slack_startThread(args: { channelId: string; message: string }): Promise<unknown>;
138
138
  slack_uploadFile(args: Record<string, unknown>): Promise<unknown>;
139
139
  slack_downloadFile(args: { url: string }): Promise<unknown>;
140
+ slack_delete(args: { channelId: string; messageTs: string }): Promise<unknown>;
141
+ slack_update(args: { channelId: string; messageTs: string; message: string }): Promise<unknown>;
140
142
 
141
143
  // --- write: messaging (internal) ---
142
144
  message_post(args: { channel?: string; content: string; to?: string }): Promise<unknown>;
@@ -196,9 +196,111 @@ async function listSwarmAutostashes(clonePath: string, role: string): Promise<Sw
196
196
  }
197
197
  }
198
198
 
199
+ /**
200
+ * Continuation task types that must NEVER take the destructive hard-reset path,
201
+ * even though they are not `taskType === "resume"`. Each of these ingress paths
202
+ * creates a NEW task that CONTINUES/cooperates with existing work — often on the
203
+ * same worker/reused clone — rather than starting genuinely fresh coding work:
204
+ * - "follow-up" / "reroute-decision": lead-owned continuations created by
205
+ * src/tasks/worker-follow-up.ts after a task completes/fails or needs
206
+ * re-delegation; they inherit the parent's vcsRepo/branch context via
207
+ * createTaskExtended's parentTaskId inheritance (src/be/db.ts).
208
+ * - "agentmail-reply": AgentMail follow-up on an EXISTING thread
209
+ * (src/agentmail/handlers.ts) — always carries `parentTaskId` pointing at
210
+ * the task it's continuing (as opposed to "agentmail-message", which fires
211
+ * only when no existing task was found for the thread).
212
+ * - "github-comment" / "github-review" / "gitlab-comment" / "gitlab-ci":
213
+ * tracker feedback on an ALREADY-OPEN PR/MR (review comments, review
214
+ * verdicts, CI failures) — the point is to keep working on the existing
215
+ * feature branch, not reset back to the default branch.
216
+ */
217
+ const CONTINUATION_TASK_TYPES = new Set([
218
+ "resume",
219
+ "follow-up",
220
+ "reroute-decision",
221
+ "agentmail-reply",
222
+ "github-comment",
223
+ "github-review",
224
+ "gitlab-comment",
225
+ "gitlab-ci",
226
+ ]);
227
+
228
+ /**
229
+ * Task statuses under which a task may have already mutated the shared clone
230
+ * (checked out a feature branch, left uncommitted/unpushed work) — or could
231
+ * still be doing so concurrently, e.g. with `maxTasks > 1` on the same worker.
232
+ * Mirrors the ACTIVE_TASK_STATUSES / getInProgressTasksByContextKey status
233
+ * sets already used server-side (src/agentmail/handlers.ts, src/be/db.ts) for
234
+ * the same "is this task still doing live work" question.
235
+ */
236
+ const ACTIVE_PARENT_STATUSES = new Set<string>(["pending", "in_progress", "offered", "paused"]);
237
+
238
+ /**
239
+ * A task is a "first kickoff" (safe to hard-reset the base branch) only when it
240
+ * is BOTH (a) not an explicit continuation task-type and (b) not parent-linked
241
+ * to a still-active task.
242
+ *
243
+ * `parentTaskId` alone is NOT a resume signal — send-task/trackers set it on
244
+ * ~every dispatched child task (verified 395/395 real coding tasks carried one;
245
+ * 0 were typed "resume"), so gating on its mere presence made the reset a no-op
246
+ * in production (see git history on this function). But the *opposite*
247
+ * over-correction — treating every non-"resume" task as a safe root kickoff —
248
+ * is also wrong: several ingress paths create parent-linked NON-resume tasks
249
+ * specifically to CONTINUE existing work, often on the same worker/reused
250
+ * clone:
251
+ * - Slack follow-ups (src/slack/handlers.ts) wire `parentTaskId:
252
+ * latestTask?.id` with NO distinguishing taskType at all.
253
+ * - Sibling-awareness (src/tasks/sibling-awareness.ts, applied to every
254
+ * ingress via createTaskWithSiblingAwareness) wires `parentTaskId` to an
255
+ * in-progress same-agent sibling for session continuity — again with no
256
+ * reserved taskType.
257
+ * - AgentMail replies, tracker comments/reviews/CI events — see
258
+ * CONTINUATION_TASK_TYPES.
259
+ * Since the first two carry no reserved taskType, a taskType denylist alone
260
+ * cannot catch them — we ALSO check whether `parentTaskId` currently points at
261
+ * a still-active task (ACTIVE_PARENT_STATUSES). If so, that parent/sibling may
262
+ * hold a checked-out feature branch or unpushed/uncommitted work in the SAME
263
+ * shared clone (especially with `maxTasks > 1`), so a hard reset there would
264
+ * destroy it — take the non-destructive merge path instead.
265
+ *
266
+ * `fetchParentTaskStatus` is injected so this stays a pure, unit-testable
267
+ * function; the real call site fetches `GET /api/tasks/{id}` (same pattern as
268
+ * fetchProviderSessionInfo below).
269
+ */
270
+ export async function isFirstKickoffTask(
271
+ task?: { taskType?: string; parentTaskId?: string } | null,
272
+ fetchParentTaskStatus?: (parentTaskId: string) => Promise<string | null | undefined>,
273
+ ): Promise<boolean> {
274
+ if (task?.taskType && CONTINUATION_TASK_TYPES.has(task.taskType)) return false;
275
+ if (task?.parentTaskId && fetchParentTaskStatus) {
276
+ const parentStatus = await fetchParentTaskStatus(task.parentTaskId);
277
+ if (parentStatus && ACTIVE_PARENT_STATUSES.has(parentStatus)) return false;
278
+ }
279
+ return true;
280
+ }
281
+
282
+ /** Fetch a task's current status by id. Returns null on any failure (not-found, network, etc). */
283
+ async function fetchTaskStatus(
284
+ apiUrl: string,
285
+ apiKey: string,
286
+ taskId: string,
287
+ ): Promise<string | null> {
288
+ const headers: Record<string, string> = {};
289
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
290
+ try {
291
+ const response = await fetch(`${apiUrl}/api/tasks/${taskId}`, { headers });
292
+ if (!response.ok) return null;
293
+ const data = (await response.json()) as { status?: string };
294
+ return data.status ?? null;
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
199
300
  async function refreshExistingRepoForTask(
200
301
  repoConfig: { name: string; clonePath: string; defaultBranch: string },
201
302
  role: string,
303
+ isFirstKickoff: boolean,
202
304
  ): Promise<string | null> {
203
305
  const { name, clonePath, defaultBranch } = repoConfig;
204
306
  const statusResult =
@@ -219,13 +321,36 @@ async function refreshExistingRepoForTask(
219
321
  }
220
322
  }
221
323
 
324
+ const fetchSpec = `${defaultBranch}:refs/remotes/origin/${defaultBranch}`;
325
+ const remoteRef = `refs/remotes/origin/${defaultBranch}`;
326
+
222
327
  try {
223
- console.log(`[${role}] Refreshing ${name} from origin/${defaultBranch}...`);
224
- const fetchSpec = `${defaultBranch}:refs/remotes/origin/${defaultBranch}`;
225
- const remoteRef = `refs/remotes/origin/${defaultBranch}`;
226
328
  await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} fetch origin ${fetchSpec}`.quiet();
227
- await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} merge --no-edit --no-stat ${remoteRef}`.quiet();
228
- console.log(`[${role}] Refreshed ${name}`);
329
+
330
+ // First-kickoff guarantee (see call sites): the clone is REUSED task-to-task,
331
+ // so a leftover feature branch from a prior task can still be checked out here.
332
+ // On a genuine first kickoff (no parentTaskId / not a resume) we force the base
333
+ // branch back to a clean, current state before the caller creates a work branch
334
+ // off it — otherwise a diverged leftover branch would abort the merge below and
335
+ // leave the repo stale (only a warning was emitted). Resumes and follow-up/child
336
+ // tasks (which may legitimately still have that feature branch checked out with
337
+ // in-progress work) must NEVER take this path — merge is the safe default there.
338
+ if (isFirstKickoff) {
339
+ console.log(`[${role}] First kickoff for ${name} — resetting to ${remoteRef}...`);
340
+ try {
341
+ await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} checkout ${defaultBranch}`.quiet();
342
+ } catch {
343
+ // Local branch may be missing (e.g. deleted by a prior task) — recreate it
344
+ // tracking the remote instead of failing the whole refresh.
345
+ await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} checkout -B ${defaultBranch} ${remoteRef}`.quiet();
346
+ }
347
+ await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} reset --hard ${remoteRef}`.quiet();
348
+ console.log(`[${role}] Reset ${name} to ${remoteRef}`);
349
+ } else {
350
+ console.log(`[${role}] Refreshing ${name} from ${remoteRef}...`);
351
+ await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} merge --no-edit --no-stat ${remoteRef}`.quiet();
352
+ console.log(`[${role}] Refreshed ${name}`);
353
+ }
229
354
  return null;
230
355
  } catch (err) {
231
356
  const errorMsg = scrubSecrets((err as Error).message);
@@ -278,6 +403,13 @@ export async function ensureRepoForTask(
278
403
  hooks?: { enabled: boolean } | null;
279
404
  },
280
405
  role: string,
406
+ /**
407
+ * True only for a genuine first kickoff (a brand-new task, not a resume and
408
+ * not a follow-up/child task continuing prior work). Defaults to false —
409
+ * when in doubt, do NOT force-reset the base branch, since the clone is
410
+ * reused task-to-task and may hold another task's in-progress branch.
411
+ */
412
+ isFirstKickoff = false,
281
413
  ): Promise<{
282
414
  clonePath: string;
283
415
  claudeMd: string | null;
@@ -308,7 +440,11 @@ export async function ensureRepoForTask(
308
440
  console.log(`[${role}] Cloned ${name}`);
309
441
  } else {
310
442
  console.log(`[${role}] Repo ${name} already cloned at ${clonePath}`);
311
- warning = await refreshExistingRepoForTask({ name, clonePath, defaultBranch }, role);
443
+ warning = await refreshExistingRepoForTask(
444
+ { name, clonePath, defaultBranch },
445
+ role,
446
+ isFirstKickoff,
447
+ );
312
448
  }
313
449
 
314
450
  await installRepoHooksForTask(repoConfig, role);
@@ -4794,7 +4930,9 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4794
4930
  clonePath: `/workspace/personal/repos/${task.vcsRepo.split("/").pop() || task.vcsRepo}`,
4795
4931
  defaultBranch: "main",
4796
4932
  };
4797
- const repoContext = await ensureRepoForTask(effectiveConfig, role);
4933
+ // This is the paused-task-resume-after-restart path — never a first
4934
+ // kickoff, so never force-reset the base branch here.
4935
+ const repoContext = await ensureRepoForTask(effectiveConfig, role, false);
4798
4936
  if (repoContext?.clonePath) {
4799
4937
  resumeCwd = repoContext.clonePath;
4800
4938
  }
@@ -5187,7 +5325,15 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
5187
5325
  clonePath: `/workspace/personal/repos/${taskVcsRepo.split("/").pop() || taskVcsRepo}`,
5188
5326
  defaultBranch: "main",
5189
5327
  };
5190
- const repoResult = await ensureRepoForTask(effectiveConfig, role);
5328
+ // First kickoff = a genuinely new root task: not a continuation
5329
+ // task-type, and not parent-linked to a still-active task (Slack
5330
+ // follow-ups / sibling-awareness wire parentTaskId with no
5331
+ // reserved taskType). See isFirstKickoffTask() for the full
5332
+ // reasoning and CONTINUATION_TASK_TYPES for the denylist.
5333
+ const isFirstKickoff = await isFirstKickoffTask(taskObj, (parentTaskId) =>
5334
+ fetchTaskStatus(apiUrl, apiKey, parentTaskId),
5335
+ );
5336
+ const repoResult = await ensureRepoForTask(effectiveConfig, role, isFirstKickoff);
5191
5337
  currentRepoContext = {
5192
5338
  ...repoResult,
5193
5339
  guidelines: repoConfig?.guidelines ?? null,
@@ -944,6 +944,12 @@ interface ReviewInlineComment {
944
944
  body: string;
945
945
  html_url: string;
946
946
  diff_hunk: string;
947
+ pull_request_review_id?: number | null;
948
+ }
949
+
950
+ interface FetchReviewCommentsResult {
951
+ comments: ReviewInlineComment[];
952
+ degraded: boolean;
947
953
  }
948
954
 
949
955
  function parseNextPageLink(linkHeader: string | null): string | null {
@@ -952,30 +958,38 @@ function parseNextPageLink(linkHeader: string | null): string | null {
952
958
  return match ? (match[1] ?? null) : null;
953
959
  }
954
960
 
955
- async function fetchReviewComments(
956
- repo: string,
957
- prNumber: number,
958
- reviewId: number,
959
- installationId: number,
960
- ): Promise<ReviewInlineComment[]> {
961
- const token = await getInstallationToken(installationId);
962
- if (!token) {
963
- return [];
964
- }
965
- const headers = {
961
+ const REVIEW_COMMENTS_EMPTY_RETRY_DELAYS_MS = [1_500, 3_000, 3_000];
962
+ const defaultReviewCommentsRetryDelay = (ms: number): Promise<void> =>
963
+ new Promise((resolve) => setTimeout(resolve, ms));
964
+ let reviewCommentsRetryDelay: (ms: number) => Promise<void> | void =
965
+ defaultReviewCommentsRetryDelay;
966
+
967
+ export function setReviewCommentsRetryDelayForTests(
968
+ delay?: (ms: number) => Promise<void> | void,
969
+ ): void {
970
+ reviewCommentsRetryDelay = delay ?? defaultReviewCommentsRetryDelay;
971
+ }
972
+
973
+ function buildReviewCommentsHeaders(token: string): Record<string, string> {
974
+ return {
966
975
  Accept: "application/vnd.github+json",
967
976
  Authorization: `Bearer ${token}`,
968
977
  "X-GitHub-Api-Version": "2022-11-28",
969
978
  };
979
+ }
980
+
981
+ async function fetchPaginatedReviewComments(
982
+ initialUrl: string,
983
+ headers: Record<string, string>,
984
+ ): Promise<FetchReviewCommentsResult> {
970
985
  const allComments: ReviewInlineComment[] = [];
971
- let url: string | null =
972
- `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${reviewId}/comments?per_page=100`;
986
+ let url: string | null = initialUrl;
973
987
  try {
974
988
  while (url) {
975
989
  const response = await fetch(url, { headers });
976
990
  if (!response.ok) {
977
991
  console.error(`[GitHub] Failed to fetch review inline comments: ${response.status}`);
978
- return allComments;
992
+ return { comments: allComments, degraded: true };
979
993
  }
980
994
  const page = (await response.json()) as ReviewInlineComment[];
981
995
  if (Array.isArray(page)) {
@@ -983,11 +997,92 @@ async function fetchReviewComments(
983
997
  }
984
998
  url = parseNextPageLink(response.headers.get("link"));
985
999
  }
986
- return allComments;
1000
+ return { comments: allComments, degraded: false };
987
1001
  } catch (error) {
988
1002
  console.error("[GitHub] Error fetching review inline comments:", error);
989
- return allComments;
1003
+ return { comments: allComments, degraded: true };
1004
+ }
1005
+ }
1006
+
1007
+ async function fetchReviewScopedComments(
1008
+ repo: string,
1009
+ prNumber: number,
1010
+ reviewId: number,
1011
+ headers: Record<string, string>,
1012
+ ): Promise<FetchReviewCommentsResult> {
1013
+ const url = `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${reviewId}/comments?per_page=100`;
1014
+
1015
+ let result = await fetchPaginatedReviewComments(url, headers);
1016
+ if (result.degraded || result.comments.length > 0) {
1017
+ return result;
1018
+ }
1019
+
1020
+ for (const delayMs of REVIEW_COMMENTS_EMPTY_RETRY_DELAYS_MS) {
1021
+ await reviewCommentsRetryDelay(delayMs);
1022
+ result = await fetchPaginatedReviewComments(url, headers);
1023
+ if (result.degraded || result.comments.length > 0) {
1024
+ return result;
1025
+ }
990
1026
  }
1027
+
1028
+ return result;
1029
+ }
1030
+
1031
+ async function fetchPrLevelReviewComments(
1032
+ repo: string,
1033
+ prNumber: number,
1034
+ reviewId: number,
1035
+ headers: Record<string, string>,
1036
+ ): Promise<FetchReviewCommentsResult> {
1037
+ const url = `https://api.github.com/repos/${repo}/pulls/${prNumber}/comments?per_page=100`;
1038
+ const result = await fetchPaginatedReviewComments(url, headers);
1039
+ return {
1040
+ comments: result.comments.filter((comment) => comment.pull_request_review_id === reviewId),
1041
+ degraded: result.degraded,
1042
+ };
1043
+ }
1044
+
1045
+ function dedupeReviewComments(comments: ReviewInlineComment[]): ReviewInlineComment[] {
1046
+ const byId = new Map<number, ReviewInlineComment>();
1047
+ for (const comment of comments) {
1048
+ if (!byId.has(comment.id)) {
1049
+ byId.set(comment.id, comment);
1050
+ }
1051
+ }
1052
+ return [...byId.values()];
1053
+ }
1054
+
1055
+ async function fetchReviewComments(
1056
+ repo: string,
1057
+ prNumber: number,
1058
+ reviewId: number,
1059
+ installationId: number,
1060
+ ): Promise<FetchReviewCommentsResult> {
1061
+ const token = await getInstallationToken(installationId);
1062
+ if (!token) {
1063
+ return { comments: [], degraded: true };
1064
+ }
1065
+
1066
+ const headers = buildReviewCommentsHeaders(token);
1067
+ const scopedResult = await fetchReviewScopedComments(repo, prNumber, reviewId, headers);
1068
+ if (!scopedResult.degraded && scopedResult.comments.length > 0) {
1069
+ return scopedResult;
1070
+ }
1071
+
1072
+ const fallbackResult = await fetchPrLevelReviewComments(repo, prNumber, reviewId, headers);
1073
+ const mergedComments = dedupeReviewComments([
1074
+ ...scopedResult.comments,
1075
+ ...fallbackResult.comments,
1076
+ ]);
1077
+
1078
+ if (fallbackResult.comments.length > 0) {
1079
+ return { comments: mergedComments, degraded: false };
1080
+ }
1081
+
1082
+ return {
1083
+ comments: mergedComments,
1084
+ degraded: scopedResult.degraded || fallbackResult.degraded,
1085
+ };
991
1086
  }
992
1087
 
993
1088
  function buildInlineCommentsSection(comments: ReviewInlineComment[]): string {
@@ -1000,6 +1095,13 @@ function buildInlineCommentsSection(comments: ReviewInlineComment[]): string {
1000
1095
  return `\n\n## Inline review comments (${comments.length})\n\n${items.join("\n\n")}`;
1001
1096
  }
1002
1097
 
1098
+ function buildInlineCommentsDegradedSection(repo: string, prNumber: number): string {
1099
+ return `\n\n## ⚠️ Inline comments could NOT be auto-fetched
1100
+ The automatic inline-comment fetch failed or was unverifiable while the reviewer submitted this review. Inline comments ARE the change requests. BEFORE scoping or dispatching this task you MUST fetch them yourself:
1101
+ \`gh api "repos/${repo}/pulls/${prNumber}/comments?per_page=100" --jq '.[] | {id,path,line,body}'\`
1102
+ Reply to and resolve EVERY unresolved inline thread. Do NOT dispatch off the review body alone.`;
1103
+ }
1104
+
1003
1105
  /**
1004
1106
  * Handle pull_request_review events (submitted, edited, dismissed)
1005
1107
  *
@@ -1033,15 +1135,13 @@ export async function handlePullRequestReview(
1033
1135
  return { created: false };
1034
1136
  }
1035
1137
 
1036
- // Fetch inline comments now so we can decide whether to skip and include them in the task.
1037
- // Returns [] when no installation credentials are available (graceful degradation).
1038
- const inlineComments = installation?.id
1138
+ const { comments: inlineComments, degraded } = installation?.id
1039
1139
  ? await fetchReviewComments(repository.full_name, pr.number, review.id, installation.id)
1040
- : [];
1140
+ : { comments: [], degraded: true };
1041
1141
 
1042
1142
  // Skip "commented" reviews only when there is neither an overall body nor any inline
1043
1143
  // comments — a body-less review with inline comments carries real reviewer feedback.
1044
- if (review.state === "commented" && !review.body && inlineComments.length === 0) {
1144
+ if (review.state === "commented" && !review.body && inlineComments.length === 0 && !degraded) {
1045
1145
  return { created: false };
1046
1146
  }
1047
1147
 
@@ -1062,7 +1162,9 @@ export async function handlePullRequestReview(
1062
1162
 
1063
1163
  // Build task description
1064
1164
  const reviewBodySection = review.body ? `\n\nReview Comment:\n${review.body}` : "";
1065
- const inlineCommentsSection = buildInlineCommentsSection(inlineComments);
1165
+ const inlineCommentsSection =
1166
+ buildInlineCommentsSection(inlineComments) +
1167
+ (degraded ? buildInlineCommentsDegradedSection(repository.full_name, pr.number) : "");
1066
1168
  const relatedTaskSection = existingTask
1067
1169
  ? `Related task: ${existingTask.id}\n🔀 Consider routing to the same agent working on the related task.\n`
1068
1170
  : "";
@@ -1074,9 +1176,10 @@ export async function handlePullRequestReview(
1074
1176
  : review.state === "changes_requested"
1075
1177
  ? "💡 Suggested: Address the requested changes and update the PR"
1076
1178
  : "💡 Suggested: Review the feedback and respond if needed";
1077
- const reviewSuggestions = hasInlineComments
1078
- ? `${baseReviewSuggestion}\n💬 Address EVERY inline comment. After pushing fixes, reply to and resolve each inline review thread on GitHub so the reviewer sees visible confirmation.`
1079
- : baseReviewSuggestion;
1179
+ const reviewSuggestions =
1180
+ hasInlineComments || degraded
1181
+ ? `${baseReviewSuggestion}\n💬 Address EVERY inline comment. After pushing fixes, reply to and resolve each inline review thread on GitHub so the reviewer sees visible confirmation.`
1182
+ : baseReviewSuggestion;
1080
1183
 
1081
1184
  const result = resolveTemplate(
1082
1185
  "github.pull_request.review_submitted",