@desplega.ai/agent-swarm 1.83.0 → 1.83.2
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/openapi.json +177 -10
- package/package.json +6 -6
- package/src/artifact-sdk/server.ts +23 -1
- package/src/be/budget-admission.ts +28 -4
- package/src/be/budget-refusal-notify.ts +19 -3
- package/src/be/db-queries/oauth.ts +43 -0
- package/src/be/db.ts +37 -4
- package/src/be/migrations/074_user_budget_scope.sql +85 -0
- package/src/be/schedules/validate.ts +21 -0
- package/src/be/skill-sync.ts +65 -15
- package/src/commands/resume-session.ts +118 -0
- package/src/commands/runner.ts +178 -121
- package/src/http/core.ts +4 -1
- package/src/http/index.ts +16 -0
- package/src/http/integrations.ts +26 -0
- package/src/http/mcp-user.ts +111 -0
- package/src/http/poll.ts +19 -5
- package/src/http/schedules.ts +35 -10
- package/src/http/skills.ts +27 -2
- package/src/http/users.ts +107 -2
- package/src/jira/client.ts +3 -5
- package/src/jira/oauth.ts +1 -0
- package/src/jira/sync.ts +2 -2
- package/src/oauth/ensure-token.ts +1 -0
- package/src/oauth/wrapper.ts +38 -7
- package/src/providers/claude-adapter.ts +7 -2
- package/src/providers/claude-managed-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +30 -0
- package/src/providers/opencode-adapter.ts +149 -14
- package/src/providers/pi-mono-adapter.ts +41 -1
- package/src/providers/types.ts +1 -1
- package/src/server-user.ts +117 -0
- package/src/tests/artifact-sdk.test.ts +23 -19
- package/src/tests/budget-user-scope.test.ts +376 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +192 -0
- package/src/tests/codex-rate-limit-parse.test.ts +256 -0
- package/src/tests/db-queries-oauth.test.ts +43 -0
- package/src/tests/ensure-token.test.ts +93 -0
- package/src/tests/error-tracker.test.ts +52 -0
- package/src/tests/fetch-resolved-env.test.ts +33 -20
- package/src/tests/http-api-integration.test.ts +36 -0
- package/src/tests/http-users.test.ts +29 -1
- package/src/tests/mcp-user-route.test.ts +325 -0
- package/src/tests/opencode-adapter.test.ts +75 -0
- package/src/tests/pi-mono-adapter.test.ts +21 -1
- package/src/tests/rate-limit-event.test.ts +69 -6
- package/src/tests/resume-session.test.ts +93 -0
- package/src/tests/runner-skills-refresh.test.ts +200 -0
- package/src/tests/schedule-validation-helper.test.ts +51 -0
- package/src/tests/skill-sync.test.ts +73 -9
- package/src/tests/skills-signature.test.ts +141 -0
- package/src/tests/task-tools-ctx.test.ts +100 -0
- package/src/tests/task-tools-ownership.test.ts +167 -0
- package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
- package/src/tests/user-token-routes.test.ts +221 -0
- package/src/tools/cancel-task.ts +137 -83
- package/src/tools/get-task-details.ts +73 -59
- package/src/tools/get-tasks.ts +134 -126
- package/src/tools/schedules/update-schedule.ts +48 -8
- package/src/tools/send-task.ts +312 -312
- package/src/tools/slack-upload-file.ts +17 -5
- package/src/tools/task-action.ts +464 -367
- package/src/tools/task-tool-ctx.ts +43 -0
- package/src/types.ts +6 -2
- package/src/utils/error-tracker.ts +122 -9
- package/src/utils/skills-refresh.ts +123 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import type { AgentTask, User } from "@/types";
|
|
3
|
+
import type { RequestInfo } from "./utils";
|
|
4
|
+
|
|
5
|
+
export type ToolCtx =
|
|
6
|
+
| { kind: "owner"; agentId?: string; sourceTaskId?: string; sessionId?: string }
|
|
7
|
+
| { kind: "user"; userId: string; user: User; sessionId?: string };
|
|
8
|
+
|
|
9
|
+
export function ownerCtx(info: RequestInfo): ToolCtx {
|
|
10
|
+
return {
|
|
11
|
+
kind: "owner",
|
|
12
|
+
agentId: info.agentId,
|
|
13
|
+
sourceTaskId: info.sourceTaskId,
|
|
14
|
+
sessionId: info.sessionId,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function userCtx(user: User, sessionId?: string): ToolCtx {
|
|
19
|
+
return {
|
|
20
|
+
kind: "user",
|
|
21
|
+
userId: user.id,
|
|
22
|
+
user,
|
|
23
|
+
sessionId,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assertOwnsTask(ctx: ToolCtx, task: AgentTask): CallToolResult | null {
|
|
28
|
+
if (ctx.kind === "owner" || task.requestedByUserId === ctx.userId) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const message = `Forbidden: this task is not yours (task ${task.id}).`;
|
|
33
|
+
// RBAC chokepoint — a future admin/role tier widens visibility here, in this one function.
|
|
34
|
+
return {
|
|
35
|
+
isError: true,
|
|
36
|
+
content: [{ type: "text", text: message }],
|
|
37
|
+
structuredContent: {
|
|
38
|
+
success: false,
|
|
39
|
+
code: "forbidden",
|
|
40
|
+
message,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1682,7 +1682,7 @@ export type ContextSnapshot = z.infer<typeof ContextSnapshotSchema>;
|
|
|
1682
1682
|
// effective_from <= now" lookup is a pure integer comparison. Matches the
|
|
1683
1683
|
// SQL columns in migration 046_budgets_and_pricing.sql verbatim.
|
|
1684
1684
|
|
|
1685
|
-
export const BudgetScopeSchema = z.enum(["global", "agent"]);
|
|
1685
|
+
export const BudgetScopeSchema = z.enum(["global", "agent", "user"]);
|
|
1686
1686
|
export type BudgetScope = z.infer<typeof BudgetScopeSchema>;
|
|
1687
1687
|
|
|
1688
1688
|
export const BudgetSchema = z.object({
|
|
@@ -1730,7 +1730,7 @@ export const PricingRowSchema = z.object({
|
|
|
1730
1730
|
});
|
|
1731
1731
|
export type PricingRow = z.infer<typeof PricingRowSchema>;
|
|
1732
1732
|
|
|
1733
|
-
export const BudgetRefusalCauseSchema = z.enum(["agent", "global"]);
|
|
1733
|
+
export const BudgetRefusalCauseSchema = z.enum(["agent", "global", "user"]);
|
|
1734
1734
|
export type BudgetRefusalCause = z.infer<typeof BudgetRefusalCauseSchema>;
|
|
1735
1735
|
|
|
1736
1736
|
export const BudgetRefusalNotificationSchema = z.object({
|
|
@@ -1742,6 +1742,8 @@ export const BudgetRefusalNotificationSchema = z.object({
|
|
|
1742
1742
|
agentBudgetUsd: z.number().nullable().optional(),
|
|
1743
1743
|
globalSpendUsd: z.number().nullable().optional(),
|
|
1744
1744
|
globalBudgetUsd: z.number().nullable().optional(),
|
|
1745
|
+
userSpendUsd: z.number().nullable().optional(),
|
|
1746
|
+
userBudgetUsd: z.number().nullable().optional(),
|
|
1745
1747
|
followUpTaskId: z.string().nullable().optional(),
|
|
1746
1748
|
createdAt: z.number(), // epoch ms
|
|
1747
1749
|
});
|
|
@@ -1761,6 +1763,8 @@ export const BudgetRefusedTriggerSchema = z.object({
|
|
|
1761
1763
|
agentBudget: z.number().optional(),
|
|
1762
1764
|
globalSpend: z.number().optional(),
|
|
1763
1765
|
globalBudget: z.number().optional(),
|
|
1766
|
+
userSpend: z.number().optional(),
|
|
1767
|
+
userBudget: z.number().optional(),
|
|
1764
1768
|
resetAt: z.string(), // ISO 8601, next UTC midnight
|
|
1765
1769
|
});
|
|
1766
1770
|
export type BudgetRefusedTrigger = z.infer<typeof BudgetRefusedTriggerSchema>;
|
|
@@ -11,13 +11,34 @@ export interface ErrorSignal {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Maximum cooldown horizon for a rate-limit reset. A weekly OAuth limit resets
|
|
15
|
+
* up to ~7 days out, so the cap must be at least that or a weekly-limited key
|
|
16
|
+
* gets re-clamped to a short cooldown and re-handed to a worker every few hours
|
|
17
|
+
* (the fail-every-6h sawtooth). 7d still guards against absurd far-future
|
|
18
|
+
* (malformed) values.
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_RATE_LIMIT_RESET_MS = 7 * 24 * 60 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Single source of truth for "does this text look like a rate-limit signal?".
|
|
24
|
+
* Shared by the runner's cooldown gate and {@link parseStderrForErrors} so the
|
|
25
|
+
* two matchers can't drift. Tolerates a qualifier between "your" and "limit"
|
|
26
|
+
* (weekly / 5-hour / daily): matches "hit your weekly limit", "hit your 5-hour
|
|
27
|
+
* limit", "hit your limit", "Claude usage limit reached", "rate limit exceeded",
|
|
28
|
+
* "429 Too Many Requests"; does not match "No conversation found with session ID".
|
|
29
|
+
*/
|
|
30
|
+
export function isRateLimitMessage(s: string): boolean {
|
|
31
|
+
return /rate.?limit|hit your[\w\s-]*limit|usage[ _-]?limit|too many requests|\b429\b/i.test(s);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Clamps a candidate reset timestamp (ms) to [now+60s, now+7d].
|
|
15
36
|
* Protects against past timestamps (clock skew) and absurdly far future values (malformed).
|
|
16
37
|
*/
|
|
17
38
|
function clampRateLimitResetMs(candidateMs: number): number {
|
|
18
39
|
const nowMs = Date.now();
|
|
19
40
|
const minMs = nowMs + 60_000;
|
|
20
|
-
const maxMs = nowMs +
|
|
41
|
+
const maxMs = nowMs + MAX_RATE_LIMIT_RESET_MS;
|
|
21
42
|
return Math.min(Math.max(candidateMs, minMs), maxMs);
|
|
22
43
|
}
|
|
23
44
|
|
|
@@ -96,6 +117,26 @@ export class SessionErrorTracker {
|
|
|
96
117
|
}
|
|
97
118
|
}
|
|
98
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Process a Codex-style usage-limit error message (from a `{type:"error"}`
|
|
122
|
+
* or `{type:"turn.failed"}` SDK event). Only stashes when the message
|
|
123
|
+
* contains the usage-limit signature AND carries a parseable wall-clock
|
|
124
|
+
* reset time. "Try again later." and workspace-credit branches fall through
|
|
125
|
+
* to the runner's tier-3 fallback instead.
|
|
126
|
+
* Last call wins — multiple events per session are deduped to the latest.
|
|
127
|
+
*/
|
|
128
|
+
processCodexUsageLimitMessage(message: string): void {
|
|
129
|
+
if (!message) return;
|
|
130
|
+
if (!/usage limit|hit your usage/i.test(message)) return;
|
|
131
|
+
|
|
132
|
+
const iso = parseCodexRateLimitResetTime(message);
|
|
133
|
+
if (!iso) return;
|
|
134
|
+
|
|
135
|
+
const candidateMs = new Date(iso).getTime();
|
|
136
|
+
if (!Number.isFinite(candidateMs)) return;
|
|
137
|
+
this.rateLimitResetAtMs = clampRateLimitResetMs(candidateMs);
|
|
138
|
+
}
|
|
139
|
+
|
|
99
140
|
/**
|
|
100
141
|
* Returns the stashed rate limit reset time as an ISO string, or undefined
|
|
101
142
|
* if no rejected rate_limit_event was seen in this session.
|
|
@@ -173,7 +214,14 @@ export class SessionErrorTracker {
|
|
|
173
214
|
|
|
174
215
|
/** Check if the failure was due to a missing/stale session ID */
|
|
175
216
|
isSessionNotFound(): boolean {
|
|
176
|
-
return this.errors.some((e) =>
|
|
217
|
+
return this.errors.some((e) => {
|
|
218
|
+
const message = e.message.toLowerCase();
|
|
219
|
+
return (
|
|
220
|
+
message.includes("no conversation found with session id") ||
|
|
221
|
+
(message.includes("--resume requires a valid session id") &&
|
|
222
|
+
message.includes("does not match any session title"))
|
|
223
|
+
);
|
|
224
|
+
});
|
|
177
225
|
}
|
|
178
226
|
|
|
179
227
|
getErrors(): ReadonlyArray<ErrorSignal> {
|
|
@@ -250,6 +298,76 @@ const MONTH_NAMES: Record<string, number> = {
|
|
|
250
298
|
december: 11,
|
|
251
299
|
};
|
|
252
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Parse the reset time embedded in a Codex `UsageLimitReached` error message.
|
|
303
|
+
* Codex emits one of these formats via chrono's `%-I:%M %p` (same day) or
|
|
304
|
+
* `%b %-d{th/st/nd/rd}, %Y %-I:%M %p` (different day):
|
|
305
|
+
* "Try again at 8:35 PM."
|
|
306
|
+
* "or try again at 8:35 PM."
|
|
307
|
+
* "Try again at May 26th, 2026 8:35 PM."
|
|
308
|
+
* "or try again at May 26th, 2026 8:35 PM."
|
|
309
|
+
* Wall-clock times are UTC because the agent-swarm Docker worker has TZ=Etc/UTC;
|
|
310
|
+
* chrono::Local resolves to UTC in that container.
|
|
311
|
+
*/
|
|
312
|
+
export function parseCodexRateLimitResetTime(
|
|
313
|
+
message: string,
|
|
314
|
+
now: Date = new Date(),
|
|
315
|
+
): string | undefined {
|
|
316
|
+
if (!message) return undefined;
|
|
317
|
+
|
|
318
|
+
// Different-day format (more specific — try first):
|
|
319
|
+
// "Month Day{st/nd/rd/th}, Year HH:MM AM/PM"
|
|
320
|
+
const datedMatch = message.match(
|
|
321
|
+
/\btry again at\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?,\s+(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)\b/i,
|
|
322
|
+
);
|
|
323
|
+
if (datedMatch) {
|
|
324
|
+
const monthIdx = MONTH_NAMES[datedMatch[1]!.toLowerCase()];
|
|
325
|
+
if (monthIdx !== undefined) {
|
|
326
|
+
const day = Number.parseInt(datedMatch[2]!, 10);
|
|
327
|
+
const year = Number.parseInt(datedMatch[3]!, 10);
|
|
328
|
+
const rawHours = Number.parseInt(datedMatch[4]!, 10);
|
|
329
|
+
const minutes = Number.parseInt(datedMatch[5]!, 10);
|
|
330
|
+
const ampm = datedMatch[6]!.toLowerCase();
|
|
331
|
+
if (rawHours < 1 || rawHours > 12 || minutes < 0 || minutes > 59) return undefined;
|
|
332
|
+
let hours = rawHours;
|
|
333
|
+
if (ampm === "pm" && hours !== 12) hours += 12;
|
|
334
|
+
if (ampm === "am" && hours === 12) hours = 0;
|
|
335
|
+
const d = new Date(Date.UTC(year, monthIdx, day, hours, minutes, 0));
|
|
336
|
+
// Round-trip guard: Date.UTC silently normalises out-of-range days (e.g. May 32 → June 1).
|
|
337
|
+
if (d.getUTCFullYear() !== year || d.getUTCMonth() !== monthIdx || d.getUTCDate() !== day) {
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
return d.toISOString();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Same-day format: "HH:MM AM/PM"
|
|
345
|
+
// Anchored on "try again at" so we don't match times elsewhere in the message.
|
|
346
|
+
const timeMatch = message.match(/\btry again at\s+(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)\b/i);
|
|
347
|
+
if (timeMatch) {
|
|
348
|
+
const rawHours = Number.parseInt(timeMatch[1]!, 10);
|
|
349
|
+
const minutes = Number.parseInt(timeMatch[2]!, 10);
|
|
350
|
+
const ampm = timeMatch[3]!.toLowerCase();
|
|
351
|
+
if (rawHours < 1 || rawHours > 12 || minutes < 0 || minutes > 59) return undefined;
|
|
352
|
+
let hours = rawHours;
|
|
353
|
+
if (ampm === "pm" && hours !== 12) hours += 12;
|
|
354
|
+
if (ampm === "am" && hours === 12) hours = 0;
|
|
355
|
+
const candidate = new Date(
|
|
356
|
+
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hours, minutes, 0),
|
|
357
|
+
);
|
|
358
|
+
// Rollover: if the parsed wall-clock is more than SKEW_MS before "now", assume tomorrow.
|
|
359
|
+
// At-or-just-before-now candidates (clock skew, second truncation) stay same-day and
|
|
360
|
+
// flow to clampRateLimitResetMs which applies the now+60s floor.
|
|
361
|
+
const SKEW_MS = 2 * 60 * 1000;
|
|
362
|
+
if (candidate.getTime() < now.getTime() - SKEW_MS) {
|
|
363
|
+
candidate.setUTCDate(candidate.getUTCDate() + 1);
|
|
364
|
+
}
|
|
365
|
+
return candidate.toISOString();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
253
371
|
/**
|
|
254
372
|
* Parse a rate limit error message to extract a reset time, returning an ISO datetime string.
|
|
255
373
|
* Handles patterns like:
|
|
@@ -338,12 +456,7 @@ export function parseStderrForErrors(stderr: string, tracker: SessionErrorTracke
|
|
|
338
456
|
const lower = stderr.toLowerCase();
|
|
339
457
|
const firstLine = stderr.trim().split("\n")[0] ?? stderr.trim();
|
|
340
458
|
|
|
341
|
-
if (
|
|
342
|
-
lower.includes("rate limit") ||
|
|
343
|
-
lower.includes("rate_limit") ||
|
|
344
|
-
lower.includes("429") ||
|
|
345
|
-
lower.includes("hit your limit")
|
|
346
|
-
) {
|
|
459
|
+
if (isRateLimitMessage(stderr)) {
|
|
347
460
|
tracker.addStderrError(firstLine);
|
|
348
461
|
} else if (
|
|
349
462
|
lower.includes("authentication") ||
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-side per-task skill refresh.
|
|
3
|
+
*
|
|
4
|
+
* Polls the cheap signature endpoint; on a hash mismatch, refetches the
|
|
5
|
+
* full skill list and re-runs filesystem sync (claude/pi/codex dirs). The
|
|
6
|
+
* worker stores the signature returned in the list response so the cached
|
|
7
|
+
* hash always corresponds exactly to the snapshot it acted on — avoids a
|
|
8
|
+
* stale-hash race between the signature and list endpoints.
|
|
9
|
+
*
|
|
10
|
+
* Transient errors are swallowed (returned as `changed: false`) so a flaky
|
|
11
|
+
* API can't churn the system prompt.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type SkillsRefreshContext = {
|
|
15
|
+
apiUrl: string;
|
|
16
|
+
swarmUrl: string;
|
|
17
|
+
apiKey: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
role: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SkillsRefreshResult = {
|
|
23
|
+
changed: boolean;
|
|
24
|
+
summary?: { name: string; description: string }[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function refreshSkillsIfChanged(
|
|
28
|
+
ctx: SkillsRefreshContext,
|
|
29
|
+
lastHashRef: { current: string | null },
|
|
30
|
+
): Promise<SkillsRefreshResult> {
|
|
31
|
+
const { apiUrl, swarmUrl, apiKey, agentId, role } = ctx;
|
|
32
|
+
const authHeaders: Record<string, string> = { "X-Agent-ID": agentId };
|
|
33
|
+
if (apiKey) authHeaders.Authorization = `Bearer ${apiKey}`;
|
|
34
|
+
|
|
35
|
+
// Step 1: cheap signature probe
|
|
36
|
+
try {
|
|
37
|
+
const sigResp = await fetch(`${apiUrl}/api/agents/${agentId}/skills/signature`, {
|
|
38
|
+
headers: authHeaders,
|
|
39
|
+
});
|
|
40
|
+
if (sigResp.ok) {
|
|
41
|
+
const sig = (await sigResp.json()) as { hash: string };
|
|
42
|
+
if (lastHashRef.current !== null && sig.hash === lastHashRef.current) {
|
|
43
|
+
return { changed: false };
|
|
44
|
+
}
|
|
45
|
+
} else if (sigResp.status >= 500) {
|
|
46
|
+
// Transient — don't churn the prompt on a flaky API
|
|
47
|
+
return { changed: false };
|
|
48
|
+
}
|
|
49
|
+
// 4xx falls through (e.g. fresh worker hitting a legacy server without
|
|
50
|
+
// the signature endpoint yet) — let the list call drive the result.
|
|
51
|
+
} catch {
|
|
52
|
+
return { changed: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Step 2: full fetch + sync (only reached when hash differs or first call)
|
|
56
|
+
let summary: { name: string; description: string }[] | undefined;
|
|
57
|
+
let newHash: string | null = null;
|
|
58
|
+
try {
|
|
59
|
+
const skillsResp = await fetch(`${apiUrl}/api/agents/${agentId}/skills`, {
|
|
60
|
+
headers: authHeaders,
|
|
61
|
+
});
|
|
62
|
+
if (skillsResp.ok) {
|
|
63
|
+
const skillsData = (await skillsResp.json()) as {
|
|
64
|
+
skills: { name: string; description: string; isActive: boolean; isEnabled: boolean }[];
|
|
65
|
+
signature?: string;
|
|
66
|
+
};
|
|
67
|
+
summary = skillsData.skills
|
|
68
|
+
.filter((s) => s.isActive && s.isEnabled)
|
|
69
|
+
.map((s) => ({ name: s.name, description: s.description }));
|
|
70
|
+
if (typeof skillsData.signature === "string") {
|
|
71
|
+
newHash = skillsData.signature;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Non-fatal — skills are optional
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Step 3: filesystem sync (claude/pi/codex dirs)
|
|
79
|
+
let syncOk = false;
|
|
80
|
+
try {
|
|
81
|
+
const syncHeaders: Record<string, string> = {
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
"X-Agent-ID": agentId,
|
|
84
|
+
};
|
|
85
|
+
if (apiKey) syncHeaders.Authorization = `Bearer ${apiKey}`;
|
|
86
|
+
const syncRes = await fetch(`${swarmUrl}/api/skills/sync-filesystem`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: syncHeaders,
|
|
89
|
+
});
|
|
90
|
+
if (syncRes.ok) {
|
|
91
|
+
const syncResult = (await syncRes.json()) as {
|
|
92
|
+
synced: number;
|
|
93
|
+
removed: number;
|
|
94
|
+
errors: string[];
|
|
95
|
+
};
|
|
96
|
+
console.log(
|
|
97
|
+
`[${role}] Skills synced: ${syncResult.synced} written, ${syncResult.removed} removed`,
|
|
98
|
+
);
|
|
99
|
+
if (syncResult.errors.length > 0) {
|
|
100
|
+
console.warn(`[${role}] Skill sync errors: ${syncResult.errors.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
syncOk = true;
|
|
103
|
+
} else {
|
|
104
|
+
console.warn(`[${role}] Skill sync failed: HTTP ${syncRes.status}`);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn(`[${role}] Skill sync failed: ${(err as Error).message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (summary === undefined && newHash === null) {
|
|
111
|
+
return { changed: false };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only cache the new hash once the FS sync has actually succeeded —
|
|
115
|
+
// otherwise a transient sync failure would leave the cached hash matching
|
|
116
|
+
// the current signature, causing later polls to short-circuit and the
|
|
117
|
+
// disk state to stay stale until an unrelated skill mutation. The next
|
|
118
|
+
// poll re-enters this code path (lastHashRef unchanged) and retries.
|
|
119
|
+
if (syncOk && newHash !== null) {
|
|
120
|
+
lastHashRef.current = newHash;
|
|
121
|
+
}
|
|
122
|
+
return { changed: true, summary };
|
|
123
|
+
}
|