@desplega.ai/agent-swarm 1.111.0 → 1.112.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 (69) hide show
  1. package/dist/{actions-a4crgvtd.js → actions-eckb4g8r.js} +6 -6
  2. package/dist/{app-va5mr9yw.js → app-b05sgm02.js} +3 -3
  3. package/dist/{assistant-0jm4vy8r.js → assistant-7255sbyw.js} +9 -9
  4. package/dist/{boot-reembed-50zzk97q.js → boot-reembed-y1bt3ttn.js} +4 -4
  5. package/dist/{boot-reembed-0t815mmm.js → boot-reembed-yv7awg4y.js} +3 -3
  6. package/dist/{boot-scrub-logs-r99nbmxh.js → boot-scrub-logs-ddyaeqar.js} +2 -2
  7. package/dist/{cli-aqr96mny.js → cli-0c3n0eba.js} +1 -1
  8. package/dist/{cli-dza5039b.js → cli-15598a4k.js} +299 -38
  9. package/dist/{cli-jrmjxsnk.js → cli-30d2ct3k.js} +3 -3
  10. package/dist/{cli-4f6vw5vy.js → cli-3r5y6ayj.js} +2 -2
  11. package/dist/{cli-3cm8ar1c.js → cli-52tbq17n.js} +1 -1
  12. package/dist/{cli-gchg43wt.js → cli-56s4ehzw.js} +1 -1
  13. package/dist/{cli-0d5xddf1.js → cli-709t753c.js} +1 -1
  14. package/dist/{cli-xqzvygxp.js → cli-76d1vtvq.js} +11 -2
  15. package/dist/{cli-0d404x64.js → cli-7xkwgkrs.js} +1 -1
  16. package/dist/{cli-kavm2cv5.js → cli-8f0dwca2.js} +1 -1
  17. package/dist/{cli-r3y5chyg.js → cli-94vzr3nq.js} +4 -4
  18. package/dist/{cli-4ke1amr9.js → cli-951w7c79.js} +1 -1
  19. package/dist/{cli-2b0ew5v8.js → cli-9p0qhead.js} +1 -1
  20. package/dist/{cli-z626gjqj.js → cli-a5e8n5hg.js} +1 -1
  21. package/dist/{cli-y7v6e7yr.js → cli-h9jx5mag.js} +4 -4
  22. package/dist/{cli-f1f82qgv.js → cli-hm27prrc.js} +5 -5
  23. package/dist/{cli-t8m00q3b.js → cli-pt7c7qxz.js} +3 -3
  24. package/dist/{cli-ee3fkebs.js → cli-r1btc895.js} +2 -2
  25. package/dist/{cli-6tfm5fr1.js → cli-rp8pca98.js} +1 -1
  26. package/dist/{cli-h3vhr1gj.js → cli-s9pj3c7z.js} +2 -2
  27. package/dist/{cli-8j1h9627.js → cli-tch0ypp6.js} +61 -14
  28. package/dist/{cli-1aqc46he.js → cli-tg0f2wmz.js} +5 -5
  29. package/dist/{cli-fdnpymsv.js → cli-tp7e60s3.js} +4 -2
  30. package/dist/cli.js +8 -8
  31. package/dist/{commands-t3rdvb55.js → commands-14g7taf4.js} +2 -2
  32. package/dist/{db-mjqc8hj6.js → db-3tge4jrn.js} +2 -2
  33. package/dist/{handlers-mcmg5qgc.js → handlers-sy64egg6.js} +9 -9
  34. package/dist/{hook-0p4q1tym.js → hook-rnkzw8vp.js} +1 -1
  35. package/dist/{http-gs6fk9sg.js → http-xx9xptcs.js} +33 -31
  36. package/dist/{index-w15fk28m.js → index-3wr6v2mm.js} +7 -7
  37. package/dist/{index-744yvc8x.js → index-brma88d4.js} +9 -9
  38. package/dist/{index-6zpajd9e.js → index-ha8e9xn2.js} +6 -6
  39. package/dist/{index-20mf9wvc.js → index-zfx5p959.js} +8 -8
  40. package/dist/{keepalive-3gvnbxaj.js → keepalive-rbz05zad.js} +4 -4
  41. package/dist/{lead-yjbmhj3g.js → lead-s790v7r0.js} +17 -17
  42. package/dist/{maintenance-jbvy2bfg.js → maintenance-420rmadb.js} +4 -4
  43. package/dist/{onboard-sexatejh.js → onboard-jgqamqnw.js} +2 -2
  44. package/dist/{otel-impl-sgqvcw0f.js → otel-impl-ch3n3783.js} +1 -1
  45. package/dist/{pricing-refresh-rat240yg.js → pricing-refresh-rmtrby6h.js} +4 -4
  46. package/dist/{seed-pricing-7yfaf123.js → seed-pricing-1szjzfj1.js} +3 -3
  47. package/dist/{setup-vm5vcc6c.js → setup-8jfzkyzv.js} +2 -2
  48. package/dist/{worker-02y2r0r2.js → worker-40vq0pm8.js} +17 -17
  49. package/openapi.json +1 -1
  50. package/package.json +1 -1
  51. package/src/be/scripts/typecheck.ts +2 -0
  52. package/src/commands/runner.ts +154 -8
  53. package/src/github/handlers.ts +128 -25
  54. package/src/scripts-runtime/sdk-allowlist.ts +2 -0
  55. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
  56. package/src/server.ts +4 -0
  57. package/src/slack/message-text.ts +25 -0
  58. package/src/tests/github-event-filter.test.ts +3 -2
  59. package/src/tests/github-handlers-inline-comments.test.ts +114 -24
  60. package/src/tests/runner-repo-autostash.test.ts +168 -1
  61. package/src/tests/slack-delete.test.ts +157 -0
  62. package/src/tests/slack-message-text.test.ts +37 -1
  63. package/src/tests/slack-update.test.ts +167 -0
  64. package/src/tests/tool-annotations.test.ts +3 -0
  65. package/src/tools/slack-delete.ts +102 -0
  66. package/src/tools/slack-reply.ts +11 -3
  67. package/src/tools/slack-start-thread.ts +2 -0
  68. package/src/tools/slack-update.ts +127 -0
  69. package/src/tools/tool-config.ts +3 -1
@@ -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",
@@ -67,6 +67,8 @@ export const SDK_TOOL_NAME_MAP = {
67
67
  slack_startThread: "slack-start-thread", // external: sends to Slack
68
68
  slack_uploadFile: "slack-upload-file", // external: sends to Slack
69
69
  slack_downloadFile: "slack-download-file",
70
+ slack_delete: "slack-delete", // external: mutates Slack, destructive
71
+ slack_update: "slack-update", // external: mutates Slack
70
72
 
71
73
  // ── messaging (internal) ──
72
74
  message_read: "read-messages",
@@ -209,6 +209,8 @@ declare module "swarm-sdk" {
209
209
  slack_startThread(args: { channelId: string; message: string }): Promise<unknown>;
210
210
  slack_uploadFile(args: Record<string, unknown>): Promise<unknown>;
211
211
  slack_downloadFile(args: { url: string }): Promise<unknown>;
212
+ slack_delete(args: { channelId: string; messageTs: string }): Promise<unknown>;
213
+ slack_update(args: { channelId: string; messageTs: string; message: string }): Promise<unknown>;
212
214
 
213
215
  // --- write: messaging (internal) ---
214
216
  message_post(args: { channel?: string; content: string; to?: string }): Promise<unknown>;
package/src/server.ts CHANGED
@@ -103,12 +103,14 @@ import {
103
103
  registerSkillUninstallTool,
104
104
  registerSkillUpdateTool,
105
105
  } from "./tools/skills";
106
+ import { registerSlackDeleteTool } from "./tools/slack-delete";
106
107
  import { registerSlackDownloadFileTool } from "./tools/slack-download-file";
107
108
  import { registerSlackListChannelsTool } from "./tools/slack-list-channels";
108
109
  import { registerSlackPostTool } from "./tools/slack-post";
109
110
  import { registerSlackReadTool } from "./tools/slack-read";
110
111
  import { registerSlackReplyTool } from "./tools/slack-reply";
111
112
  import { registerSlackStartThreadTool } from "./tools/slack-start-thread";
113
+ import { registerSlackUpdateTool } from "./tools/slack-update";
112
114
  import { registerSlackUploadFileTool } from "./tools/slack-upload-file";
113
115
  import { registerStoreProgressTool } from "./tools/store-progress";
114
116
  // Swarm config tools
@@ -256,6 +258,8 @@ export function createServer() {
256
258
  registerSlackListChannelsTool(server);
257
259
  registerSlackUploadFileTool(server);
258
260
  registerSlackDownloadFileTool(server);
261
+ registerSlackDeleteTool(server);
262
+ registerSlackUpdateTool(server);
259
263
 
260
264
  // AgentMail integration tool (always registered, self-service inbox mapping)
261
265
  registerRegisterAgentmailInboxTool(server);
@@ -178,3 +178,28 @@ export function extractSlackMessageText(msg: SlackMessageLike): string {
178
178
  }
179
179
  return bodyText || topText;
180
180
  }
181
+
182
+ /**
183
+ * Normalize a Slack message timestamp into the dotted API form
184
+ * (1783411554.596189) that chat.delete/chat.update require.
185
+ *
186
+ * Accepts, in order of preference:
187
+ * - the dotted API form → "1783411554.596189" (returned unchanged)
188
+ * - the 'p' deep-link form → "p1783411554596189" (dot re-inserted)
189
+ * - the bare digit run → "1783411554596189"
190
+ * - a full Slack permalink URL → ".../archives/C.../p1783411554596189?thread_ts=..."
191
+ *
192
+ * The Lead can pass whatever handle it holds; this makes delete/edit reliable
193
+ * without the caller pre-cleaning the input.
194
+ */
195
+ export function parseSlackTs(input: string): string {
196
+ const trimmed = input.trim();
197
+ if (/^\d{6,}\.\d{1,}$/.test(trimmed)) return trimmed; // already dotted
198
+
199
+ // Extract the p-form / bare digit run from anywhere in the string,
200
+ // including a full permalink URL (…/archives/C…/p1783411554596189?…).
201
+ const m = trimmed.match(/p?(\d{10})(\d{6})(?:\D|$)/);
202
+ if (m) return `${m[1]}.${m[2]}`;
203
+
204
+ return trimmed; // fall through; Slack rejects a bad ts with a clear error we map below
205
+ }
@@ -183,7 +183,7 @@ describe("suppressed cascade events", () => {
183
183
  expect(result.created).toBe(false);
184
184
  });
185
185
 
186
- test("pull_request_review.submitted with empty commented review is ignored", async () => {
186
+ test("pull_request_review.submitted with empty commented review is created when inline comments are unverifiable", async () => {
187
187
  const event: PullRequestReviewEvent = {
188
188
  action: "submitted",
189
189
  review: {
@@ -207,7 +207,8 @@ describe("suppressed cascade events", () => {
207
207
  sender: { login: "reviewer" },
208
208
  };
209
209
  const result = await handlePullRequestReview(event);
210
- expect(result.created).toBe(false);
210
+ expect(result.created).toBe(true);
211
+ expect(result.taskId).toBeDefined();
211
212
  });
212
213
 
213
214
  test("check_run.completed with failure returns created: false", async () => {