@desplega.ai/agent-swarm 2.5.3 → 2.5.4
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-gr2fcyyj.js → acp-adapter-ek38cwsv.js} +4 -4
- package/dist/{actions-wv766ghf.js → actions-92mepq03.js} +17 -17
- package/dist/{amp-adapter-speb906t.js → amp-adapter-9gaxg015.js} +2 -2
- package/dist/{app-ag29n14f.js → app-ctn8gsgx.js} +3 -3
- package/dist/{assistant-3x8bz3eg.js → assistant-zyr5kz47.js} +19 -19
- package/dist/{boot-reembed-jsmfrpzf.js → boot-reembed-dwzfttdr.js} +1 -1
- package/dist/{boot-reembed-5b3d2eea.js → boot-reembed-w75maga2.js} +1 -1
- package/dist/{boot-scrub-sweep-s73gerxy.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
- package/dist/{boot-seeds-5zmax2wr.js → boot-seeds-x28v3fhb.js} +4 -4
- package/dist/{claude-adapter-hswdnm3t.js → claude-adapter-f0afqnne.js} +4 -4
- package/dist/{claude-managed-adapter-cvbzj1h6.js → claude-managed-adapter-x81a7r65.js} +3 -3
- package/dist/{claude-managed-setup-3k8m3hhc.js → claude-managed-setup-bqzwjd9x.js} +1 -1
- package/dist/{claude-sdk-session-fkbyk66x.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
- package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
- package/dist/{cli-vwn81jbs.js → cli-19202kew.js} +3 -3
- package/dist/{cli-svrj71pg.js → cli-1r5ad7j5.js} +7 -7
- package/dist/{cli-hhxjnjzs.js → cli-25n3mv5d.js} +8 -6
- package/dist/cli-2655k16q.js +140 -0
- package/dist/{cli-hcpj2xqz.js → cli-277c31dw.js} +1 -1
- package/dist/{cli-35gtk1p6.js → cli-4b2z6n0m.js} +29 -7
- package/dist/{cli-40jsnrjm.js → cli-5bnsc0e1.js} +7 -5
- package/dist/{cli-fzcn3d87.js → cli-621ce7kw.js} +1 -1
- package/dist/{cli-s49hpn37.js → cli-64mh9mf3.js} +19 -5
- package/dist/{cli-e8jyq7d8.js → cli-6a1xg1p7.js} +6 -2
- package/dist/{cli-p9rafdrb.js → cli-7p4jxgtt.js} +4 -4
- package/dist/{cli-w1zwctj2.js → cli-9j129brq.js} +9 -3
- package/dist/{cli-ce0ak9ms.js → cli-abs8ndtr.js} +11 -11
- package/dist/{cli-aecdcr77.js → cli-asq5ydm4.js} +123 -50
- package/dist/{cli-n45mr7zh.js → cli-bvpgr9ny.js} +2 -2
- package/dist/{cli-210xd1f4.js → cli-c61mbdds.js} +3 -3
- package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
- package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
- package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
- package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
- package/dist/{cli-s53vp709.js → cli-eftzyh9y.js} +1 -1
- package/dist/{cli-3ngdk5te.js → cli-ethgy7ha.js} +1 -1
- package/dist/{cli-99xgw26r.js → cli-ff1tm9n9.js} +1 -1
- package/dist/{cli-ewm3pwbq.js → cli-g9vd9ece.js} +37 -12
- package/dist/{cli-jjznk3f7.js → cli-gbfxp48k.js} +62 -28
- package/dist/{cli-z21whrrg.js → cli-gn5af0x5.js} +14 -14
- package/dist/{cli-1fc85mvt.js → cli-gwhjmh8g.js} +4 -4
- package/dist/{cli-avdvgcay.js → cli-hh5we352.js} +24 -5
- package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
- package/dist/{cli-kxawjrxg.js → cli-jb30dzvk.js} +332 -36
- package/dist/{cli-25j01dpj.js → cli-jbnn4vxb.js} +3 -3
- package/dist/{cli-qzme5949.js → cli-k4tjsdx9.js} +44 -39
- package/dist/{cli-8s5wx3nz.js → cli-m4ecdxvz.js} +40 -13
- package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
- package/dist/{cli-p6zs9h4y.js → cli-rr7zx19j.js} +767 -249
- package/dist/{cli-502eqv93.js → cli-s682zgqz.js} +1 -1
- package/dist/{cli-fsdprwrg.js → cli-sbwsxvtd.js} +1 -1
- package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
- package/dist/{cli-egjggehm.js → cli-tsma7p8v.js} +1 -1
- package/dist/{cli-qgf8emrn.js → cli-wghjkr0e.js} +5 -5
- package/dist/{cli-p0rzjkya.js → cli-wx2nj19m.js} +29 -10
- package/dist/{cli-e2ar4bp1.js → cli-x0f2v69c.js} +111 -42
- package/dist/{cli-y561h5a9.js → cli-xtrz6bzs.js} +5 -5
- package/dist/{cli-gpsz7g88.js → cli-xzfq4at3.js} +7 -7
- package/dist/cli.js +18 -18
- package/dist/{codex-adapter-n8ka66vf.js → codex-adapter-wch12q99.js} +2 -2
- package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
- package/dist/{codex-session-runner-fsebts37.js → codex-session-runner-41pfcr95.js} +2 -2
- package/dist/{commands-t04rt2pc.js → commands-9849dkj6.js} +2 -2
- package/dist/{controller-rgna04r2.js → controller-ja0sd95p.js} +27 -1
- package/dist/{controllers-connect-bqm1rtft.js → controllers-connect-xp05dj6x.js} +16 -16
- package/dist/{cursor-adapter-swr2k95y.js → cursor-adapter-623qxqew.js} +1 -2
- package/dist/{db-a4m7haw0.js → db-47vj40dy.js} +3 -3
- package/dist/{db-24ck09xy.js → db-w759qmx0.js} +2 -2
- package/dist/{dispatcher-nwf9ss0q.js → dispatcher-7b8e4z2j.js} +15 -15
- package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
- package/dist/{dsh-adapter-kpdnhjst.js → dsh-adapter-2ezw3xea.js} +1 -2
- package/dist/{external-channel-j1e2p34p.js → external-channel-e2s028da.js} +2 -2
- package/dist/{grok-adapter-qtjz0whk.js → grok-adapter-ys7gaqee.js} +5 -5
- package/dist/{handlers-19xr6dnz.js → handlers-wy1dr6ja.js} +18 -18
- package/dist/{heartbeat-vtesda5d.js → heartbeat-w3mntghj.js} +17 -17
- package/dist/{hook-8wqaszda.js → hook-3jjywnpb.js} +9 -3
- package/dist/{hook-b2hhj6rd.js → hook-g7kvv6x9.js} +3 -2
- package/dist/{http-mxcqhhcs.js → http-tkp7d4kb.js} +267 -75
- package/dist/{index-srg522m6.js → index-1ef1myq2.js} +15 -15
- package/dist/{index-1r8wfpzs.js → index-3n8m9ea2.js} +1 -1
- package/dist/{index-kw2mmat7.js → index-nmm79qgr.js} +17 -17
- package/dist/{index-n1vbvta9.js → index-q7m26kvs.js} +24 -22
- package/dist/{index-spwxjgay.js → index-xdaahthb.js} +15 -15
- package/dist/{keepalive-dsd8rtgx.js → keepalive-v4ta8wgg.js} +3 -3
- package/dist/{lead-zyerk1x2.js → lead-sawnyqxk.js} +33 -33
- package/dist/{maintenance-jqmqwnet.js → maintenance-84n1ex21.js} +1 -1
- package/dist/{oauth-refresh-sweep-q2c3bnvm.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
- package/dist/{onboard-w4yd7s8t.js → onboard-sfmpe4np.js} +2 -2
- package/dist/{opencode-adapter-9tppvxar.js → opencode-adapter-jmpst4q4.js} +3 -3
- package/dist/{otel-impl-f36byzbd.js → otel-impl-v6cc0zve.js} +1 -1
- package/dist/{pi-mono-adapter-tqsge1ay.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
- package/dist/{pricing-refresh-xvak5p4v.js → pricing-refresh-fsk1e1zc.js} +3 -3
- package/dist/{rbac-roles-gfn30fb8.js → rbac-roles-prmkdnkw.js} +2 -2
- package/dist/{rbac-roles-682k2mt5.js → rbac-roles-zxmxqa39.js} +1 -1
- package/dist/{render-v2-hgytz8nn.js → render-v2-pjwxvnkw.js} +3 -3
- package/dist/{resume-2dvq348g.js → resume-frdtgfa0.js} +8 -6
- package/dist/{session-access-7f21ekq9.js → session-access-fpez4y5q.js} +4 -4
- package/dist/{setup-epffxtbk.js → setup-mq6m3m8q.js} +1 -1
- package/dist/{work-objects-xz1a5rqk.js → work-objects-kfahhjsq.js} +18 -18
- package/dist/{worker-42t8emjt.js → worker-v5e5rxj3.js} +33 -33
- package/openapi.json +68 -3
- package/package.json +5 -5
- package/plugin/commands/create-pr.md +2 -2
- package/plugin/pi-skills/create-pr/SKILL.md +2 -2
- package/src/be/db/approvals.ts +48 -0
- package/src/be/db/tasks/write.ts +15 -5
- package/src/be/db/workflow-idempotency.ts +183 -0
- package/src/be/db/workflow-runs.ts +318 -7
- package/src/be/db.ts +67 -17
- package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
- package/src/be/sealed-json.ts +1 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
- package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/sql-connections.ts +7 -1
- package/src/be/swarm-config-guard.ts +2 -0
- package/src/be/users.ts +9 -0
- package/src/commands/controller.ts +33 -0
- package/src/commands/runner.ts +6 -2
- package/src/extensions/apply-task-create.ts +57 -11
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +25 -0
- package/src/extensions/dispatcher.ts +4 -0
- package/src/extensions/post-bridge.ts +8 -0
- package/src/hooks/context-bound-note.ts +196 -0
- package/src/hooks/hook.ts +12 -0
- package/src/http/config.ts +48 -16
- package/src/http/idempotency-key.ts +117 -0
- package/src/http/workflows.ts +129 -24
- package/src/prompts/context-bound-note.ts +48 -0
- package/src/prompts/session-templates.ts +25 -1
- package/src/providers/amp-adapter.ts +30 -8
- package/src/providers/claude-adapter.ts +29 -4
- package/src/providers/claude-managed-adapter.ts +26 -0
- package/src/providers/claude-sdk-session.ts +10 -0
- package/src/providers/claude-session-events.ts +13 -0
- package/src/providers/codex-adapter.ts +12 -0
- package/src/providers/opencode-adapter.ts +18 -0
- package/src/providers/pi-mono-adapter.ts +35 -0
- package/src/runtime-providers/docker.ts +6 -2
- package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
- package/src/tests/amp-adapter.test.ts +97 -0
- package/src/tests/approval-requests.test.ts +10 -7
- package/src/tests/approval-sweeps.test.ts +12 -6
- package/src/tests/base-prompt.test.ts +1 -1
- package/src/tests/claude-hook-output.test.ts +79 -1
- package/src/tests/claude-managed-adapter.test.ts +275 -0
- package/src/tests/codex-adapter.test.ts +163 -0
- package/src/tests/context-bound-note.test.ts +269 -0
- package/src/tests/extensions-pre-task.test.ts +273 -0
- package/src/tests/fetch-resolved-env.test.ts +17 -0
- package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
- package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
- package/src/tests/fixtures/fake-amp.ts +22 -8
- package/src/tests/fixtures/held-template-render.ts +42 -0
- package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
- package/src/tests/openapi-route-headers.test.ts +2 -0
- package/src/tests/opencode-adapter.test.ts +134 -0
- package/src/tests/pi-mono-adapter.test.ts +105 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/runner-push-delivery.test.ts +25 -0
- package/src/tests/runtime-controller-liveness.test.ts +133 -0
- package/src/tests/runtime-providers-docker.test.ts +2 -0
- package/src/tests/script-connections.test.ts +3 -0
- package/src/tests/script-run-config-scope.test.ts +91 -3
- package/src/tests/session-logs.test.ts +70 -0
- package/src/tests/sessions-tree.test.ts +37 -0
- package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
- package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
- package/src/tests/workflow-idempotency.test.ts +915 -0
- package/src/tools/workflows/cancel-workflow-run.ts +1 -1
- package/src/utils/context-window.ts +8 -6
- package/src/workflows/approval-cancel.ts +15 -13
- package/src/workflows/approval-notifications.ts +5 -1
- package/src/workflows/checkpoint.ts +6 -3
- package/src/workflows/engine.ts +30 -8
- package/src/workflows/executors/foreach.ts +2 -2
- package/src/workflows/foreach-driver.ts +10 -3
- package/src/workflows/foreach-join.ts +29 -6
- package/src/workflows/recovery.ts +6 -1
- package/src/workflows/resume.ts +168 -56
- package/src/workflows/retry-poller.ts +49 -3
- package/src/workflows/task-step-routing.ts +29 -7
- package/templates/ai-toolbox.manifest.json +8 -8
- package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
- package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
- package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
- package/templates/skills/agent-browser/SKILL.md +4 -4
- package/templates/skills/agent-browser/content.md +4 -4
- package/templates/skills/code-quality/config.json +1 -1
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/delegate-work/content.md +29 -25
- package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
- package/templates/skills/qa/content.md +2 -2
- package/templates/skills/researching/content.md +2 -0
- package/templates/skills/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { configureHttpResolver, resetHttpResolver } from "../../prompts/resolver";
|
|
2
|
+
|
|
3
|
+
export const HELD_NOTE_TEXT = "Finish current work and complete or defer.";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Point the prompt resolver at a local server whose template responses wait
|
|
7
|
+
* for `release()`, so a test can choose when an async note render finishes.
|
|
8
|
+
* `requested` resolves on the first render request. `stop()` releases,
|
|
9
|
+
* restores the resolver and closes the server.
|
|
10
|
+
*/
|
|
11
|
+
export function holdTemplateRender(): {
|
|
12
|
+
release: () => void;
|
|
13
|
+
requested: Promise<void>;
|
|
14
|
+
stop: () => void;
|
|
15
|
+
} {
|
|
16
|
+
let release!: () => void;
|
|
17
|
+
const gate = new Promise<void>((resolve) => {
|
|
18
|
+
release = resolve;
|
|
19
|
+
});
|
|
20
|
+
let onRequest!: () => void;
|
|
21
|
+
const requested = new Promise<void>((resolve) => {
|
|
22
|
+
onRequest = resolve;
|
|
23
|
+
});
|
|
24
|
+
const server = Bun.serve({
|
|
25
|
+
port: 0,
|
|
26
|
+
async fetch() {
|
|
27
|
+
onRequest();
|
|
28
|
+
await gate;
|
|
29
|
+
return Response.json({ text: HELD_NOTE_TEXT, skipped: false, unresolved: [] });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
configureHttpResolver(`http://localhost:${server.port}`, "test");
|
|
33
|
+
return {
|
|
34
|
+
release,
|
|
35
|
+
requested,
|
|
36
|
+
stop: () => {
|
|
37
|
+
release();
|
|
38
|
+
resetHttpResolver();
|
|
39
|
+
server.stop(true);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One keyed workflow trigger on its own DB connection, for
|
|
3
|
+
* `workflow-idempotency.test.ts`. Runs the HTTP handler's sequence: look the
|
|
4
|
+
* key up, then start the run with the key recorded in the run's transaction,
|
|
5
|
+
* and answer from the record when a concurrent process took the key first.
|
|
6
|
+
*
|
|
7
|
+
* Barrier: after the lookup finds nothing it creates `<dir>/looked-<n>` and
|
|
8
|
+
* waits for `<dir>/go`, so the parent can release every child only once all
|
|
9
|
+
* of them are past the lookup and about to race for the write lock.
|
|
10
|
+
*
|
|
11
|
+
* Prints one JSON line: `{ runId, replayed }`.
|
|
12
|
+
*/
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { getWorkflow, initDb } from "../../be/db";
|
|
15
|
+
import {
|
|
16
|
+
findAcceptedWorkflowOperation,
|
|
17
|
+
IdempotencyKeyTakenError,
|
|
18
|
+
recordAcceptedWorkflowOperation,
|
|
19
|
+
type WorkflowIdempotencyClaim,
|
|
20
|
+
workflowRequestFingerprint,
|
|
21
|
+
} from "../../be/db/workflow-idempotency";
|
|
22
|
+
import { getExecutorRegistry, startWorkflowExecution } from "../../workflows";
|
|
23
|
+
|
|
24
|
+
const [dbPath, workflowId, key, barrierDir, n] = process.argv.slice(2);
|
|
25
|
+
if (!dbPath || !workflowId || !key || !barrierDir || !n) {
|
|
26
|
+
console.error("usage: workflow-idempotency-trigger.child <db> <workflowId> <key> <dir> <n>");
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const realLog = console.log;
|
|
31
|
+
console.log = () => {};
|
|
32
|
+
console.debug = () => {};
|
|
33
|
+
console.warn = () => {};
|
|
34
|
+
|
|
35
|
+
initDb(dbPath);
|
|
36
|
+
const workflow = await getWorkflow(workflowId);
|
|
37
|
+
if (!workflow) throw new Error(`workflow ${workflowId} not found`);
|
|
38
|
+
|
|
39
|
+
const body = { n: 1 };
|
|
40
|
+
const claim: WorkflowIdempotencyClaim = {
|
|
41
|
+
principal: "agent:concurrency-test",
|
|
42
|
+
operation: "workflow.trigger",
|
|
43
|
+
targetId: workflowId,
|
|
44
|
+
key,
|
|
45
|
+
fingerprint: workflowRequestFingerprint("workflow.trigger", body),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (await findAcceptedWorkflowOperation(claim)) throw new Error("key already accepted");
|
|
49
|
+
await Bun.write(join(barrierDir, `looked-${n}`), "");
|
|
50
|
+
const go = Bun.file(join(barrierDir, "go"));
|
|
51
|
+
while (!(await go.exists())) await Bun.sleep(5);
|
|
52
|
+
|
|
53
|
+
let result: { runId: string; replayed: boolean };
|
|
54
|
+
try {
|
|
55
|
+
const runId = await startWorkflowExecution(workflow, body, getExecutorRegistry(), {
|
|
56
|
+
triggerType: "api",
|
|
57
|
+
accept: (run) =>
|
|
58
|
+
recordAcceptedWorkflowOperation(claim, {
|
|
59
|
+
workflowId,
|
|
60
|
+
runId: run.runId,
|
|
61
|
+
status: 201,
|
|
62
|
+
body: run,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
result = { runId, replayed: false };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (!(err instanceof IdempotencyKeyTakenError)) throw err;
|
|
68
|
+
const accepted = await findAcceptedWorkflowOperation(claim);
|
|
69
|
+
result = { runId: (accepted?.body as { runId: string }).runId, replayed: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
realLog(JSON.stringify(result));
|
|
73
|
+
process.exit(0);
|
|
@@ -75,6 +75,8 @@ describe("runtime identity header in OpenAPI", () => {
|
|
|
75
75
|
"GET /api/poll",
|
|
76
76
|
"POST /api/mcp-bridge",
|
|
77
77
|
"POST /api/scripts/run",
|
|
78
|
+
"POST /api/workflows",
|
|
79
|
+
"POST /api/workflows/{id}/trigger",
|
|
78
80
|
"POST /close",
|
|
79
81
|
"POST /ping",
|
|
80
82
|
"PUT /api/agents/{id}/credential-status",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
|
|
11
11
|
import type { OpenCodeEvent } from "@opencode/sdk";
|
|
12
12
|
import { normalizeSessionLogs, type SessionLogRecord } from "../../apps/ui/src/logs-parser";
|
|
13
|
+
import { renderContextBoundNote } from "../prompts/context-bound-note";
|
|
13
14
|
import {
|
|
14
15
|
_setOpencodeSdkLoaderForTests,
|
|
15
16
|
_setOpencodeSessionHooksForTests,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
import { createSkillInvokeTracker, type SkillInvoke } from "../providers/skill-invoke";
|
|
23
24
|
import type { ProviderEvent, ProviderSessionConfig } from "../providers/types";
|
|
24
25
|
import { clearVolatileSecretsForTesting } from "../utils/secret-scrubber";
|
|
26
|
+
import { HELD_NOTE_TEXT, holdTemplateRender } from "./fixtures/held-template-render";
|
|
25
27
|
|
|
26
28
|
const SESSION_ID = "ses_test";
|
|
27
29
|
|
|
@@ -68,6 +70,7 @@ type FakeHostOptions = {
|
|
|
68
70
|
tools?: string[];
|
|
69
71
|
/** Model responses handed to the plugin's `http.response` hook at startup. */
|
|
70
72
|
responses?: Response[];
|
|
73
|
+
syntheticError?: Error;
|
|
71
74
|
};
|
|
72
75
|
|
|
73
76
|
/**
|
|
@@ -85,6 +88,7 @@ function makeFakeHost(events: OpenCodeEvent[], opts: FakeHostOptions = {}) {
|
|
|
85
88
|
createOptions: undefined as unknown as Record<string, unknown>,
|
|
86
89
|
created: [] as unknown[],
|
|
87
90
|
prompts: [] as Array<Record<string, unknown>>,
|
|
91
|
+
synthetics: [] as Array<Record<string, unknown>>,
|
|
88
92
|
interrupts: [] as unknown[],
|
|
89
93
|
closeCalls: 0,
|
|
90
94
|
mcpListCalls: 0,
|
|
@@ -116,6 +120,11 @@ function makeFakeHost(events: OpenCodeEvent[], opts: FakeHostOptions = {}) {
|
|
|
116
120
|
host.interrupts.push(input);
|
|
117
121
|
return {};
|
|
118
122
|
},
|
|
123
|
+
synthetic: async (input: Record<string, unknown>) => {
|
|
124
|
+
host.synthetics.push(input);
|
|
125
|
+
if (opts.syntheticError) throw opts.syntheticError;
|
|
126
|
+
return {};
|
|
127
|
+
},
|
|
119
128
|
},
|
|
120
129
|
events: {
|
|
121
130
|
async *subscribe() {
|
|
@@ -815,6 +824,131 @@ describe("OpencodeSession — abort and steering", () => {
|
|
|
815
824
|
});
|
|
816
825
|
});
|
|
817
826
|
|
|
827
|
+
describe("OpencodeSession — context-bound note", () => {
|
|
828
|
+
const boundConfig = (threshold: string) =>
|
|
829
|
+
testConfig({ env: { CONTEXT_BOUND_NOTE_TOKENS: threshold } });
|
|
830
|
+
const step = (sessionID: string, input: number, read = 0, write = 0) =>
|
|
831
|
+
ev("session.step.ended", { sessionID, tokens: tokens(input, 10, read, write) });
|
|
832
|
+
|
|
833
|
+
test("steers the note once at the threshold, not again on a larger step", async () => {
|
|
834
|
+
const { session, host } = await startSession([], boundConfig("1000"));
|
|
835
|
+
// 600 input + 300 cache read + 100 cache write = 1,000 (output does not count).
|
|
836
|
+
session.handleOpencodeEvent(step(SESSION_ID, 500, 300, 100));
|
|
837
|
+
session.handleOpencodeEvent(step(SESSION_ID, 600, 300, 100));
|
|
838
|
+
session.handleOpencodeEvent(step(SESSION_ID, 5_000));
|
|
839
|
+
await waitFor(() => host.synthetics.length > 0);
|
|
840
|
+
await Bun.sleep(20);
|
|
841
|
+
expect(host.synthetics).toEqual([
|
|
842
|
+
{
|
|
843
|
+
sessionID: SESSION_ID,
|
|
844
|
+
text: await renderContextBoundNote(1_000, "openrouter/google/gemini-3-flash-preview"),
|
|
845
|
+
delivery: "steer",
|
|
846
|
+
resume: false,
|
|
847
|
+
},
|
|
848
|
+
]);
|
|
849
|
+
expect(host.synthetics[0]?.text).toContain("1,000");
|
|
850
|
+
session.handleOpencodeEvent(succeeded);
|
|
851
|
+
expect((await session.waitForCompletion()).isError).toBe(false);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test("the note shows the window share for a known model and omits it for an unknown one", async () => {
|
|
855
|
+
const noteFor = async (model: string) => {
|
|
856
|
+
const { session, host } = await startSession(
|
|
857
|
+
[],
|
|
858
|
+
testConfig({ model, env: { CONTEXT_BOUND_NOTE_TOKENS: "1000" } }),
|
|
859
|
+
);
|
|
860
|
+
session.handleOpencodeEvent(step(SESSION_ID, 524_288));
|
|
861
|
+
await waitFor(() => host.synthetics.length > 0);
|
|
862
|
+
session.handleOpencodeEvent(succeeded);
|
|
863
|
+
await session.waitForCompletion();
|
|
864
|
+
return host.synthetics[0]?.text;
|
|
865
|
+
};
|
|
866
|
+
const rest =
|
|
867
|
+
"Keep this task bounded: finish the current step, store-progress, and complete or defer instead of starting new exploration.";
|
|
868
|
+
expect(await noteFor("openrouter/google/gemini-3-flash-preview")).toBe(
|
|
869
|
+
`Context is 524,288 tokens (50% of the 1,048,576-token window). ${rest}`,
|
|
870
|
+
);
|
|
871
|
+
expect(await noteFor("openrouter/made-up/model")).toBe(`Context is 524,288 tokens. ${rest}`);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("off when CONTEXT_BOUND_NOTE_TOKENS is unset", async () => {
|
|
875
|
+
const { session, host } = await startSession([]);
|
|
876
|
+
session.handleOpencodeEvent(step(SESSION_ID, 900_000));
|
|
877
|
+
await Bun.sleep(20);
|
|
878
|
+
expect(host.synthetics).toEqual([]);
|
|
879
|
+
session.handleOpencodeEvent(succeeded);
|
|
880
|
+
await session.waitForCompletion();
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test("no-op below the threshold and for a child session's steps", async () => {
|
|
884
|
+
const { session, host } = await startSession([], boundConfig("1000"));
|
|
885
|
+
session.handleOpencodeEvent(step(SESSION_ID, 999));
|
|
886
|
+
session.handleOpencodeEvent(step("ses_child", 50_000));
|
|
887
|
+
await Bun.sleep(20);
|
|
888
|
+
expect(host.synthetics).toEqual([]);
|
|
889
|
+
session.handleOpencodeEvent(succeeded);
|
|
890
|
+
await session.waitForCompletion();
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
test("off at 0", async () => {
|
|
894
|
+
const { session, host } = await startSession([], boundConfig("0"));
|
|
895
|
+
session.handleOpencodeEvent(step(SESSION_ID, 250_000));
|
|
896
|
+
await Bun.sleep(20);
|
|
897
|
+
expect(host.synthetics).toEqual([]);
|
|
898
|
+
session.handleOpencodeEvent(succeeded);
|
|
899
|
+
await session.waitForCompletion();
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test("fails open on corrupt step tokens", async () => {
|
|
903
|
+
const { session, host } = await startSession([], boundConfig("1000"));
|
|
904
|
+
session.handleOpencodeEvent(
|
|
905
|
+
ev("session.step.ended", {
|
|
906
|
+
sessionID: SESSION_ID,
|
|
907
|
+
tokens: { input: "lots", output: 1, reasoning: 0, cache: { read: null, write: {} } },
|
|
908
|
+
}),
|
|
909
|
+
);
|
|
910
|
+
await Bun.sleep(20);
|
|
911
|
+
expect(host.synthetics).toEqual([]);
|
|
912
|
+
session.handleOpencodeEvent(succeeded);
|
|
913
|
+
expect((await session.waitForCompletion()).isError).toBe(false);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("a rejected steer still lets the session complete", async () => {
|
|
917
|
+
const { session, host } = await startSession([], boundConfig("1000"), {
|
|
918
|
+
syntheticError: new Error("synthetic refused"),
|
|
919
|
+
});
|
|
920
|
+
session.handleOpencodeEvent(step(SESSION_ID, 5_000));
|
|
921
|
+
await waitFor(() => host.synthetics.length > 0);
|
|
922
|
+
session.handleOpencodeEvent(succeeded);
|
|
923
|
+
expect((await session.waitForCompletion()).isError).toBe(false);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test("a note rendered after the session completed is dropped", async () => {
|
|
927
|
+
const lateSynthetics = async (releaseInSession: boolean) => {
|
|
928
|
+
const held = holdTemplateRender();
|
|
929
|
+
try {
|
|
930
|
+
const { session, host } = await startSession([], boundConfig("1000"));
|
|
931
|
+
session.handleOpencodeEvent(step(SESSION_ID, 5_000));
|
|
932
|
+
await held.requested;
|
|
933
|
+
if (releaseInSession) {
|
|
934
|
+
held.release();
|
|
935
|
+
await waitFor(() => host.synthetics.length > 0);
|
|
936
|
+
}
|
|
937
|
+
session.handleOpencodeEvent(succeeded);
|
|
938
|
+
expect((await session.waitForCompletion()).isError).toBe(false);
|
|
939
|
+
held.release();
|
|
940
|
+
await Bun.sleep(50);
|
|
941
|
+
return host.synthetics.map((s) => s.text);
|
|
942
|
+
} finally {
|
|
943
|
+
held.stop();
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
expect(await lateSynthetics(false)).toEqual([]);
|
|
947
|
+
// Control: ready while the session runs.
|
|
948
|
+
expect(await lateSynthetics(true)).toEqual([HELD_NOTE_TEXT]);
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
818
952
|
describe("OpenRouter attribution config", () => {
|
|
819
953
|
test("overrides harness attribution while preserving custom headers and options", () => {
|
|
820
954
|
const config = {
|
|
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { OPENAI_COMPATIBLE_PROVIDERS } from "@desplega/model-catalog";
|
|
14
14
|
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { renderContextBoundNote } from "../prompts/context-bound-note";
|
|
15
16
|
import { configureHttpResolver, resetHttpResolver } from "../prompts/resolver";
|
|
16
17
|
import {
|
|
17
18
|
checkPiMonoCredentials,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
writeSessionModelsJson,
|
|
25
26
|
} from "../providers/pi-mono-adapter";
|
|
26
27
|
import type { ProviderSessionConfig } from "../providers/types";
|
|
28
|
+
import { HELD_NOTE_TEXT, holdTemplateRender } from "./fixtures/held-template-render";
|
|
27
29
|
|
|
28
30
|
describe("PiMonoAdapter", () => {
|
|
29
31
|
test("name is 'pi'", () => {
|
|
@@ -1187,6 +1189,8 @@ function makeMockAgentSession(opts: {
|
|
|
1187
1189
|
steerCalls?: string[];
|
|
1188
1190
|
followUpCalls?: string[];
|
|
1189
1191
|
steeringError?: string;
|
|
1192
|
+
/** Awaited after each call's events, as a real turn keeps running its tools. */
|
|
1193
|
+
afterEvents?: () => Promise<unknown>;
|
|
1190
1194
|
}): AgentSession {
|
|
1191
1195
|
const listeners: Array<(event: AgentSessionEvent) => void> = [];
|
|
1192
1196
|
const scripts = opts.turns ?? [opts.events ?? []];
|
|
@@ -1210,6 +1214,7 @@ function makeMockAgentSession(opts: {
|
|
|
1210
1214
|
for (const event of scripts[call] ?? []) {
|
|
1211
1215
|
for (const l of listeners) l(event);
|
|
1212
1216
|
}
|
|
1217
|
+
await opts.afterEvents?.();
|
|
1213
1218
|
if (opts.throwError) throw new Error(opts.throwError);
|
|
1214
1219
|
},
|
|
1215
1220
|
getContextUsage: () => null,
|
|
@@ -1754,3 +1759,103 @@ describe("classifyAwsSdkError — all 4 categories (quick summary)", () => {
|
|
|
1754
1759
|
}
|
|
1755
1760
|
});
|
|
1756
1761
|
});
|
|
1762
|
+
|
|
1763
|
+
describe("PiMonoSession context-bound note", () => {
|
|
1764
|
+
/** An assistant `message_end` with this request usage; a tool call unless `final`. */
|
|
1765
|
+
function assistantTurn(usage: unknown, final = false): AgentSessionEvent {
|
|
1766
|
+
return {
|
|
1767
|
+
type: "message_end",
|
|
1768
|
+
message: {
|
|
1769
|
+
role: "assistant",
|
|
1770
|
+
content: final
|
|
1771
|
+
? [{ type: "text", text: "Done" }]
|
|
1772
|
+
: [{ type: "toolCall", id: "call-1", name: "bash", arguments: {} }],
|
|
1773
|
+
stopReason: final ? "stop" : "toolUse",
|
|
1774
|
+
usage,
|
|
1775
|
+
},
|
|
1776
|
+
} as unknown as AgentSessionEvent;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
const usage = (input: number) => ({ input, cacheRead: 300, cacheWrite: 200, output: 50 });
|
|
1780
|
+
|
|
1781
|
+
async function steersFor(
|
|
1782
|
+
threshold: string,
|
|
1783
|
+
events: AgentSessionEvent[],
|
|
1784
|
+
afterEvents: () => Promise<unknown> = () => Bun.sleep(50),
|
|
1785
|
+
model?: string,
|
|
1786
|
+
): Promise<string[]> {
|
|
1787
|
+
const steerCalls: string[] = [];
|
|
1788
|
+
const session = new PiMonoSession(
|
|
1789
|
+
makeMockAgentSession({ events, steerCalls, afterEvents }),
|
|
1790
|
+
{
|
|
1791
|
+
...makeSessionConfig(join(tmpLogDir, `bound-note-${crypto.randomUUID()}.log`)),
|
|
1792
|
+
env: { CONTEXT_BOUND_NOTE_TOKENS: threshold },
|
|
1793
|
+
...(model ? { model } : {}),
|
|
1794
|
+
},
|
|
1795
|
+
false,
|
|
1796
|
+
);
|
|
1797
|
+
await session.waitForCompletion();
|
|
1798
|
+
return steerCalls;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
test("steers the note once when a tool-call request reaches the threshold", async () => {
|
|
1802
|
+
const steers = await steersFor("1000", [
|
|
1803
|
+
assistantTurn(usage(400)),
|
|
1804
|
+
assistantTurn(usage(500)),
|
|
1805
|
+
assistantTurn(usage(900)),
|
|
1806
|
+
assistantTurn(usage(1500), true),
|
|
1807
|
+
]);
|
|
1808
|
+
// 500 + 300 + 200 reaches 1000; the larger later request does not steer again.
|
|
1809
|
+
expect(steers).toEqual([await renderContextBoundNote(1000)]);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
test("the note shows the window share for a known model and omits it for an unknown one", async () => {
|
|
1813
|
+
const events = () => [assistantTurn(usage(209_500)), assistantTurn(usage(400), true)];
|
|
1814
|
+
const rest =
|
|
1815
|
+
"Keep this task bounded: finish the current step, store-progress, and complete or defer instead of starting new exploration.";
|
|
1816
|
+
expect(await steersFor("200000", events(), undefined, "anthropic/claude-sonnet-4-6")).toEqual([
|
|
1817
|
+
`Context is 210,000 tokens (21% of the 1,000,000-token window). ${rest}`,
|
|
1818
|
+
]);
|
|
1819
|
+
expect(await steersFor("200000", events(), undefined, "made-up/model")).toEqual([
|
|
1820
|
+
`Context is 210,000 tokens. ${rest}`,
|
|
1821
|
+
]);
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
test("no-op below the threshold and when disabled at 0", async () => {
|
|
1825
|
+
const events = [assistantTurn(usage(400)), assistantTurn(usage(400), true)];
|
|
1826
|
+
expect(await steersFor("1000", events)).toEqual([]);
|
|
1827
|
+
expect(await steersFor("0", events)).toEqual([]);
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
test("fails open on missing or corrupt usage", async () => {
|
|
1831
|
+
const events = [
|
|
1832
|
+
assistantTurn(undefined),
|
|
1833
|
+
assistantTurn({ input: "lots", cacheRead: {}, cacheWrite: null }),
|
|
1834
|
+
assistantTurn(undefined, true),
|
|
1835
|
+
];
|
|
1836
|
+
expect(await steersFor("1", events)).toEqual([]);
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
test("never steers after the final reply, which would start another turn", async () => {
|
|
1840
|
+
expect(await steersFor("1", [assistantTurn(usage(5000), true)])).toEqual([]);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
test("a note rendered after its tool turn ended is dropped", async () => {
|
|
1844
|
+
const turnEnd = { type: "turn_end" } as unknown as AgentSessionEvent;
|
|
1845
|
+
const lateSteers = async (events: AgentSessionEvent[]) => {
|
|
1846
|
+
const held = holdTemplateRender();
|
|
1847
|
+
try {
|
|
1848
|
+
return await steersFor("1", events, async () => {
|
|
1849
|
+
held.release();
|
|
1850
|
+
await Bun.sleep(50);
|
|
1851
|
+
});
|
|
1852
|
+
} finally {
|
|
1853
|
+
held.stop();
|
|
1854
|
+
}
|
|
1855
|
+
};
|
|
1856
|
+
// Pi already read steering for that turn; the next reply could be the final one.
|
|
1857
|
+
expect(await lateSteers([assistantTurn(usage(5000)), turnEnd])).toEqual([]);
|
|
1858
|
+
// Control: ready while the tool turn is still running.
|
|
1859
|
+
expect(await lateSteers([assistantTurn(usage(5000))])).toEqual([HELD_NOTE_TEXT]);
|
|
1860
|
+
});
|
|
1861
|
+
});
|
|
@@ -22,6 +22,7 @@ const SYSTEM_TEMPLATES = [
|
|
|
22
22
|
"system.agent.communication",
|
|
23
23
|
"system.agent.communication.remote",
|
|
24
24
|
"system.agent.compaction.goal_reminder",
|
|
25
|
+
"system.agent.context.bound_note",
|
|
25
26
|
"system.agent.cursor.first_message",
|
|
26
27
|
"system.agent.lead",
|
|
27
28
|
"system.agent.memory",
|
|
@@ -117,11 +118,11 @@ describe("Session templates: registration", () => {
|
|
|
117
118
|
expect(sessionSystemEventTypes("session")).toEqual([...SESSION_TEMPLATES].sort());
|
|
118
119
|
});
|
|
119
120
|
|
|
120
|
-
test("registers
|
|
121
|
+
test("registers 32 system and session templates in total", () => {
|
|
121
122
|
const all = getAllTemplateDefinitions();
|
|
122
123
|
const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
|
|
123
|
-
//
|
|
124
|
-
expect(sessionSystem.length).toBe(
|
|
124
|
+
// 27 system blocks + 5 session composites.
|
|
125
|
+
expect(sessionSystem.length).toBe(32);
|
|
125
126
|
});
|
|
126
127
|
|
|
127
128
|
test("drops the v1 blocks that prompt v2 deleted", () => {
|
|
@@ -139,6 +139,31 @@ async function nudge(kind: WorkerNudge["kind"], taskId?: string) {
|
|
|
139
139
|
expect((await waiting)?.kind).toBe(kind);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
test("shutdown stops an open poll window before it can claim a resume", async () => {
|
|
143
|
+
let shuttingDown = false;
|
|
144
|
+
// The second poll would claim the resume created during shutdown.
|
|
145
|
+
pollTriggerAfter = 2;
|
|
146
|
+
const sleep = spyOn(Bun, "sleep").mockImplementation(async () => {
|
|
147
|
+
expect(requests.filter((r) => r === "GET /api/poll")).toHaveLength(1);
|
|
148
|
+
shuttingDown = true;
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
const trigger = await pollForTrigger({
|
|
152
|
+
apiUrl,
|
|
153
|
+
apiKey,
|
|
154
|
+
agentId,
|
|
155
|
+
pollInterval: 2,
|
|
156
|
+
pollTimeout: 5000,
|
|
157
|
+
shouldStop: () => shuttingDown,
|
|
158
|
+
});
|
|
159
|
+
expect(shuttingDown).toBe(true);
|
|
160
|
+
expect(trigger).toBeNull();
|
|
161
|
+
expect(requests.filter((r) => r === "GET /api/poll")).toHaveLength(1);
|
|
162
|
+
} finally {
|
|
163
|
+
sleep.mockRestore();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
142
167
|
async function poll(now: number, mode: DeliveryMode = "push") {
|
|
143
168
|
const checks = delivery.checks(mode, now);
|
|
144
169
|
const schedule = delivery.schedule({
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { afterEach, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createServer as createHttpServer } from "node:http";
|
|
4
|
+
import { createServer as createNetServer, type Socket } from "node:net";
|
|
5
|
+
import { WebSocketServer } from "ws";
|
|
6
|
+
import { startControllerClient } from "../commands/controller";
|
|
7
|
+
import { CONTROLLER_PROTOCOL_VERSION } from "../runtime-providers/controller-protocol";
|
|
8
|
+
import type { RuntimeProvider } from "../runtime-providers/types";
|
|
9
|
+
import { listenOnFreePort } from "./test-net";
|
|
10
|
+
|
|
11
|
+
const LIVENESS_MS = 300;
|
|
12
|
+
const WELCOME = JSON.stringify({
|
|
13
|
+
type: "welcome",
|
|
14
|
+
controllerId: "controller-1",
|
|
15
|
+
version: CONTROLLER_PROTOCOL_VERSION,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const provider = {
|
|
19
|
+
kind: "docker",
|
|
20
|
+
traits: { canProvision: true, ephemeralDisk: true, supportsPause: false },
|
|
21
|
+
checkConfig: () => ({ ready: true, missing: [] }),
|
|
22
|
+
} as unknown as RuntimeProvider;
|
|
23
|
+
|
|
24
|
+
const cleanups: Array<() => void> = [];
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// A raw TCP WebSocket peer. Under Bun, `ws` maps to a native shim that always answers
|
|
30
|
+
// pings, so it cannot model a dead link. This peer completes the handshake, sends
|
|
31
|
+
// welcome, and then never answers a frame: what a cut network looks like to the client.
|
|
32
|
+
async function startRawServer(options: { pingEveryMs?: number } = {}) {
|
|
33
|
+
let connections = 0;
|
|
34
|
+
const sockets: Socket[] = [];
|
|
35
|
+
const server = createNetServer((socket) => {
|
|
36
|
+
sockets.push(socket);
|
|
37
|
+
socket.once("data", (data) => {
|
|
38
|
+
const key = /sec-websocket-key: *(\S+)/i.exec(data.toString())?.[1];
|
|
39
|
+
const accept = createHash("sha1")
|
|
40
|
+
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
41
|
+
.digest("base64");
|
|
42
|
+
socket.write(
|
|
43
|
+
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`,
|
|
44
|
+
);
|
|
45
|
+
const payload = Buffer.from(WELCOME);
|
|
46
|
+
socket.write(Buffer.concat([Buffer.from([0x81, payload.length]), payload]));
|
|
47
|
+
connections++;
|
|
48
|
+
if (options.pingEveryMs) {
|
|
49
|
+
const timer = setInterval(
|
|
50
|
+
() => socket.write(Buffer.from([0x89, 0x00])),
|
|
51
|
+
options.pingEveryMs,
|
|
52
|
+
);
|
|
53
|
+
socket.once("close", () => clearInterval(timer));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
socket.on("error", () => {});
|
|
57
|
+
});
|
|
58
|
+
const port = await listenOnFreePort(server);
|
|
59
|
+
cleanups.push(() => {
|
|
60
|
+
for (const socket of sockets) socket.destroy();
|
|
61
|
+
server.close();
|
|
62
|
+
});
|
|
63
|
+
return { apiUrl: `http://127.0.0.1:${port}`, connections: () => connections };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function startClient(apiUrl: string) {
|
|
67
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
68
|
+
const client = startControllerClient({
|
|
69
|
+
apiUrl,
|
|
70
|
+
token: "aseph_test-token",
|
|
71
|
+
provider,
|
|
72
|
+
target: {},
|
|
73
|
+
livenessTimeoutMs: LIVENESS_MS,
|
|
74
|
+
});
|
|
75
|
+
cleanups.push(() => {
|
|
76
|
+
client.stop();
|
|
77
|
+
logSpy.mockRestore();
|
|
78
|
+
});
|
|
79
|
+
return { logs: () => logSpy.mock.calls.flat().join("\n") };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function until(check: () => boolean, timeoutMs: number) {
|
|
83
|
+
const deadline = Date.now() + timeoutMs;
|
|
84
|
+
while (!check()) {
|
|
85
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for condition");
|
|
86
|
+
await Bun.sleep(20);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test("a server that goes silent without closing makes the client reconnect", async () => {
|
|
91
|
+
const server = await startRawServer();
|
|
92
|
+
const { logs } = startClient(server.apiUrl);
|
|
93
|
+
await until(() => logs().includes("Connected as controller-1"), 2000);
|
|
94
|
+
const silentAt = Date.now();
|
|
95
|
+
// Detection within the timeout plus one check interval, then the first backoff (at most 1000ms).
|
|
96
|
+
await until(() => server.connections() === 2, LIVENESS_MS * 2 + 1500);
|
|
97
|
+
expect(logs()).toContain(`No traffic for ${LIVENESS_MS}ms`);
|
|
98
|
+
expect(logs()).toContain("Disconnected. Reconnect in");
|
|
99
|
+
expect(Date.now() - silentAt).toBeGreaterThanOrEqual(LIVENESS_MS);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("server ping frames keep an idle controller connected", async () => {
|
|
103
|
+
// Matches the API: it pings on an interval and sends no app message to an idle controller.
|
|
104
|
+
// This peer never answers the client's pings, so only the server pings count.
|
|
105
|
+
const server = await startRawServer({ pingEveryMs: LIVENESS_MS / 3 });
|
|
106
|
+
const { logs } = startClient(server.apiUrl);
|
|
107
|
+
await until(() => logs().includes("Connected as controller-1"), 2000);
|
|
108
|
+
await Bun.sleep(LIVENESS_MS * 4);
|
|
109
|
+
expect(server.connections()).toBe(1);
|
|
110
|
+
expect(logs()).not.toContain("Disconnected");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("pongs to client pings keep an idle controller connected", async () => {
|
|
114
|
+
// A healthy peer that sends nothing on its own still answers pings.
|
|
115
|
+
const http = createHttpServer();
|
|
116
|
+
const wss = new WebSocketServer({ server: http });
|
|
117
|
+
let connections = 0;
|
|
118
|
+
wss.on("connection", (ws) => {
|
|
119
|
+
connections++;
|
|
120
|
+
ws.send(WELCOME);
|
|
121
|
+
});
|
|
122
|
+
const port = await listenOnFreePort(http);
|
|
123
|
+
cleanups.push(() => {
|
|
124
|
+
for (const ws of wss.clients) ws.terminate();
|
|
125
|
+
wss.close();
|
|
126
|
+
http.close();
|
|
127
|
+
});
|
|
128
|
+
const { logs } = startClient(`http://127.0.0.1:${port}`);
|
|
129
|
+
await until(() => logs().includes("Connected as controller-1"), 2000);
|
|
130
|
+
await Bun.sleep(LIVENESS_MS * 4);
|
|
131
|
+
expect(connections).toBe(1);
|
|
132
|
+
expect(logs()).not.toContain("Disconnected");
|
|
133
|
+
});
|
|
@@ -310,6 +310,7 @@ describe("Docker runtime provider", () => {
|
|
|
310
310
|
await provider.terminate(target, handle);
|
|
311
311
|
expect(calls[0]?.init.method).toBe("DELETE");
|
|
312
312
|
expect(calls[0]?.url.searchParams.get("force")).toBe("true");
|
|
313
|
+
expect(calls[0]?.url.searchParams.get("v")).toBe("true");
|
|
313
314
|
});
|
|
314
315
|
|
|
315
316
|
test("termination succeeds when the container is already absent", async () => {
|
|
@@ -376,6 +377,7 @@ describe("Docker runtime provider", () => {
|
|
|
376
377
|
"DELETE /v1.47/containers/container-1",
|
|
377
378
|
]);
|
|
378
379
|
expect(calls[2]?.url.searchParams.get("force")).toBe("true");
|
|
380
|
+
expect(calls[2]?.url.searchParams.get("v")).toBe("true");
|
|
379
381
|
}
|
|
380
382
|
});
|
|
381
383
|
|
|
@@ -570,6 +570,9 @@ describe("script connections", () => {
|
|
|
570
570
|
markMigrationApplied(database, "151_repair_scheduled_task_required_params.sql");
|
|
571
571
|
// 152 indexes task routing context absent from this migration-112-only fixture.
|
|
572
572
|
markMigrationApplied(database, "152_routing_decisions.sql");
|
|
573
|
+
// 153 drops a column, so SQLite revalidates 144's agent_tasks triggers
|
|
574
|
+
// against this fixture's id-only agent_tasks (Bun < 1.4.4 hid the error).
|
|
575
|
+
markMigrationApplied(database, "153_deferred_task_wait_members.sql");
|
|
573
576
|
// 156 alters inbox_item_state, which this migration-112-only fixture does not create.
|
|
574
577
|
markMigrationApplied(database, "156_inbox_item_state_read_at.sql");
|
|
575
578
|
// 157 alters scheduled_tasks and slack_messages, neither of which this
|