@desplega.ai/agent-swarm 1.167.0 → 1.168.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/{acp-adapter-zpvy6321.js → acp-adapter-zhejfecx.js} +2 -2
- package/dist/{actions-fq2zrwtf.js → actions-7jg840nr.js} +11 -11
- package/dist/{amp-adapter-5gbk7501.js → amp-adapter-ye6rnetm.js} +1 -1
- package/dist/{app-nctetn1e.js → app-ffes2kyf.js} +3 -3
- package/dist/{assistant-0aq8gjvj.js → assistant-fhm5smyz.js} +12 -12
- package/dist/{boot-reembed-1ze5853n.js → boot-reembed-20q3nzvg.js} +2 -2
- package/dist/{boot-reembed-fmp5ww69.js → boot-reembed-jvfadhsk.js} +2 -2
- package/dist/{boot-scrub-sweep-cbp8xd4e.js → boot-scrub-sweep-jtfajzve.js} +3 -3
- package/dist/{boot-seeds-22tx8a53.js → boot-seeds-9qt0j8j2.js} +5 -5
- package/dist/{claude-adapter-0jhgeh1d.js → claude-adapter-a8znhthz.js} +3 -3
- package/dist/{claude-sdk-session-wh77rh83.js → claude-sdk-session-7x7chdrs.js} +7590 -7578
- package/dist/{cli-ypgba1a8.js → cli-0jngabtb.js} +1 -1
- package/dist/{cli-tx2zvn23.js → cli-43jg9xdw.js} +3 -3
- package/dist/{cli-6qxmjpem.js → cli-47zfzshm.js} +5 -5
- package/dist/{cli-mkdsy1nq.js → cli-74ez9b66.js} +3 -2
- package/dist/{cli-ykt8f7va.js → cli-9q0bh411.js} +1 -1
- package/dist/{cli-p8rt8spp.js → cli-9q24yyvs.js} +2 -2
- package/dist/{cli-cexctgn5.js → cli-aj1e0h0d.js} +1 -1
- package/dist/{cli-z1dqhc9y.js → cli-bcspch6h.js} +1 -1
- package/dist/{cli-wydz3feg.js → cli-ee0402n1.js} +107 -30
- package/dist/{cli-ty4bzkqr.js → cli-ew0c3a1j.js} +1 -1
- package/dist/{cli-v1pwxreq.js → cli-ewhwpyrb.js} +1 -1
- package/dist/{cli-kw0jcfje.js → cli-fhj4y16f.js} +1 -1
- package/dist/{cli-m7vmeepg.js → cli-fqq06kkb.js} +1 -1
- package/dist/{cli-577bxkf6.js → cli-g6gx2dn1.js} +31 -6
- package/dist/{cli-c8r99akr.js → cli-j05x7pnb.js} +8 -8
- package/dist/{cli-c2tpw9ck.js → cli-jb931ba2.js} +1 -1
- package/dist/{cli-mxry91sa.js → cli-nctfv2yy.js} +72 -3
- package/dist/{cli-ymmc1zxg.js → cli-ng1pmdx6.js} +3 -3
- package/dist/{cli-6vfz44sr.js → cli-p5jqk8fr.js} +1 -1
- package/dist/{cli-qzmzwegq.js → cli-pgehz8e1.js} +4 -4
- package/dist/{cli-cf2vgn9d.js → cli-rr5z1j4p.js} +14 -14
- package/dist/{cli-1yrcfa5s.js → cli-svah6b6w.js} +4 -4
- package/dist/{cli-y0kjpfm9.js → cli-ytmmyj7f.js} +2 -2
- package/dist/{cli-pkj1w46r.js → cli-z48ggfyq.js} +1 -1
- package/dist/cli.js +13 -13
- package/dist/{codex-adapter-cgesnjkr.js → codex-adapter-n63k33d3.js} +2 -2
- package/dist/{codex-session-runner-8kd7ddhr.js → codex-session-runner-xkxfd81a.js} +2 -2
- package/dist/{commands-h6zaaxym.js → commands-q4mfhn51.js} +2 -2
- package/dist/{db-qg3myn53.js → db-1t0bj3pc.js} +2 -2
- package/dist/{dispatcher-7vnwj9qw.js → dispatcher-k1fhavcf.js} +10 -10
- package/dist/{dsh-adapter-tz6hfp5r.js → dsh-adapter-5423p70m.js} +1 -1
- package/dist/{grok-adapter-jdt2dz2s.js → grok-adapter-phrndeb8.js} +3 -3
- package/dist/{handlers-2qtkpncr.js → handlers-59ywqwzh.js} +13 -13
- package/dist/{hook-qnbdgwvd.js → hook-atj0swc2.js} +2 -2
- package/dist/{hook-51yfcgsv.js → hook-mew1chq9.js} +1 -1
- package/dist/{http-jsmtag08.js → http-ef2fnn9v.js} +659 -26
- package/dist/{index-t4g06w93.js → index-48pb9erw.js} +10 -10
- package/dist/{index-ekywyv2v.js → index-9fc2h7ww.js} +10 -10
- package/dist/{index-2t9kesn1.js → index-gr69c43q.js} +2 -2
- package/dist/{index-cx1sb9bq.js → index-h26wjcfy.js} +16 -6
- package/dist/{index-bt8wkv1w.js → index-w2yhye23.js} +11 -11
- package/dist/{keepalive-4aezx4bj.js → keepalive-qhcqz56v.js} +4 -4
- package/dist/{lead-r44bkpm5.js → lead-hpk5r3b1.js} +18 -18
- package/dist/{maintenance-grz5jrzh.js → maintenance-nvkyp2xe.js} +2 -2
- package/dist/{oauth-refresh-sweep-wm4ctjfj.js → oauth-refresh-sweep-ngskthwh.js} +3 -3
- package/dist/{onboard-v60zgqf0.js → onboard-yfnkqhxc.js} +2 -2
- package/dist/{otel-impl-ghh4dyf8.js → otel-impl-hhm64r6m.js} +1 -1
- package/dist/{pricing-refresh-mjrqvnvw.js → pricing-refresh-35f91nx2.js} +4 -4
- package/dist/{rbac-roles-axv4d74x.js → rbac-roles-97w370qj.js} +3 -3
- package/dist/{rbac-roles-fd5tskj9.js → rbac-roles-z6sje3gy.js} +2 -2
- package/dist/{render-v2-k50j33qd.js → render-v2-n4v66bj3.js} +3 -3
- package/dist/{setup-4syh0pd2.js → setup-m3hhyv5f.js} +2 -2
- package/dist/{work-objects-jcdbg8rr.js → work-objects-mxf2c842.js} +13 -13
- package/dist/{worker-x8j8c9sx.js → worker-9t07z1gq.js} +18 -18
- package/openapi.json +72 -1
- package/package.json +3 -3
- package/src/be/script-connections.ts +40 -6
- package/src/be/swarm-config-guard.ts +1 -0
- package/src/extensions/catalog.generated.json +17 -0
- package/src/http/script-connections.ts +35 -0
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/claude-adapter.ts +6 -0
- package/src/providers/claude-background-keepalive.ts +100 -0
- package/src/providers/claude-sdk-session.ts +6 -0
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/scheduler/scheduler.ts +82 -13
- package/src/scripts-runtime/executors/registry.ts +1 -1
- package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
- package/src/tests/claude-background-keepalive.fixture.ts +162 -0
- package/src/tests/claude-background-keepalive.test.ts +139 -0
- package/src/tests/connection-embedded-auth.test.ts +51 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/extensions-catalog.test.ts +1 -0
- package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
- package/src/tests/schedule-target-type.test.ts +136 -0
- package/src/tests/script-connections-http.test.ts +45 -0
- package/src/tests/script-connections-mcp.test.ts +41 -0
- package/src/tests/script-executor-registry.test.ts +18 -0
- package/src/tests/send-task-self-send.test.ts +107 -0
- package/src/tools/script-connections/tool.ts +35 -3
- package/src/tools/send-task.ts +9 -1
- package/templates/extensions/README.md +1 -0
- package/templates/extensions/slack-reply-gate/README.md +98 -0
- package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
- package/templates/extensions/slack-reply-gate/manifest.json +8 -0
- package/templates/skills/swarm-scripts/SKILL.md +10 -0
- package/templates/skills/swarm-scripts/content.md +10 -0
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
getUserById,
|
|
16
16
|
getWorkflow,
|
|
17
17
|
getWorkflowRun,
|
|
18
|
+
recordInlineScriptRun,
|
|
18
19
|
type UpdateScheduledTaskData,
|
|
19
20
|
updateScheduledTask,
|
|
20
21
|
} from "@/be/db";
|
|
@@ -32,6 +33,7 @@ import { startWorkflowExecution } from "@/workflows/engine";
|
|
|
32
33
|
import type { ExecutorRegistry } from "@/workflows/executors/registry";
|
|
33
34
|
import { handleScheduleTrigger } from "@/workflows/triggers";
|
|
34
35
|
import { ensure } from "../utils/business-use";
|
|
36
|
+
import { scrubObject, scrubSecrets } from "../utils/secret-scrubber";
|
|
35
37
|
|
|
36
38
|
import {
|
|
37
39
|
dispatchDeferredTaskWait,
|
|
@@ -83,23 +85,90 @@ async function executeScheduleScript(schedule: ScheduledTask): Promise<void> {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
const agentId = schedule.createdByAgentId ?? "schedule";
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
args: schedule.scriptArgs ?? {},
|
|
89
|
-
fsMode: "none",
|
|
88
|
+
const args = schedule.scriptArgs ?? {};
|
|
89
|
+
const record = {
|
|
90
90
|
agentId,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
91
|
+
scriptName: schedule.scriptName,
|
|
92
|
+
// A pointer, not a copy: script schedules fire as often as every minute and
|
|
93
|
+
// script_runs has no retention sweep, so copying the source on every fire
|
|
94
|
+
// would grow the table by tens of MB a day. script_versions keeps the text.
|
|
95
|
+
source: `// Scheduled run of '${script.name}' v${script.version} (global, contentHash ${script.contentHash}) by schedule '${schedule.name}'.\n// Full source: script_versions row for this version.\n`,
|
|
96
|
+
args,
|
|
97
|
+
startedAt: new Date().toISOString(),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
let output: Awaited<ReturnType<typeof runScript>>;
|
|
101
|
+
try {
|
|
102
|
+
output = await runScript({
|
|
103
|
+
source: script.source,
|
|
104
|
+
args,
|
|
105
|
+
fsMode: "none",
|
|
106
|
+
agentId,
|
|
107
|
+
egressSecrets: await buildScriptCredentialBindings({ agentId }),
|
|
108
|
+
apiConnections: getScriptApiConnectionDescriptors({ agentId }),
|
|
109
|
+
mcpConnections: getScriptMcpConnectionDescriptors({ agentId }),
|
|
110
|
+
timeoutMs: 60_000,
|
|
111
|
+
});
|
|
112
|
+
} catch (err) {
|
|
113
|
+
await recordScheduleScriptRun({
|
|
114
|
+
...record,
|
|
115
|
+
status: "failed",
|
|
116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
117
|
+
});
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
96
120
|
|
|
97
121
|
if (output.exitCode !== 0 || output.error) {
|
|
98
|
-
|
|
122
|
+
const message =
|
|
99
123
|
output.stderr ||
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
124
|
+
`Script '${schedule.scriptName}' exited with code ${output.exitCode}${
|
|
125
|
+
output.error ? ` (${output.error})` : ""
|
|
126
|
+
}`;
|
|
127
|
+
await recordScheduleScriptRun({
|
|
128
|
+
...record,
|
|
129
|
+
status: "failed",
|
|
130
|
+
output: output.result,
|
|
131
|
+
error: message,
|
|
132
|
+
});
|
|
133
|
+
throw new Error(message);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await recordScheduleScriptRun({ ...record, status: "completed", output: output.result });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Persist a scheduled script fire as a terminal `script_runs` row, so script
|
|
141
|
+
* audits see schedule-driven runs the same way as POST /api/scripts/run.
|
|
142
|
+
* Best effort: a persistence error never changes the schedule's outcome.
|
|
143
|
+
*/
|
|
144
|
+
async function recordScheduleScriptRun(run: {
|
|
145
|
+
agentId: string;
|
|
146
|
+
scriptName: string;
|
|
147
|
+
source: string;
|
|
148
|
+
args: unknown;
|
|
149
|
+
startedAt: string;
|
|
150
|
+
status: "completed" | "failed";
|
|
151
|
+
output?: unknown;
|
|
152
|
+
error?: string;
|
|
153
|
+
}): Promise<void> {
|
|
154
|
+
try {
|
|
155
|
+
await recordInlineScriptRun({
|
|
156
|
+
id: crypto.randomUUID(),
|
|
157
|
+
agentId: run.agentId,
|
|
158
|
+
scriptName: run.scriptName,
|
|
159
|
+
source: run.source,
|
|
160
|
+
args: scrubObject(run.args),
|
|
161
|
+
status: run.status,
|
|
162
|
+
output: scrubObject(run.output),
|
|
163
|
+
error: run.error === undefined ? undefined : scrubSecrets(run.error),
|
|
164
|
+
startedAt: run.startedAt,
|
|
165
|
+
finishedAt: new Date().toISOString(),
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.warn(
|
|
169
|
+
`[scheduler] could not record script run for '${run.scriptName}': ${scrubSecrets(
|
|
170
|
+
err instanceof Error ? err.message : String(err),
|
|
171
|
+
)}`,
|
|
103
172
|
);
|
|
104
173
|
}
|
|
105
174
|
}
|
|
@@ -8,7 +8,7 @@ const EXECUTORS: Record<string, () => ScriptExecutor> = {
|
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
export function getScriptExecutor(name = process.env.SCRIPT_EXECUTOR ?? "native"): ScriptExecutor {
|
|
11
|
-
const factory = EXECUTORS[name];
|
|
11
|
+
const factory = EXECUTORS[name.trim()];
|
|
12
12
|
if (!factory) {
|
|
13
13
|
throw new Error(
|
|
14
14
|
`Unknown SCRIPT_EXECUTOR: ${name}. Available: ${Object.keys(EXECUTORS).join(", ")}`,
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { appendFile, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ClaudeAdapter } from "../providers/claude-adapter";
|
|
6
|
+
import { BACKGROUND_KEEPALIVE_INTERVAL_MS } from "../providers/claude-background-keepalive";
|
|
7
|
+
import type { ProviderResult, ProviderSession, ProviderSessionConfig } from "../providers/types";
|
|
8
|
+
import { CHILD_PROCESS_TEST_BUDGET_MS } from "./test-proc";
|
|
9
|
+
|
|
10
|
+
// These tests go through ClaudeAdapter.createSession, so they cover the wiring
|
|
11
|
+
// in each transport: observe() on every parsed message and stop() at teardown.
|
|
12
|
+
// A fake Claude binary ends its turn and goes silent; a local server records the
|
|
13
|
+
// session heartbeats the keepalive sends while it waits.
|
|
14
|
+
|
|
15
|
+
const fixturePath = join(import.meta.dir, "claude-background-keepalive.fixture.ts");
|
|
16
|
+
const TICK_MS = 20;
|
|
17
|
+
|
|
18
|
+
// The keepalive pings every 60s. Run its interval at TICK_MS instead; every
|
|
19
|
+
// other timer keeps its real period.
|
|
20
|
+
const realSetInterval = globalThis.setInterval;
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
globalThis.setInterval = ((handler: TimerHandler, timeout?: number, ...rest: unknown[]) =>
|
|
23
|
+
realSetInterval(
|
|
24
|
+
handler,
|
|
25
|
+
timeout === BACKGROUND_KEEPALIVE_INTERVAL_MS ? TICK_MS : timeout,
|
|
26
|
+
...rest,
|
|
27
|
+
)) as typeof setInterval;
|
|
28
|
+
});
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
globalThis.setInterval = realSetInterval;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const heartbeats = new Map<string, number>();
|
|
34
|
+
const server = Bun.serve({
|
|
35
|
+
port: 0,
|
|
36
|
+
fetch(req) {
|
|
37
|
+
const match = new URL(req.url).pathname.match(/^\/api\/active-sessions\/heartbeat\/(.+)$/);
|
|
38
|
+
if (req.method === "PUT" && match?.[1]) {
|
|
39
|
+
const taskId = decodeURIComponent(match[1]);
|
|
40
|
+
heartbeats.set(taskId, (heartbeats.get(taskId) ?? 0) + 1);
|
|
41
|
+
}
|
|
42
|
+
return Response.json({ updated: true });
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
afterAll(() => server.stop(true));
|
|
46
|
+
|
|
47
|
+
const temporaryDirectories: string[] = [];
|
|
48
|
+
let originalHome: string | undefined;
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
// Every session seeds Claude trust into `$HOME/.claude.json`; keep that off the real home.
|
|
52
|
+
originalHome = process.env.HOME;
|
|
53
|
+
const home = await mkdtemp(join(tmpdir(), "claude-bg-keepalive-home-"));
|
|
54
|
+
temporaryDirectories.push(home);
|
|
55
|
+
process.env.HOME = home;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
if (originalHome === undefined) delete process.env.HOME;
|
|
60
|
+
else process.env.HOME = originalHome;
|
|
61
|
+
await Promise.all(
|
|
62
|
+
temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
type Transport = "cli" | "sdk";
|
|
67
|
+
|
|
68
|
+
interface Harness {
|
|
69
|
+
session: ProviderSession;
|
|
70
|
+
taskId: string;
|
|
71
|
+
send(command: "empty" | "exit" | "fail"): Promise<void>;
|
|
72
|
+
count(): number;
|
|
73
|
+
completed(): boolean;
|
|
74
|
+
turnEnded(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function startSession(transport: Transport, withBackgroundTask: boolean): Promise<Harness> {
|
|
78
|
+
const directory = await mkdtemp(join(tmpdir(), "claude-bg-keepalive-"));
|
|
79
|
+
temporaryDirectories.push(directory);
|
|
80
|
+
const controlFile = join(directory, "control");
|
|
81
|
+
const logFile = join(directory, "session.jsonl");
|
|
82
|
+
const taskId = crypto.randomUUID();
|
|
83
|
+
const config: ProviderSessionConfig = {
|
|
84
|
+
prompt: "wait on the background job",
|
|
85
|
+
systemPrompt: "",
|
|
86
|
+
model: "claude-haiku-4-5",
|
|
87
|
+
role: "worker",
|
|
88
|
+
agentId: crypto.randomUUID(),
|
|
89
|
+
taskId,
|
|
90
|
+
apiUrl: `http://127.0.0.1:${server.port}`,
|
|
91
|
+
apiKey: "test-key",
|
|
92
|
+
cwd: directory,
|
|
93
|
+
logFile,
|
|
94
|
+
env: {
|
|
95
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
96
|
+
CLAUDE_CODE_OAUTH_TOKEN: "example-fixture-oauth-token",
|
|
97
|
+
CLAUDE_TRANSPORT: transport,
|
|
98
|
+
CLAUDE_BINARY: `${process.execPath} ${fixturePath}`,
|
|
99
|
+
BG_FIXTURE_TRANSPORT: transport,
|
|
100
|
+
BG_FIXTURE_CONTROL: controlFile,
|
|
101
|
+
BG_FIXTURE_BACKGROUND: withBackgroundTask ? "1" : "0",
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const session = await new ClaudeAdapter(async () => {}).createSession(config);
|
|
105
|
+
let completed = false;
|
|
106
|
+
void session.waitForCompletion().finally(() => {
|
|
107
|
+
completed = true;
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
session,
|
|
111
|
+
taskId,
|
|
112
|
+
completed: () => completed,
|
|
113
|
+
send: (command) => appendFile(controlFile, `${command}\n`),
|
|
114
|
+
count: () => heartbeats.get(taskId) ?? 0,
|
|
115
|
+
// Both transports log every protocol line; the result line marks the end of the turn.
|
|
116
|
+
turnEnded: () =>
|
|
117
|
+
waitFor(async () => {
|
|
118
|
+
const file = Bun.file(logFile);
|
|
119
|
+
return (await file.exists()) && (await file.text()).includes('"type":"result"');
|
|
120
|
+
}, "the fixture's result message"),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function waitFor(
|
|
125
|
+
condition: () => boolean | Promise<boolean>,
|
|
126
|
+
what: string,
|
|
127
|
+
timeoutMs = 5_000,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
const deadline = Date.now() + timeoutMs;
|
|
130
|
+
while (!(await condition())) {
|
|
131
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${what}`);
|
|
132
|
+
await Bun.sleep(TICK_MS / 2);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Waits out in-flight requests, then asserts no further heartbeat arrives. */
|
|
137
|
+
async function expectHeartbeatsStopped(harness: Harness): Promise<void> {
|
|
138
|
+
await Bun.sleep(TICK_MS * 3);
|
|
139
|
+
const settled = harness.count();
|
|
140
|
+
await Bun.sleep(TICK_MS * 8);
|
|
141
|
+
expect(harness.count()).toBe(settled);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function liveSessionWithHeartbeats(transport: Transport): Promise<Harness> {
|
|
145
|
+
const harness = await startSession(transport, true);
|
|
146
|
+
await harness.turnEnded();
|
|
147
|
+
// The turn is over and the process is silent; only the keepalive is sending.
|
|
148
|
+
await waitFor(() => harness.count() >= 3, "keepalive heartbeats after the turn ended");
|
|
149
|
+
return harness;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
describe.each(["cli", "sdk"] as const)("Claude %s transport background keepalive", (transport) => {
|
|
153
|
+
test(
|
|
154
|
+
"heartbeats while a background task stays live after the turn ends, and stops when the set empties",
|
|
155
|
+
async () => {
|
|
156
|
+
const harness = await liveSessionWithHeartbeats(transport);
|
|
157
|
+
try {
|
|
158
|
+
await harness.send("empty");
|
|
159
|
+
await expectHeartbeatsStopped(harness);
|
|
160
|
+
// The session is still running: the empty set stopped the pings, not teardown.
|
|
161
|
+
expect(harness.completed()).toBe(false);
|
|
162
|
+
} finally {
|
|
163
|
+
await harness.send("exit");
|
|
164
|
+
await harness.session.waitForCompletion();
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
test(
|
|
171
|
+
"normal end stops the keepalive",
|
|
172
|
+
async () => {
|
|
173
|
+
const harness = await liveSessionWithHeartbeats(transport);
|
|
174
|
+
await harness.send("exit");
|
|
175
|
+
const result = await harness.session.waitForCompletion();
|
|
176
|
+
expect(result.isError).toBe(false);
|
|
177
|
+
await expectHeartbeatsStopped(harness);
|
|
178
|
+
},
|
|
179
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
test(
|
|
183
|
+
"error end stops the keepalive",
|
|
184
|
+
async () => {
|
|
185
|
+
const harness = await liveSessionWithHeartbeats(transport);
|
|
186
|
+
await harness.send("fail");
|
|
187
|
+
const result: ProviderResult = await harness.session.waitForCompletion();
|
|
188
|
+
expect(result.exitCode).not.toBe(0);
|
|
189
|
+
await expectHeartbeatsStopped(harness);
|
|
190
|
+
},
|
|
191
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
test(
|
|
195
|
+
"abort stops the keepalive",
|
|
196
|
+
async () => {
|
|
197
|
+
const harness = await liveSessionWithHeartbeats(transport);
|
|
198
|
+
await harness.session.abort();
|
|
199
|
+
await harness.session.waitForCompletion();
|
|
200
|
+
await expectHeartbeatsStopped(harness);
|
|
201
|
+
},
|
|
202
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
test(
|
|
206
|
+
"a session with no background work sends no keepalive heartbeats",
|
|
207
|
+
async () => {
|
|
208
|
+
const harness = await startSession(transport, false);
|
|
209
|
+
try {
|
|
210
|
+
await harness.turnEnded();
|
|
211
|
+
await Bun.sleep(TICK_MS * 10);
|
|
212
|
+
expect(harness.count()).toBe(0);
|
|
213
|
+
} finally {
|
|
214
|
+
await harness.send("exit");
|
|
215
|
+
await harness.session.waitForCompletion();
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
219
|
+
);
|
|
220
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Fake Claude binary for the background-keepalive wiring tests. It speaks just
|
|
2
|
+
// enough of both transports: the SDK initialize handshake on stdin, and the
|
|
3
|
+
// stream-json stdout that the CLI and SDK sessions both parse. After one turn it
|
|
4
|
+
// goes silent, the way Claude does while it waits on a background job, until the
|
|
5
|
+
// test steps it through the control file (one command per line).
|
|
6
|
+
const transport = process.env.BG_FIXTURE_TRANSPORT;
|
|
7
|
+
const controlFile = process.env.BG_FIXTURE_CONTROL;
|
|
8
|
+
const withBackgroundTask = process.env.BG_FIXTURE_BACKGROUND === "1";
|
|
9
|
+
const sessionId = "22222222-2222-4222-8222-222222222222";
|
|
10
|
+
|
|
11
|
+
if (process.argv.includes("--version")) {
|
|
12
|
+
console.log("2.1.263 (Claude Code)");
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if ((transport !== "cli" && transport !== "sdk") || !controlFile) {
|
|
17
|
+
console.error("BG_FIXTURE_TRANSPORT (cli|sdk) and BG_FIXTURE_CONTROL are required");
|
|
18
|
+
process.exit(2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeMessage(message: Record<string, unknown>): void {
|
|
22
|
+
console.log(JSON.stringify(message));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function backgroundTasksChanged(tasks: Array<Record<string, unknown>>): void {
|
|
26
|
+
writeMessage({
|
|
27
|
+
type: "system",
|
|
28
|
+
subtype: "background_tasks_changed",
|
|
29
|
+
tasks,
|
|
30
|
+
uuid: crypto.randomUUID(),
|
|
31
|
+
session_id: sessionId,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const usage = {
|
|
36
|
+
input_tokens: 10,
|
|
37
|
+
output_tokens: 1,
|
|
38
|
+
cache_read_input_tokens: 0,
|
|
39
|
+
cache_creation_input_tokens: 0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let started = false;
|
|
43
|
+
|
|
44
|
+
async function runTurn(): Promise<void> {
|
|
45
|
+
if (started) return;
|
|
46
|
+
started = true;
|
|
47
|
+
writeMessage({
|
|
48
|
+
type: "system",
|
|
49
|
+
subtype: "init",
|
|
50
|
+
session_id: sessionId,
|
|
51
|
+
model: "claude-haiku-4-5",
|
|
52
|
+
tools: [],
|
|
53
|
+
mcp_servers: [],
|
|
54
|
+
plugins: [],
|
|
55
|
+
permissionMode: "bypassPermissions",
|
|
56
|
+
slash_commands: [],
|
|
57
|
+
apiKeySource: "none",
|
|
58
|
+
cwd: process.cwd(),
|
|
59
|
+
claude_code_version: "2.1.263",
|
|
60
|
+
output_style: "default",
|
|
61
|
+
skills: [],
|
|
62
|
+
uuid: crypto.randomUUID(),
|
|
63
|
+
});
|
|
64
|
+
if (withBackgroundTask) {
|
|
65
|
+
backgroundTasksChanged([
|
|
66
|
+
{ task_id: "bg-1", task_type: "local_bash", description: "Wait for TLC" },
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
writeMessage({
|
|
70
|
+
type: "assistant",
|
|
71
|
+
message: {
|
|
72
|
+
id: "message-1",
|
|
73
|
+
type: "message",
|
|
74
|
+
role: "assistant",
|
|
75
|
+
model: "claude-haiku-4-5",
|
|
76
|
+
content: [{ type: "text", text: "Waiting for the background job to finish." }],
|
|
77
|
+
stop_reason: "end_turn",
|
|
78
|
+
stop_sequence: null,
|
|
79
|
+
usage,
|
|
80
|
+
},
|
|
81
|
+
parent_tool_use_id: null,
|
|
82
|
+
uuid: crypto.randomUUID(),
|
|
83
|
+
session_id: sessionId,
|
|
84
|
+
});
|
|
85
|
+
writeMessage({
|
|
86
|
+
type: "result",
|
|
87
|
+
subtype: "success",
|
|
88
|
+
duration_ms: 1,
|
|
89
|
+
duration_api_ms: 1,
|
|
90
|
+
is_error: false,
|
|
91
|
+
num_turns: 1,
|
|
92
|
+
result: "Waiting for the background job to finish.",
|
|
93
|
+
stop_reason: "end_turn",
|
|
94
|
+
total_cost_usd: 0.001,
|
|
95
|
+
usage,
|
|
96
|
+
modelUsage: {},
|
|
97
|
+
permission_denials: [],
|
|
98
|
+
queued_turn_count: 0,
|
|
99
|
+
uuid: crypto.randomUUID(),
|
|
100
|
+
session_id: sessionId,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Silent from here on, except for what the test asks for.
|
|
104
|
+
let handled = 0;
|
|
105
|
+
for (;;) {
|
|
106
|
+
const file = Bun.file(controlFile as string);
|
|
107
|
+
const commands = (await file.exists()) ? (await file.text()).split("\n").filter(Boolean) : [];
|
|
108
|
+
while (handled < commands.length) {
|
|
109
|
+
const command = commands[handled++];
|
|
110
|
+
if (command === "empty") backgroundTasksChanged([]);
|
|
111
|
+
if (command === "exit") process.exit(0);
|
|
112
|
+
if (command === "fail") {
|
|
113
|
+
console.error("Error: fixture process crashed");
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
await Bun.sleep(10);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readSdkInput(): Promise<void> {
|
|
122
|
+
const decoder = new TextDecoder();
|
|
123
|
+
let buffer = "";
|
|
124
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
125
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
126
|
+
let newline = buffer.indexOf("\n");
|
|
127
|
+
while (newline >= 0) {
|
|
128
|
+
const line = buffer.slice(0, newline).trim();
|
|
129
|
+
buffer = buffer.slice(newline + 1);
|
|
130
|
+
newline = buffer.indexOf("\n");
|
|
131
|
+
if (!line) continue;
|
|
132
|
+
const message = JSON.parse(line) as {
|
|
133
|
+
type?: string;
|
|
134
|
+
request_id?: string;
|
|
135
|
+
request?: { subtype?: string };
|
|
136
|
+
};
|
|
137
|
+
if (message.type === "control_request" && message.request?.subtype === "initialize") {
|
|
138
|
+
writeMessage({
|
|
139
|
+
type: "control_response",
|
|
140
|
+
response: {
|
|
141
|
+
subtype: "success",
|
|
142
|
+
request_id: message.request_id ?? "fixture-initialize",
|
|
143
|
+
response: {
|
|
144
|
+
commands: [],
|
|
145
|
+
agents: [],
|
|
146
|
+
output_style: "default",
|
|
147
|
+
available_output_styles: [],
|
|
148
|
+
models: [],
|
|
149
|
+
account: {},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
} else if (message.type === "user") {
|
|
154
|
+
void runTurn();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The CLI transport passes the prompt with -p; the SDK sends it on stdin.
|
|
161
|
+
if (transport === "sdk") void readSdkInput();
|
|
162
|
+
else await runTurn();
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { ClaudeBackgroundKeepalive } from "../providers/claude-background-keepalive";
|
|
3
|
+
|
|
4
|
+
// The keepalive's contract is the HTTP heartbeat it sends while Claude idles on
|
|
5
|
+
// background work. A local server records those requests; short intervals keep
|
|
6
|
+
// the timing real without fake clocks.
|
|
7
|
+
const INTERVAL_MS = 20;
|
|
8
|
+
// Real timers under CI's parallel load can slip a tick; retry instead of
|
|
9
|
+
// widening every window.
|
|
10
|
+
const TIMING = { retry: 2 };
|
|
11
|
+
const received: Array<{ method: string; path: string; auth: string | null; agent: string | null }> =
|
|
12
|
+
[];
|
|
13
|
+
const server = Bun.serve({
|
|
14
|
+
port: 0,
|
|
15
|
+
fetch(req) {
|
|
16
|
+
const url = new URL(req.url);
|
|
17
|
+
received.push({
|
|
18
|
+
method: req.method,
|
|
19
|
+
path: url.pathname,
|
|
20
|
+
auth: req.headers.get("authorization"),
|
|
21
|
+
agent: req.headers.get("x-agent-id"),
|
|
22
|
+
});
|
|
23
|
+
return Response.json({ updated: true });
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(() => server.stop(true));
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
received.length = 0;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function keepalive(maxIdleMs?: number, apiUrl = `http://127.0.0.1:${server.port}`) {
|
|
33
|
+
return new ClaudeBackgroundKeepalive({
|
|
34
|
+
apiUrl,
|
|
35
|
+
apiKey: "key-123",
|
|
36
|
+
agentId: "agent-1",
|
|
37
|
+
taskId: "task-1",
|
|
38
|
+
intervalMs: INTERVAL_MS,
|
|
39
|
+
maxIdleMs,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const bgChanged = (tasks: Array<Record<string, unknown>>) => ({
|
|
44
|
+
type: "system",
|
|
45
|
+
subtype: "background_tasks_changed",
|
|
46
|
+
tasks,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("ClaudeBackgroundKeepalive", () => {
|
|
50
|
+
test(
|
|
51
|
+
"keeps heartbeating after the turn ends while a background Bash job is live",
|
|
52
|
+
async () => {
|
|
53
|
+
const k = keepalive();
|
|
54
|
+
// Message order from the stalled run: the backgrounded Bash joins the set,
|
|
55
|
+
// the assistant ends its turn, then the process goes silent.
|
|
56
|
+
k.observe(
|
|
57
|
+
bgChanged([{ task_id: "bscveebhn", task_type: "local_bash", description: "Wait for TLC" }]),
|
|
58
|
+
);
|
|
59
|
+
k.observe({ type: "assistant" });
|
|
60
|
+
k.observe({ type: "result", subtype: "success" });
|
|
61
|
+
await Bun.sleep(INTERVAL_MS * 6);
|
|
62
|
+
k.stop();
|
|
63
|
+
|
|
64
|
+
expect(received.length).toBeGreaterThanOrEqual(3);
|
|
65
|
+
expect(received[0]).toEqual({
|
|
66
|
+
method: "PUT",
|
|
67
|
+
path: "/api/active-sessions/heartbeat/task-1",
|
|
68
|
+
auth: "Bearer key-123",
|
|
69
|
+
agent: "agent-1",
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
TIMING,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
test(
|
|
76
|
+
"a silent session with no background work sends nothing, so it still goes stale",
|
|
77
|
+
async () => {
|
|
78
|
+
const k = keepalive();
|
|
79
|
+
k.observe({ type: "assistant" });
|
|
80
|
+
k.observe({ type: "result", subtype: "success" });
|
|
81
|
+
// Ambient watchers are not activity either.
|
|
82
|
+
k.observe(bgChanged([{ task_id: "watcher", ambient: true }]));
|
|
83
|
+
await Bun.sleep(INTERVAL_MS * 5);
|
|
84
|
+
k.stop();
|
|
85
|
+
expect(received).toHaveLength(0);
|
|
86
|
+
},
|
|
87
|
+
TIMING,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
test(
|
|
91
|
+
"stops when the background set empties",
|
|
92
|
+
async () => {
|
|
93
|
+
const k = keepalive();
|
|
94
|
+
k.observe(bgChanged([{ task_id: "a" }, { task_id: "b" }]));
|
|
95
|
+
await Bun.sleep(INTERVAL_MS * 3);
|
|
96
|
+
k.observe(bgChanged([]));
|
|
97
|
+
await Bun.sleep(INTERVAL_MS);
|
|
98
|
+
const settled = received.length;
|
|
99
|
+
expect(settled).toBeGreaterThan(0);
|
|
100
|
+
await Bun.sleep(INTERVAL_MS * 4);
|
|
101
|
+
k.stop();
|
|
102
|
+
expect(received).toHaveLength(settled);
|
|
103
|
+
},
|
|
104
|
+
TIMING,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
test(
|
|
108
|
+
"a background job that never settles is covered only up to the idle cap",
|
|
109
|
+
async () => {
|
|
110
|
+
const k = keepalive(INTERVAL_MS * 3);
|
|
111
|
+
k.observe(bgChanged([{ task_id: "hung" }]));
|
|
112
|
+
await Bun.sleep(INTERVAL_MS * 6);
|
|
113
|
+
const capped = received.length;
|
|
114
|
+
expect(capped).toBeGreaterThan(0);
|
|
115
|
+
await Bun.sleep(INTERVAL_MS * 4);
|
|
116
|
+
expect(received).toHaveLength(capped);
|
|
117
|
+
|
|
118
|
+
// New output from the session restarts coverage while the job is live.
|
|
119
|
+
k.observe({ type: "assistant" });
|
|
120
|
+
await Bun.sleep(INTERVAL_MS * 2);
|
|
121
|
+
k.stop();
|
|
122
|
+
expect(received.length).toBeGreaterThan(capped);
|
|
123
|
+
},
|
|
124
|
+
TIMING,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
test(
|
|
128
|
+
"stop() ends the keepalive and ignores later messages",
|
|
129
|
+
async () => {
|
|
130
|
+
const k = keepalive();
|
|
131
|
+
k.observe(bgChanged([{ task_id: "a" }]));
|
|
132
|
+
k.stop();
|
|
133
|
+
k.observe(bgChanged([{ task_id: "b" }]));
|
|
134
|
+
await Bun.sleep(INTERVAL_MS * 4);
|
|
135
|
+
expect(received).toHaveLength(0);
|
|
136
|
+
},
|
|
137
|
+
TIMING,
|
|
138
|
+
);
|
|
139
|
+
});
|