@desplega.ai/agent-swarm 1.89.0 → 1.91.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/README.md +4 -0
- package/openapi.json +74 -1
- package/package.json +6 -6
- package/plugin/skills/composio/SKILL.md +138 -63
- package/plugin/skills/composio-gmail/SKILL.md +83 -0
- package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
- package/plugin/skills/composio-google-docs/SKILL.md +71 -0
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/db.ts +28 -0
- package/src/be/memory/providers/sqlite-store.ts +6 -1
- package/src/be/memory/types.ts +1 -0
- package/src/be/modelsdev-cache.json +752 -81
- package/src/be/scripts/typecheck.ts +132 -1
- package/src/be/seed-scripts/catalog/compound-insights.ts +188 -0
- package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
- package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
- package/src/be/seed-scripts/catalog/tool-usage.ts +56 -0
- package/src/be/seed-scripts/index.ts +36 -0
- package/src/commands/artifact.ts +3 -2
- package/src/commands/profile-sync.ts +310 -0
- package/src/commands/runner.ts +91 -1
- package/src/heartbeat/heartbeat.ts +54 -7
- package/src/hooks/hook.ts +32 -9
- package/src/http/index.ts +47 -0
- package/src/http/integrations.ts +6 -1
- package/src/http/mcp-bridge.ts +117 -0
- package/src/http/mcp-oauth.ts +97 -39
- package/src/http/memory.ts +5 -2
- package/src/http/openapi.ts +2 -2
- package/src/http/pages-public.ts +10 -11
- package/src/http/pages.ts +7 -11
- package/src/http/scripts.ts +24 -1
- package/src/http/tasks.ts +2 -0
- package/src/http/utils.ts +11 -4
- package/src/jira/app.ts +2 -3
- package/src/jira/webhook-lifecycle.ts +2 -1
- package/src/linear/app.ts +2 -3
- package/src/providers/claude-adapter.ts +26 -0
- package/src/scripts-runtime/executors/native.ts +1 -0
- package/src/scripts-runtime/sdk-allowlist.ts +121 -0
- package/src/scripts-runtime/swarm-sdk.ts +198 -3
- package/src/scripts-runtime/types/stdlib.d.ts +227 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +227 -0
- package/src/tasks/worker-follow-up.ts +19 -1
- package/src/tests/claude-adapter-otel.test.ts +85 -1
- package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
- package/src/tests/hook-registration-nudge.test.ts +69 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
- package/src/tests/pages-public-html.test.ts +41 -0
- package/src/tests/pages-public-json-redirect.test.ts +37 -2
- package/src/tests/profile-sync.test.ts +282 -0
- package/src/tests/scripts-runtime.test.ts +33 -0
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tools/create-metric.ts +2 -3
- package/src/tools/create-page.ts +3 -6
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/register-kapso-number.ts +2 -4
- package/src/tools/request-human-input.ts +2 -1
- package/src/tools/script-common.ts +2 -4
- package/src/tools/script-run.ts +7 -0
- package/src/utils/constants.ts +58 -8
- package/templates/skills/swarm-scripts/content.md +46 -7
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness-agnostic FS → DB profile sync (worker-side, HTTP-only).
|
|
3
|
+
*
|
|
4
|
+
* Persists an agent's self-editable identity / config files back to the API:
|
|
5
|
+
* - SOUL.md / IDENTITY.md / TOOLS.md / HEARTBEAT.md (bundled identity POST)
|
|
6
|
+
* - ~/.claude/CLAUDE.md (claude POST)
|
|
7
|
+
* - /workspace/start-up.sh (agent-managed section) (setup POST)
|
|
8
|
+
*
|
|
9
|
+
* This mirrors the per-session sync that the Claude plugin hooks
|
|
10
|
+
* (`src/hooks/hook.ts`) and the pi extension (`src/providers/pi-mono-extension.ts`)
|
|
11
|
+
* already perform, but lifted into a single shared module the runner can call
|
|
12
|
+
* at session end for ANY `hasLocalEnvironment` harness (claude, pi, codex,
|
|
13
|
+
* opencode). Before this module, codex/opencode had no sync path at all and
|
|
14
|
+
* pi's path could silently not-fire (2026-06-01 regression).
|
|
15
|
+
*
|
|
16
|
+
* Boundary rules (enforced by CI):
|
|
17
|
+
* - MUST NOT import `src/be/db` or `bun:sqlite` (worker/API DB boundary —
|
|
18
|
+
* `scripts/check-db-boundary.sh`). This module is HTTP-only.
|
|
19
|
+
* - MUST NOT read the API key from `process.env` directly
|
|
20
|
+
* (`scripts/check-api-key-boundary.sh`). The caller passes the key
|
|
21
|
+
* (resolved via `getApiKey()`) in `opts.apiKey`.
|
|
22
|
+
*
|
|
23
|
+
* Hardening vs. the original copies: every POST checks `resp.ok` and surfaces
|
|
24
|
+
* a scrubbed warning on a non-2xx response or thrown error instead of
|
|
25
|
+
* silently swallowing it (the swallow is exactly what hid the 2026-06-01 pi
|
|
26
|
+
* drop). The sync stays NON-FATAL — a failed sync must never fail the task —
|
|
27
|
+
* but it must be VISIBLE.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { scrubSecrets } from "../utils/secret-scrubber.ts";
|
|
31
|
+
|
|
32
|
+
export const SOUL_MD_PATH = "/workspace/SOUL.md";
|
|
33
|
+
export const IDENTITY_MD_PATH = "/workspace/IDENTITY.md";
|
|
34
|
+
export const TOOLS_MD_PATH = "/workspace/TOOLS.md";
|
|
35
|
+
export const HEARTBEAT_MD_PATH = "/workspace/HEARTBEAT.md";
|
|
36
|
+
export const SETUP_SCRIPT_PATH = "/workspace/start-up.sh";
|
|
37
|
+
/**
|
|
38
|
+
* Claude Code's personal-file CLAUDE.md path. This is what the Claude plugin
|
|
39
|
+
* Stop hook reads and owns — the runner only uses it as a backstop for an
|
|
40
|
+
* all-Claude batch (never overwriting it with the workspace materialization).
|
|
41
|
+
*/
|
|
42
|
+
export const CLAUDE_MD_PATH = `${process.env.HOME}/.claude/CLAUDE.md`;
|
|
43
|
+
/**
|
|
44
|
+
* Workspace CLAUDE.md — the agent-level instructions file the runner
|
|
45
|
+
* materializes from the `claudeMd` DB field at boot (`runner.ts`) and that the
|
|
46
|
+
* base-prompt truncation notice tells NON-Claude harnesses (codex/pi/opencode)
|
|
47
|
+
* to edit. Distinct from CLAUDE_MD_PATH; this is the FS→DB source for the
|
|
48
|
+
* non-Claude providers that previously had no sync path at all.
|
|
49
|
+
*/
|
|
50
|
+
export const WORKSPACE_CLAUDE_MD_PATH = "/workspace/CLAUDE.md";
|
|
51
|
+
|
|
52
|
+
// Minimum length for SOUL.md and IDENTITY.md to prevent accidental corruption.
|
|
53
|
+
// Mirrors `hook.ts` (raised from 100 to 500 after profile-corruption recurrences
|
|
54
|
+
// where a short test sentinel synced into the real agent's DB row).
|
|
55
|
+
const IDENTITY_FILE_MIN_LENGTH = 500;
|
|
56
|
+
// Maximum file size we are willing to sync (>64KB is almost certainly not a
|
|
57
|
+
// hand-edited identity/config file).
|
|
58
|
+
const MAX_FILE_LENGTH = 65536;
|
|
59
|
+
|
|
60
|
+
const SETUP_MARKER_START = "# === Agent-managed setup (from DB) ===";
|
|
61
|
+
const SETUP_MARKER_END = "# === End agent-managed setup ===";
|
|
62
|
+
|
|
63
|
+
export type ProfileSyncField = "identity" | "claude" | "setup";
|
|
64
|
+
export type ProfileChangeSource = "self_edit" | "session_sync";
|
|
65
|
+
|
|
66
|
+
export interface ProfileSyncOptions {
|
|
67
|
+
agentId: string;
|
|
68
|
+
apiUrl: string;
|
|
69
|
+
apiKey: string;
|
|
70
|
+
/** Session-end sync uses "session_sync"; on-edit hooks use "self_edit". */
|
|
71
|
+
changeSource?: ProfileChangeSource;
|
|
72
|
+
/** Subset of field groups to sync. Defaults to all three. */
|
|
73
|
+
fields?: ProfileSyncField[];
|
|
74
|
+
/**
|
|
75
|
+
* Path to read the CLAUDE.md source from. Defaults to CLAUDE_MD_PATH (Claude
|
|
76
|
+
* Code's personal-file path). Non-Claude local harnesses must pass
|
|
77
|
+
* WORKSPACE_CLAUDE_MD_PATH so their `/workspace/CLAUDE.md` edits sync. See
|
|
78
|
+
* `resolveClaudeMdPath`.
|
|
79
|
+
*/
|
|
80
|
+
claudeMdPath?: string;
|
|
81
|
+
/** Injectable fetch for tests. Defaults to the global `fetch`. */
|
|
82
|
+
fetchImpl?: typeof fetch;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Choose which CLAUDE.md source the runner should sync, given the harness
|
|
87
|
+
* providers of the completed local sessions in a batch. Claude Code's personal
|
|
88
|
+
* file lives at `~/.claude/CLAUDE.md` (CLAUDE_MD_PATH — the Stop hook's path);
|
|
89
|
+
* every other local harness edits `/workspace/CLAUDE.md` (the file the runner
|
|
90
|
+
* materializes and the base prompt points them to). When a batch mixes
|
|
91
|
+
* providers, the presence of any non-Claude session means the workspace file is
|
|
92
|
+
* the edited source of truth; an all-Claude batch uses the personal-file path,
|
|
93
|
+
* where the runner only acts as a backstop for a Stop hook that didn't fire and
|
|
94
|
+
* never clobbers a real personal-file edit with the stale workspace copy.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveClaudeMdPath(completedProviders: readonly string[]): string {
|
|
97
|
+
const anyNonClaude = completedProviders.some((p) => p !== "claude");
|
|
98
|
+
return anyNonClaude ? WORKSPACE_CLAUDE_MD_PATH : CLAUDE_MD_PATH;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A single profile-update POST body, tagged with a label for logging. */
|
|
102
|
+
interface ProfilePayload {
|
|
103
|
+
label: string;
|
|
104
|
+
body: Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pure: given the raw `start-up.sh` contents, return the agent-managed content
|
|
109
|
+
* to sync, or `null` if there is nothing syncable. Extracts ONLY the content
|
|
110
|
+
* between the agent-managed markers when present (so operator content isn't
|
|
111
|
+
* duplicated); otherwise treats the whole file (minus a leading shebang) as
|
|
112
|
+
* agent-managed.
|
|
113
|
+
*/
|
|
114
|
+
export function extractSetupScriptContent(raw: string): string | null {
|
|
115
|
+
if (!raw.trim()) return null;
|
|
116
|
+
|
|
117
|
+
const startIdx = raw.indexOf(SETUP_MARKER_START);
|
|
118
|
+
const endIdx = raw.indexOf(SETUP_MARKER_END);
|
|
119
|
+
|
|
120
|
+
let content: string;
|
|
121
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
122
|
+
// Markers present — extract ONLY the content between them.
|
|
123
|
+
content = raw.substring(startIdx + SETUP_MARKER_START.length, endIdx).trim();
|
|
124
|
+
} else {
|
|
125
|
+
// No markers — agent created/replaced the entire file. Store as-is minus shebang.
|
|
126
|
+
content = raw.replace(/^#!\/bin\/bash\n/, "").trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!content || content.length > MAX_FILE_LENGTH) return null;
|
|
130
|
+
return content;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Pure: build the bundled identity-update body from raw file contents. Applies
|
|
135
|
+
* the trim / max-length guards and the SOUL/IDENTITY min-length guard. Returns
|
|
136
|
+
* an empty object when nothing is syncable (callers should skip the POST).
|
|
137
|
+
* `undefined` inputs mean the file was absent.
|
|
138
|
+
*/
|
|
139
|
+
export function buildIdentityPayload(files: {
|
|
140
|
+
soulMd?: string;
|
|
141
|
+
identityMd?: string;
|
|
142
|
+
toolsMd?: string;
|
|
143
|
+
heartbeatMd?: string;
|
|
144
|
+
}): Record<string, string> {
|
|
145
|
+
const updates: Record<string, string> = {};
|
|
146
|
+
|
|
147
|
+
if (files.soulMd !== undefined) {
|
|
148
|
+
const content = files.soulMd;
|
|
149
|
+
if (content.trim() && content.length <= MAX_FILE_LENGTH) {
|
|
150
|
+
if (content.length < IDENTITY_FILE_MIN_LENGTH) {
|
|
151
|
+
console.error(
|
|
152
|
+
`[profile-sync] Skipping SOUL.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
|
|
153
|
+
);
|
|
154
|
+
} else {
|
|
155
|
+
updates.soulMd = content;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (files.identityMd !== undefined) {
|
|
161
|
+
const content = files.identityMd;
|
|
162
|
+
if (content.trim() && content.length <= MAX_FILE_LENGTH) {
|
|
163
|
+
if (content.length < IDENTITY_FILE_MIN_LENGTH) {
|
|
164
|
+
console.error(
|
|
165
|
+
`[profile-sync] Skipping IDENTITY.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
|
|
166
|
+
);
|
|
167
|
+
} else {
|
|
168
|
+
updates.identityMd = content;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (files.toolsMd !== undefined) {
|
|
174
|
+
const content = files.toolsMd;
|
|
175
|
+
if (content.trim() && content.length <= MAX_FILE_LENGTH) {
|
|
176
|
+
updates.toolsMd = content;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (files.heartbeatMd !== undefined) {
|
|
181
|
+
const content = files.heartbeatMd;
|
|
182
|
+
if (content.length <= MAX_FILE_LENGTH) {
|
|
183
|
+
updates.heartbeatMd = content;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return updates;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Reads a file's text, returning `undefined` when it does not exist. */
|
|
191
|
+
export type FileReader = (path: string) => Promise<string | undefined>;
|
|
192
|
+
|
|
193
|
+
/** Default file reader — reads from the worker's local FS via Bun. */
|
|
194
|
+
async function readFileIfExists(path: string): Promise<string | undefined> {
|
|
195
|
+
try {
|
|
196
|
+
const file = Bun.file(path);
|
|
197
|
+
if (!(await file.exists())) return undefined;
|
|
198
|
+
return await file.text();
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Collect the profile-update POST bodies to send. Each entry is one POST.
|
|
206
|
+
* `fields` selects which groups to include. The file reader is injectable so
|
|
207
|
+
* the field-selection / guard logic can be unit-tested without touching the FS.
|
|
208
|
+
*/
|
|
209
|
+
export async function collectProfilePayloads(
|
|
210
|
+
fields: ProfileSyncField[],
|
|
211
|
+
changeSource: ProfileChangeSource,
|
|
212
|
+
readFile: FileReader = readFileIfExists,
|
|
213
|
+
claudeMdPath: string = CLAUDE_MD_PATH,
|
|
214
|
+
): Promise<ProfilePayload[]> {
|
|
215
|
+
const payloads: ProfilePayload[] = [];
|
|
216
|
+
|
|
217
|
+
if (fields.includes("identity")) {
|
|
218
|
+
const updates = buildIdentityPayload({
|
|
219
|
+
soulMd: await readFile(SOUL_MD_PATH),
|
|
220
|
+
identityMd: await readFile(IDENTITY_MD_PATH),
|
|
221
|
+
toolsMd: await readFile(TOOLS_MD_PATH),
|
|
222
|
+
heartbeatMd: await readFile(HEARTBEAT_MD_PATH),
|
|
223
|
+
});
|
|
224
|
+
if (Object.keys(updates).length > 0) {
|
|
225
|
+
payloads.push({ label: "identity", body: { ...updates, changeSource } });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (fields.includes("claude")) {
|
|
230
|
+
const raw = await readFile(claudeMdPath);
|
|
231
|
+
if (raw?.trim() && raw.length <= MAX_FILE_LENGTH) {
|
|
232
|
+
payloads.push({ label: "claude", body: { claudeMd: raw, changeSource } });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (fields.includes("setup")) {
|
|
237
|
+
const raw = await readFile(SETUP_SCRIPT_PATH);
|
|
238
|
+
if (raw !== undefined) {
|
|
239
|
+
const content = extractSetupScriptContent(raw);
|
|
240
|
+
if (content !== null) {
|
|
241
|
+
payloads.push({ label: "setup", body: { setupScript: content, changeSource } });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return payloads;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* POST a single profile update. NON-FATAL but VISIBLE: a non-2xx response or a
|
|
251
|
+
* thrown error is logged (scrubbed) and swallowed so it never fails the task,
|
|
252
|
+
* but — unlike the original copies — it is never silently ignored.
|
|
253
|
+
*/
|
|
254
|
+
export async function postProfileUpdate(
|
|
255
|
+
opts: Pick<ProfileSyncOptions, "agentId" | "apiUrl" | "apiKey" | "fetchImpl">,
|
|
256
|
+
payload: ProfilePayload,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
259
|
+
try {
|
|
260
|
+
const resp = await doFetch(`${opts.apiUrl}/api/agents/${opts.agentId}/profile`, {
|
|
261
|
+
method: "PUT",
|
|
262
|
+
headers: {
|
|
263
|
+
"Content-Type": "application/json",
|
|
264
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
265
|
+
"X-Agent-ID": opts.agentId,
|
|
266
|
+
},
|
|
267
|
+
body: JSON.stringify(payload.body),
|
|
268
|
+
});
|
|
269
|
+
if (!resp.ok) {
|
|
270
|
+
let detail = "";
|
|
271
|
+
try {
|
|
272
|
+
detail = (await resp.text()).slice(0, 500);
|
|
273
|
+
} catch {
|
|
274
|
+
/* ignore body read failure */
|
|
275
|
+
}
|
|
276
|
+
console.warn(
|
|
277
|
+
scrubSecrets(
|
|
278
|
+
`[profile-sync] ${payload.label} sync failed: HTTP ${resp.status}${detail ? ` — ${detail}` : ""}`,
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
} catch (err) {
|
|
283
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
284
|
+
console.warn(scrubSecrets(`[profile-sync] ${payload.label} sync errored: ${msg}`));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Sync the agent's local profile files back to the API. Reads SOUL/IDENTITY/
|
|
290
|
+
* TOOLS/HEARTBEAT/CLAUDE.md + the agent-managed section of start-up.sh and
|
|
291
|
+
* POSTs each changed group. Idempotent server-side: the profile route only
|
|
292
|
+
* writes a new `context_versions` row when the content hash changes, so a
|
|
293
|
+
* redundant sync (pi extension + runner, or an unchanged file) is a no-op.
|
|
294
|
+
*
|
|
295
|
+
* Always resolves (never throws) — failures are logged, not propagated.
|
|
296
|
+
*/
|
|
297
|
+
export async function syncProfileFilesToServer(opts: ProfileSyncOptions): Promise<void> {
|
|
298
|
+
const changeSource = opts.changeSource ?? "session_sync";
|
|
299
|
+
const fields = opts.fields ?? ["identity", "claude", "setup"];
|
|
300
|
+
|
|
301
|
+
const payloads = await collectProfilePayloads(
|
|
302
|
+
fields,
|
|
303
|
+
changeSource,
|
|
304
|
+
readFileIfExists,
|
|
305
|
+
opts.claudeMdPath ?? CLAUDE_MD_PATH,
|
|
306
|
+
);
|
|
307
|
+
for (const payload of payloads) {
|
|
308
|
+
await postProfileUpdate(opts, payload);
|
|
309
|
+
}
|
|
310
|
+
}
|
package/src/commands/runner.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { initTelemetry, telemetry } from "../telemetry.ts";
|
|
|
36
36
|
import type { ProviderName, RepoGuidelines } from "../types.ts";
|
|
37
37
|
import { getApiKey } from "../utils/api-key.ts";
|
|
38
38
|
import { computeBudgetBackoffMs } from "../utils/budget-backoff.ts";
|
|
39
|
+
import { getMcpBaseUrl } from "../utils/constants.ts";
|
|
39
40
|
import { getContextWindowSize } from "../utils/context-window.ts";
|
|
40
41
|
import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
|
|
41
42
|
import {
|
|
@@ -52,6 +53,7 @@ import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
|
|
|
52
53
|
import { interpolate } from "../workflows/template.ts";
|
|
53
54
|
import { buildContextPreamble, buildResumeContextPreamble } from "./context-preamble.ts";
|
|
54
55
|
import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
|
|
56
|
+
import { resolveClaudeMdPath, syncProfileFilesToServer } from "./profile-sync.ts";
|
|
55
57
|
import {
|
|
56
58
|
buildCredStatusReport,
|
|
57
59
|
buildLatestModelReport,
|
|
@@ -70,6 +72,34 @@ import "./templates.ts";
|
|
|
70
72
|
/** Throttle interval for progress updates (3 seconds). */
|
|
71
73
|
const PROGRESS_THROTTLE_MS = 3000;
|
|
72
74
|
|
|
75
|
+
/** Minimum spacing for explicit runner GC sweeps. */
|
|
76
|
+
const RUNNER_GC_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
77
|
+
|
|
78
|
+
let lastRunnerGcAt = 0;
|
|
79
|
+
|
|
80
|
+
type GcCapableGlobal = typeof globalThis & { gc?: () => void };
|
|
81
|
+
|
|
82
|
+
function scheduleRunnerGc(reason: string): boolean {
|
|
83
|
+
const gc = (globalThis as GcCapableGlobal).gc;
|
|
84
|
+
if (typeof gc !== "function") return false;
|
|
85
|
+
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (now - lastRunnerGcAt < RUNNER_GC_MIN_INTERVAL_MS) return false;
|
|
88
|
+
lastRunnerGcAt = now;
|
|
89
|
+
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
const startedAt = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
gc();
|
|
94
|
+
console.log(`[runner] Explicit GC completed after ${reason} in ${Date.now() - startedAt}ms`);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.warn(`[runner] Explicit GC failed after ${reason}: ${err}`);
|
|
97
|
+
}
|
|
98
|
+
}, 0);
|
|
99
|
+
timer.unref?.();
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
/** Save PM2 process list for persistence across container restarts */
|
|
74
104
|
async function savePm2State(role: string): Promise<void> {
|
|
75
105
|
try {
|
|
@@ -1424,6 +1454,21 @@ interface RunningTask {
|
|
|
1424
1454
|
keySuffix: string;
|
|
1425
1455
|
keyIndex: number;
|
|
1426
1456
|
};
|
|
1457
|
+
/**
|
|
1458
|
+
* Harness provider this session was actually spawned/resumed on, snapshotted
|
|
1459
|
+
* at spawn time. The runner lets in-flight sessions finish on their original
|
|
1460
|
+
* adapter after a live provider swap, so the session-end profile sync must
|
|
1461
|
+
* decide based on THIS value — not the mutable global `state.harnessProvider`.
|
|
1462
|
+
*/
|
|
1463
|
+
harnessProvider: ProviderName;
|
|
1464
|
+
/**
|
|
1465
|
+
* Whether this session ran in a local `/workspace` environment, snapshotted
|
|
1466
|
+
* from `adapter.traits.hasLocalEnvironment` at spawn time. Gates the
|
|
1467
|
+
* session-end FS → DB profile sync per finished session (a session that
|
|
1468
|
+
* started local must still sync even if the worker was swapped to a remote
|
|
1469
|
+
* provider before it completed, and vice versa).
|
|
1470
|
+
*/
|
|
1471
|
+
hasLocalEnvironment: boolean;
|
|
1427
1472
|
}
|
|
1428
1473
|
|
|
1429
1474
|
/** Runner state for tracking concurrent tasks */
|
|
@@ -3010,6 +3055,7 @@ async function spawnProviderProcess(
|
|
|
3010
3055
|
}
|
|
3011
3056
|
closeActiveToolSpans(result.exitCode === 0 ? "ok" : "error", result.failureReason);
|
|
3012
3057
|
sessionSpan.end();
|
|
3058
|
+
scheduleRunnerGc("session completion");
|
|
3013
3059
|
|
|
3014
3060
|
return result;
|
|
3015
3061
|
}),
|
|
@@ -3024,6 +3070,7 @@ async function spawnProviderProcess(
|
|
|
3024
3070
|
});
|
|
3025
3071
|
closeActiveToolSpans("error", error instanceof Error ? error.message : String(error));
|
|
3026
3072
|
sessionSpan.end();
|
|
3073
|
+
scheduleRunnerGc("session error");
|
|
3027
3074
|
throw error;
|
|
3028
3075
|
}),
|
|
3029
3076
|
);
|
|
@@ -3046,6 +3093,11 @@ async function spawnProviderProcess(
|
|
|
3046
3093
|
promise,
|
|
3047
3094
|
result: null,
|
|
3048
3095
|
credentialInfo,
|
|
3096
|
+
// Snapshot the provider + local-env trait of the adapter this session is
|
|
3097
|
+
// spawned on, so the session-end sync decision survives a live provider
|
|
3098
|
+
// swap that mutates the global RunnerState (review finding 2).
|
|
3099
|
+
harnessProvider: opts.harnessProvider,
|
|
3100
|
+
hasLocalEnvironment: adapter.traits.hasLocalEnvironment,
|
|
3049
3101
|
};
|
|
3050
3102
|
|
|
3051
3103
|
// Non-blocking completion tracking
|
|
@@ -3073,6 +3125,8 @@ async function checkCompletedProcesses(
|
|
|
3073
3125
|
cursorUpdates?: Array<{ channelId: string; ts: string }>;
|
|
3074
3126
|
workingDir?: string;
|
|
3075
3127
|
credentialInfo?: RunningTask["credentialInfo"];
|
|
3128
|
+
harnessProvider: ProviderName;
|
|
3129
|
+
hasLocalEnvironment: boolean;
|
|
3076
3130
|
}> = [];
|
|
3077
3131
|
|
|
3078
3132
|
for (const [taskId, task] of state.activeTasks) {
|
|
@@ -3088,6 +3142,8 @@ async function checkCompletedProcesses(
|
|
|
3088
3142
|
cursorUpdates: task.cursorUpdates,
|
|
3089
3143
|
workingDir: task.workingDir,
|
|
3090
3144
|
credentialInfo: task.credentialInfo,
|
|
3145
|
+
harnessProvider: task.harnessProvider,
|
|
3146
|
+
hasLocalEnvironment: task.hasLocalEnvironment,
|
|
3091
3147
|
});
|
|
3092
3148
|
}
|
|
3093
3149
|
}
|
|
@@ -3217,6 +3273,40 @@ async function checkCompletedProcesses(
|
|
|
3217
3273
|
}
|
|
3218
3274
|
}
|
|
3219
3275
|
}
|
|
3276
|
+
|
|
3277
|
+
// Harness-agnostic FS → DB profile sync at session end.
|
|
3278
|
+
//
|
|
3279
|
+
// The Claude plugin Stop hook and the pi extension sync SOUL/IDENTITY/TOOLS/
|
|
3280
|
+
// CLAUDE.md + start-up.sh on their own, but codex/opencode have no such path
|
|
3281
|
+
// and pi's can silently not-fire (2026-06-01 regression). Running the sync
|
|
3282
|
+
// here — at the single point where every completed harness session converges
|
|
3283
|
+
// (including crashes, since the process resolved with an exit code) — makes
|
|
3284
|
+
// persistence reliable for ALL local-environment harnesses without
|
|
3285
|
+
// per-adapter code. Idempotent: the profile route only writes a new context
|
|
3286
|
+
// version when the content hash changes, so pi's double-sync and claude's
|
|
3287
|
+
// redundant POST collapse to a no-op. NON-FATAL — never blocks completion;
|
|
3288
|
+
// failures are logged (scrubbed) inside the helper.
|
|
3289
|
+
//
|
|
3290
|
+
// The local-env gate is per FINISHED session, snapshotted at spawn time —
|
|
3291
|
+
// NOT the mutable global `state.hasLocalEnvironment`. The runner lets
|
|
3292
|
+
// in-flight sessions finish on their original adapter after a live provider
|
|
3293
|
+
// swap, so reading the global would (a) skip a session that started local
|
|
3294
|
+
// when the worker has since flipped to a remote provider, and (b) sync stale
|
|
3295
|
+
// local files after a remote session finishes once the worker flipped local.
|
|
3296
|
+
// We sync when ANY finished session in this batch ran locally, and pick the
|
|
3297
|
+
// CLAUDE.md source from those sessions' providers (review finding 2 + 1).
|
|
3298
|
+
const localCompleted = completedTasks.filter((t) => t.hasLocalEnvironment);
|
|
3299
|
+
if (apiConfig && localCompleted.length > 0) {
|
|
3300
|
+
await syncProfileFilesToServer({
|
|
3301
|
+
agentId: apiConfig.agentId,
|
|
3302
|
+
apiUrl: apiConfig.apiUrl,
|
|
3303
|
+
apiKey: apiConfig.apiKey,
|
|
3304
|
+
changeSource: "session_sync",
|
|
3305
|
+
claudeMdPath: resolveClaudeMdPath(localCompleted.map((t) => t.harnessProvider)),
|
|
3306
|
+
}).catch((err) => {
|
|
3307
|
+
console.warn(`[${role}] ${scrubSecrets(`Profile sync failed: ${err}`)}`);
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3220
3310
|
}
|
|
3221
3311
|
|
|
3222
3312
|
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
@@ -3299,7 +3389,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
3299
3389
|
// Get agent identity and swarm URL for base prompt
|
|
3300
3390
|
const agentId = process.env.AGENT_ID || "unknown";
|
|
3301
3391
|
|
|
3302
|
-
const apiUrl =
|
|
3392
|
+
const apiUrl = getMcpBaseUrl();
|
|
3303
3393
|
const swarmUrl = process.env.SWARM_URL || "localhost";
|
|
3304
3394
|
const apiKey = getApiKey();
|
|
3305
3395
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assignUnassignedTaskPending,
|
|
3
|
+
backfillSupersedeTaskResumeTaskId,
|
|
3
4
|
cleanupStaleSessions,
|
|
4
5
|
createTaskExtended,
|
|
5
6
|
deleteActiveSession,
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
updateAgentStatus,
|
|
26
27
|
} from "../be/db";
|
|
27
28
|
import { resolveTemplate } from "../prompts/resolver";
|
|
28
|
-
import { createResumeFollowUp } from "../tasks/worker-follow-up";
|
|
29
|
+
import { createResumeFollowUp, getNextResumeGeneration } from "../tasks/worker-follow-up";
|
|
29
30
|
import type { AgentTask } from "../types";
|
|
30
31
|
import { getExecutorRegistry } from "../workflows";
|
|
31
32
|
import { recoverIncompleteRuns } from "../workflows/recovery";
|
|
@@ -60,6 +61,11 @@ const STALE_CLEANUP_THRESHOLD_MINUTES = Number(process.env.HEARTBEAT_STALE_CLEAN
|
|
|
60
61
|
/** Max pool tasks to auto-assign per sweep */
|
|
61
62
|
const MAX_AUTO_ASSIGN_PER_SWEEP = Number(process.env.HEARTBEAT_MAX_AUTO_ASSIGN) || 5;
|
|
62
63
|
|
|
64
|
+
/** Max crash-recovery resume generations before failing for lead triage */
|
|
65
|
+
export const MAX_RESUME_GENERATIONS = Number(process.env.HEARTBEAT_MAX_RESUME_GENERATIONS) || 3;
|
|
66
|
+
|
|
67
|
+
export const RESUME_BUDGET_EXHAUSTED_REASON = "resume_budget_exhausted";
|
|
68
|
+
|
|
63
69
|
/** Heartbeat checklist interval: how often to check HEARTBEAT.md (default: 30 min) */
|
|
64
70
|
const HEARTBEAT_CHECKLIST_INTERVAL_MS =
|
|
65
71
|
Number(process.env.HEARTBEAT_CHECKLIST_INTERVAL_MS) || 30 * 60 * 1000;
|
|
@@ -98,10 +104,17 @@ export interface HeartbeatFindings {
|
|
|
98
104
|
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
|
99
105
|
let checklistInterval: ReturnType<typeof setInterval> | null = null;
|
|
100
106
|
let isSweeping = false;
|
|
107
|
+
let beforeHeartbeatSupersedeForTests: ((task: AgentTask) => void) | null = null;
|
|
101
108
|
|
|
102
109
|
/** Tasks auto-failed during the reboot sweep, consumed by boot triage */
|
|
103
110
|
let rebootAffectedTasks: Array<{ original: AgentTask; retryTaskId: string | null }> = [];
|
|
104
111
|
|
|
112
|
+
export function setBeforeHeartbeatSupersedeForTests(
|
|
113
|
+
hook: ((task: AgentTask) => void) | null,
|
|
114
|
+
): void {
|
|
115
|
+
beforeHeartbeatSupersedeForTests = hook;
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
// ============================================================================
|
|
106
119
|
// Tier 1: Preflight Gate
|
|
107
120
|
// ============================================================================
|
|
@@ -300,16 +313,40 @@ function remediateCrashedWorkerTask(
|
|
|
300
313
|
return;
|
|
301
314
|
}
|
|
302
315
|
|
|
303
|
-
|
|
316
|
+
const nextResumeGeneration = getNextResumeGeneration(task);
|
|
317
|
+
if (nextResumeGeneration > MAX_RESUME_GENERATIONS) {
|
|
318
|
+
const failed = failTask(task.id, RESUME_BUDGET_EXHAUSTED_REASON);
|
|
319
|
+
if (failed) {
|
|
320
|
+
findings.autoFailedTasks.push({
|
|
321
|
+
taskId: task.id,
|
|
322
|
+
agentId: task.agentId,
|
|
323
|
+
reason: RESUME_BUDGET_EXHAUSTED_REASON,
|
|
324
|
+
});
|
|
325
|
+
if (opts.cleanupActiveSession) deleteActiveSession(task.id);
|
|
326
|
+
console.warn(
|
|
327
|
+
`[Heartbeat] Auto-failed task ${task.id.slice(0, 8)} — ${RESUME_BUDGET_EXHAUSTED_REASON} (${opts.shortLabel})`,
|
|
328
|
+
);
|
|
329
|
+
const remaining = getActiveTaskCount(task.agentId);
|
|
330
|
+
if (remaining === 0) updateAgentStatus(task.agentId, "idle");
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
beforeHeartbeatSupersedeForTests?.(task);
|
|
336
|
+
|
|
304
337
|
const superseded = supersedeTask(task.id, {
|
|
305
338
|
reason: opts.supersedeReason,
|
|
306
339
|
resumeTaskId: null,
|
|
307
340
|
});
|
|
308
|
-
if (!superseded)
|
|
341
|
+
if (!superseded) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
309
344
|
|
|
310
345
|
const resume = createResumeFollowUp({ parentId: task.id, reason: "crash_recovery" });
|
|
311
346
|
|
|
312
347
|
if (resume.kind === "created") {
|
|
348
|
+
backfillSupersedeTaskResumeTaskId(task.id, resume.task.id);
|
|
349
|
+
|
|
313
350
|
findings.autoResumedTasks.push({
|
|
314
351
|
taskId: task.id,
|
|
315
352
|
resumeTaskId: resume.task.id,
|
|
@@ -320,10 +357,20 @@ function remediateCrashedWorkerTask(
|
|
|
320
357
|
`[Heartbeat] Auto-superseded task ${task.id.slice(0, 8)} — created resume ${resume.task.id.slice(0, 8)} (${opts.shortLabel})`,
|
|
321
358
|
);
|
|
322
359
|
} else {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
360
|
+
const reason =
|
|
361
|
+
resume.kind === "skipped"
|
|
362
|
+
? `resume_creation_skipped_${resume.reason}`
|
|
363
|
+
: "resume_creation_skipped_workflow";
|
|
364
|
+
const failed = failTask(task.id, reason);
|
|
365
|
+
if (failed) {
|
|
366
|
+
findings.autoFailedTasks.push({
|
|
367
|
+
taskId: task.id,
|
|
368
|
+
agentId: task.agentId,
|
|
369
|
+
reason,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
console.warn(
|
|
373
|
+
`[Heartbeat] Task ${task.id.slice(0, 8)} failed because no resume was created (${
|
|
327
374
|
resume.kind === "skipped" ? resume.reason : "workflow-skip"
|
|
328
375
|
})`,
|
|
329
376
|
);
|
package/src/hooks/hook.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../be/memory/raters/llm";
|
|
12
12
|
import type { Agent } from "../types";
|
|
13
13
|
import { getApiKey } from "../utils/api-key";
|
|
14
|
+
import { getMcpBaseUrl } from "../utils/constants";
|
|
14
15
|
import { summarizeSession as runSummarize } from "../utils/internal-ai";
|
|
15
16
|
import { checkToolLoop, clearToolHistory } from "./tool-loop-detection";
|
|
16
17
|
|
|
@@ -82,6 +83,27 @@ interface CancelledTasksResponse {
|
|
|
82
83
|
cancelled: CancelledTask[];
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Decide whether to show the "not registered — use join-swarm" nudge.
|
|
88
|
+
*
|
|
89
|
+
* Rules:
|
|
90
|
+
* 1. Only nudge on SessionStart — other events should not prompt re-registration.
|
|
91
|
+
* 2. If X-Agent-ID header is present the agent is pre-assigned; a null lookup
|
|
92
|
+
* is transient, not a real "unregistered" state → suppress the nudge.
|
|
93
|
+
* 3. Only genuinely-unregistered agents (no X-Agent-ID, null lookup, SessionStart)
|
|
94
|
+
* see the nudge.
|
|
95
|
+
*/
|
|
96
|
+
export function shouldShowRegistrationNudge(opts: {
|
|
97
|
+
agentInfoPresent: boolean;
|
|
98
|
+
eventType: string;
|
|
99
|
+
hasAgentIdHeader: boolean;
|
|
100
|
+
}): boolean {
|
|
101
|
+
if (opts.agentInfoPresent) return false;
|
|
102
|
+
if (opts.eventType !== "SessionStart") return false;
|
|
103
|
+
if (opts.hasAgentIdHeader) return false;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
/**
|
|
86
108
|
* Check if a path is under the agent's own subdirectory on the shared disk.
|
|
87
109
|
* Shared disk categories: thoughts, memory, downloads, misc.
|
|
@@ -150,7 +172,7 @@ async function readTaskFile(): Promise<TaskFileData | null> {
|
|
|
150
172
|
async function fetchTaskDetails(
|
|
151
173
|
taskId: string,
|
|
152
174
|
): Promise<{ id: string; task: string; progress?: string } | null> {
|
|
153
|
-
const apiUrl =
|
|
175
|
+
const apiUrl = getMcpBaseUrl();
|
|
154
176
|
const apiKey = getApiKey();
|
|
155
177
|
const headers: Record<string, string> = {};
|
|
156
178
|
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
@@ -889,13 +911,15 @@ export async function handleHook(): Promise<void> {
|
|
|
889
911
|
console.log(tray);
|
|
890
912
|
}
|
|
891
913
|
}
|
|
892
|
-
} else
|
|
914
|
+
} else if (
|
|
915
|
+
shouldShowRegistrationNudge({
|
|
916
|
+
agentInfoPresent: false,
|
|
917
|
+
eventType: msg.hook_event_name,
|
|
918
|
+
hasAgentIdHeader: hasAgentIdHeader(),
|
|
919
|
+
})
|
|
920
|
+
) {
|
|
893
921
|
console.log(
|
|
894
|
-
`You are not registered in the agent swarm yet. Use the join-swarm tool to register yourself, then check your status with my-agent-info
|
|
895
|
-
|
|
896
|
-
If the ${SERVER_NAME} server is not running or disabled, disregard this message.
|
|
897
|
-
|
|
898
|
-
${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?.headers["X-Agent-ID"]}, it will be used automatically on join-swarm.` : "You do not have a pre-defined agent ID, you will receive one when you join the swarm, or optionally you can request one when calling join-swarm."}`,
|
|
922
|
+
`You are not registered in the agent swarm yet. Use the join-swarm tool to register yourself, then check your status with my-agent-info.\n\nIf the ${SERVER_NAME} server is not running or disabled, disregard this message.\n\nYou do not have a pre-defined agent ID, you will receive one when you join the swarm, or optionally you can request one when calling join-swarm.`,
|
|
899
923
|
);
|
|
900
924
|
}
|
|
901
925
|
|
|
@@ -1151,8 +1175,7 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
|
|
|
1151
1175
|
editedPath.startsWith("/workspace/shared/memory/"))
|
|
1152
1176
|
) {
|
|
1153
1177
|
try {
|
|
1154
|
-
const apiUrl =
|
|
1155
|
-
process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
|
|
1178
|
+
const apiUrl = getMcpBaseUrl();
|
|
1156
1179
|
const apiKey = getApiKey();
|
|
1157
1180
|
const fileContent = await Bun.file(editedPath).text();
|
|
1158
1181
|
const isShared = editedPath.startsWith("/workspace/shared/");
|