@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.
- package/dist/{actions-a4crgvtd.js → actions-eckb4g8r.js} +6 -6
- package/dist/{app-va5mr9yw.js → app-b05sgm02.js} +3 -3
- package/dist/{assistant-0jm4vy8r.js → assistant-7255sbyw.js} +9 -9
- package/dist/{boot-reembed-50zzk97q.js → boot-reembed-y1bt3ttn.js} +4 -4
- package/dist/{boot-reembed-0t815mmm.js → boot-reembed-yv7awg4y.js} +3 -3
- package/dist/{boot-scrub-logs-r99nbmxh.js → boot-scrub-logs-ddyaeqar.js} +2 -2
- package/dist/{cli-aqr96mny.js → cli-0c3n0eba.js} +1 -1
- package/dist/{cli-dza5039b.js → cli-15598a4k.js} +299 -38
- package/dist/{cli-jrmjxsnk.js → cli-30d2ct3k.js} +3 -3
- package/dist/{cli-4f6vw5vy.js → cli-3r5y6ayj.js} +2 -2
- package/dist/{cli-3cm8ar1c.js → cli-52tbq17n.js} +1 -1
- package/dist/{cli-gchg43wt.js → cli-56s4ehzw.js} +1 -1
- package/dist/{cli-0d5xddf1.js → cli-709t753c.js} +1 -1
- package/dist/{cli-xqzvygxp.js → cli-76d1vtvq.js} +11 -2
- package/dist/{cli-0d404x64.js → cli-7xkwgkrs.js} +1 -1
- package/dist/{cli-kavm2cv5.js → cli-8f0dwca2.js} +1 -1
- package/dist/{cli-r3y5chyg.js → cli-94vzr3nq.js} +4 -4
- package/dist/{cli-4ke1amr9.js → cli-951w7c79.js} +1 -1
- package/dist/{cli-2b0ew5v8.js → cli-9p0qhead.js} +1 -1
- package/dist/{cli-z626gjqj.js → cli-a5e8n5hg.js} +1 -1
- package/dist/{cli-y7v6e7yr.js → cli-h9jx5mag.js} +4 -4
- package/dist/{cli-f1f82qgv.js → cli-hm27prrc.js} +5 -5
- package/dist/{cli-t8m00q3b.js → cli-pt7c7qxz.js} +3 -3
- package/dist/{cli-ee3fkebs.js → cli-r1btc895.js} +2 -2
- package/dist/{cli-6tfm5fr1.js → cli-rp8pca98.js} +1 -1
- package/dist/{cli-h3vhr1gj.js → cli-s9pj3c7z.js} +2 -2
- package/dist/{cli-8j1h9627.js → cli-tch0ypp6.js} +61 -14
- package/dist/{cli-1aqc46he.js → cli-tg0f2wmz.js} +5 -5
- package/dist/{cli-fdnpymsv.js → cli-tp7e60s3.js} +4 -2
- package/dist/cli.js +8 -8
- package/dist/{commands-t3rdvb55.js → commands-14g7taf4.js} +2 -2
- package/dist/{db-mjqc8hj6.js → db-3tge4jrn.js} +2 -2
- package/dist/{handlers-mcmg5qgc.js → handlers-sy64egg6.js} +9 -9
- package/dist/{hook-0p4q1tym.js → hook-rnkzw8vp.js} +1 -1
- package/dist/{http-gs6fk9sg.js → http-xx9xptcs.js} +33 -31
- package/dist/{index-w15fk28m.js → index-3wr6v2mm.js} +7 -7
- package/dist/{index-744yvc8x.js → index-brma88d4.js} +9 -9
- package/dist/{index-6zpajd9e.js → index-ha8e9xn2.js} +6 -6
- package/dist/{index-20mf9wvc.js → index-zfx5p959.js} +8 -8
- package/dist/{keepalive-3gvnbxaj.js → keepalive-rbz05zad.js} +4 -4
- package/dist/{lead-yjbmhj3g.js → lead-s790v7r0.js} +17 -17
- package/dist/{maintenance-jbvy2bfg.js → maintenance-420rmadb.js} +4 -4
- package/dist/{onboard-sexatejh.js → onboard-jgqamqnw.js} +2 -2
- package/dist/{otel-impl-sgqvcw0f.js → otel-impl-ch3n3783.js} +1 -1
- package/dist/{pricing-refresh-rat240yg.js → pricing-refresh-rmtrby6h.js} +4 -4
- package/dist/{seed-pricing-7yfaf123.js → seed-pricing-1szjzfj1.js} +3 -3
- package/dist/{setup-vm5vcc6c.js → setup-8jfzkyzv.js} +2 -2
- package/dist/{worker-02y2r0r2.js → worker-40vq0pm8.js} +17 -17
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/be/scripts/typecheck.ts +2 -0
- package/src/commands/runner.ts +154 -8
- package/src/github/handlers.ts +128 -25
- package/src/scripts-runtime/sdk-allowlist.ts +2 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
- package/src/server.ts +4 -0
- package/src/slack/message-text.ts +25 -0
- package/src/tests/github-event-filter.test.ts +3 -2
- package/src/tests/github-handlers-inline-comments.test.ts +114 -24
- package/src/tests/runner-repo-autostash.test.ts +168 -1
- package/src/tests/slack-delete.test.ts +157 -0
- package/src/tests/slack-message-text.test.ts +37 -1
- package/src/tests/slack-update.test.ts +167 -0
- package/src/tests/tool-annotations.test.ts +3 -0
- package/src/tools/slack-delete.ts +102 -0
- package/src/tools/slack-reply.ts +11 -3
- package/src/tools/slack-start-thread.ts +2 -0
- package/src/tools/slack-update.ts +127 -0
- package/src/tools/tool-config.ts +3 -1
package/src/commands/runner.ts
CHANGED
|
@@ -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
|
-
|
|
228
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/github/handlers.ts
CHANGED
|
@@ -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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
|
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(
|
|
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 () => {
|