Skip to content

Commit e38b91f

Browse files
committed
tests
1 parent a579f6d commit e38b91f

3 files changed

Lines changed: 192 additions & 0 deletions

File tree

TODO.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,35 @@ ClientSession.ts, AgentConnectionService.ts, Operator.ts, addStorage.ts, addStat
573573
persist-init сторэджа, операторского адаптера (таймаут-выход), кастомного resque,
574574
бросающего connector, бросающего CC_AGENT_HISTORY_FILTER.
575575

576+
## 26-й заход: краш-восстановление персиста при нескольких роях/клиентах (+2 теста, багов не найдено)
577+
578+
### Методика
579+
Честный двухпроцессный тест: writer-процесс пишет (active agent, navigation,
580+
storage, state, file-history) и умирает БЕЗ dispose (process.exit); reader —
581+
новый процесс — восстанавливается с диска. Выполняется во временном cwd.
582+
583+
### Схема ключевания бакетов (аудит)
584+
- swarm/active_agent/<swarmName>/<clientId>.json — рой+клиент ✓
585+
- swarm/navigation_stack/<swarmName>/<clientId>.json — ✓
586+
- alive/<swarmName>/<clientId>.json — ✓; policy/<swarmName>/<policyName>.json — ✓
587+
- state/<stateName>/<clientId>.json, storage/<storageName>/<clientId>.json —
588+
клиент+имя схемы, БЕЗ роя (консистентно с in-memory скоупом схем)
589+
- history/<clientId>/ (PersistList) — только клиент, БЕЗ роя/агента
590+
(консистентно с in-memory: HistoryInstance per clientId, деление — фильтром)
591+
- embedding/<embeddingName>/<hash>.json — content-addressed ✓
592+
593+
### Результаты (test/spec/crashrecovery.test.mjs, сьют 265/265)
594+
- Восстановление НЕ ломается: каждый клиент каждого роя после краша получает
595+
СВОЙ активный агент/стек; клиенты друг друга не видят.
596+
- Зафиксирован контракт: при переиспользовании ОДНОГО clientId в разных роях
597+
history/storage/state общие между роями (бакет по clientId) — идентично
598+
in-memory поведению. ВАЖНО для потребителей: один clientId между роями делит
599+
весь диалог (user/assistant сообщения роя A попадают в контекст модели роя B
600+
через дефолтный CC_AGENT_HISTORY_FILTER) — если нужна изоляция, использовать
601+
разные clientId (например суффикс роя).
602+
- Примечание: clientId "shared" неявно зарезервирован — shared storage/state
603+
персистятся под этим ключом в тех же бакетах схем.
604+
576605
## Найденные и исправленные баги (26 итого)
577606

578607
### 1. Дедлок waitForOutput при functools-kit v4 (причина 39 упавших тестов)

test/index.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import "./spec/banhammer.test.mjs";
3838
import "./spec/modelguard.test.mjs";
3939
import "./spec/hookguard.test.mjs";
4040
import "./spec/sweepguard.test.mjs";
41+
import "./spec/crashrecovery.test.mjs";
4142

4243
run(import.meta.url, () => {
4344
console.log("All tests are finished");

test/spec/crashrecovery.test.mjs

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { test } from "worker-testbed";
2+
import { execFileSync } from "node:child_process";
3+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join, dirname } from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
8+
const BUILD_PATH = join(
9+
dirname(fileURLToPath(import.meta.url)),
10+
"..",
11+
"..",
12+
"build",
13+
"index.mjs"
14+
);
15+
16+
const SCRIPT = `
17+
import {
18+
addAgent, addCompletion, addEmbedding, addState, addStorage, addSwarm,
19+
changeToAgent, getAgentName, session, setConfig, State, Storage,
20+
} from ${JSON.stringify(BUILD_PATH)};
21+
22+
const PHASE = process.argv[2];
23+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: true });
24+
25+
const seenContexts = {};
26+
const mkCompletion = (name) => addCompletion({
27+
completionName: name,
28+
getCompletion: async ({ agentName, clientId, messages }) => {
29+
seenContexts[name + ":" + clientId] = messages.map((m) => m.role + ":" + m.content);
30+
const [last] = messages.slice(-1);
31+
return { agentName, content: "echo:" + last.content, role: "assistant" };
32+
},
33+
});
34+
35+
const CA = mkCompletion("comp-a");
36+
const CB = mkCompletion("comp-b");
37+
38+
addEmbedding({ embeddingName: "emb", createEmbedding: async (t) => [t.length],
39+
calculateSimilarity: async (a, b) => (a[0] === b[0] ? 1 : 0) });
40+
addStorage({ storageName: "orders", embedding: "emb", createIndex: (i) => i.text, persist: true });
41+
addState({ stateName: "conv-state", getDefaultState: () => ({ v: "default" }), persist: true });
42+
43+
const A1 = addAgent({ agentName: "a1", completion: CA, prompt: "", storages: ["orders"], states: ["conv-state"] });
44+
const A2 = addAgent({ agentName: "a2", completion: CA, prompt: "" });
45+
const B1 = addAgent({ agentName: "b1", completion: CB, prompt: "", storages: ["orders"], states: ["conv-state"] });
46+
const B2 = addAgent({ agentName: "b2", completion: CB, prompt: "" });
47+
48+
const SWARM_A = addSwarm({ swarmName: "swarm-a", agentList: [A1, A2], defaultAgent: A1, persist: true });
49+
const SWARM_B = addSwarm({ swarmName: "swarm-b", agentList: [B1, B2], defaultAgent: B1, persist: true });
50+
51+
const C1 = "client-one";
52+
const C2 = "client-two";
53+
54+
if (PHASE === "write") {
55+
const s1 = session(C1, SWARM_A);
56+
await s1.complete("secret-alpha");
57+
await Storage.upsert({ clientId: C1, agentName: A1, storageName: "orders", item: { id: 1, text: "from-swarm-a" } });
58+
await State.setState(() => ({ v: "written-in-a" }), { clientId: C1, agentName: A1, stateName: "conv-state" });
59+
await changeToAgent(A2, C1);
60+
61+
const s2 = session(C2, SWARM_B);
62+
await s2.complete("beta-question");
63+
await changeToAgent(B2, C2);
64+
65+
console.log("WRITE_OK");
66+
process.exit(0); // simulated crash: no dispose
67+
}
68+
69+
if (PHASE === "read") {
70+
const s1 = session(C1, SWARM_A);
71+
const activeC1 = await getAgentName(C1);
72+
await s1.dispose();
73+
74+
const s2 = session(C2, SWARM_B);
75+
const activeC2 = await getAgentName(C2);
76+
await s2.dispose();
77+
78+
const s1b = session(C1, SWARM_B);
79+
const activeC1b = await getAgentName(C1);
80+
await s1b.complete("hello-from-b");
81+
const ctx = seenContexts["comp-b:client-one"] ?? [];
82+
const historyShared = ctx.some((m) => m.includes("secret-alpha"));
83+
const storageInB = await Storage.list({ clientId: C1, agentName: B1, storageName: "orders" });
84+
const stateInB = await State.getState({ clientId: C1, agentName: B1, stateName: "conv-state" });
85+
await s1b.dispose();
86+
87+
const s2b = session(C2, SWARM_B);
88+
const c2Storage = await Storage.list({ clientId: C2, agentName: B1, storageName: "orders" });
89+
await s2b.dispose();
90+
91+
console.log(JSON.stringify({
92+
activeC1, activeC2, activeC1b, historyShared,
93+
storageInB, stateInB, c2Storage,
94+
}));
95+
process.exit(0);
96+
}
97+
process.exit(1);
98+
`;
99+
100+
const runCrashScenario = () => {
101+
const workDir = mkdtempSync(join(tmpdir(), "swarm-crash-"));
102+
const scriptPath = join(workDir, "scenario.mjs");
103+
writeFileSync(scriptPath, SCRIPT);
104+
try {
105+
const writeOut = execFileSync(process.execPath, [scriptPath, "write"], {
106+
cwd: workDir,
107+
encoding: "utf-8",
108+
timeout: 30_000,
109+
});
110+
if (!writeOut.includes("WRITE_OK")) {
111+
throw new Error(`write phase failed: ${writeOut}`);
112+
}
113+
const readOut = execFileSync(process.execPath, [scriptPath, "read"], {
114+
cwd: workDir,
115+
encoding: "utf-8",
116+
timeout: 30_000,
117+
});
118+
const jsonLine = readOut
119+
.split("\n")
120+
.find((line) => line.startsWith("{"));
121+
return JSON.parse(jsonLine);
122+
} finally {
123+
rmSync(workDir, { recursive: true, force: true });
124+
}
125+
};
126+
127+
test("Will recover per-swarm per-client scopes after hard crash", async ({ pass, fail }) => {
128+
const result = runCrashScenario();
129+
130+
const ok =
131+
result.activeC1 === "a2" && // client-one restored inside swarm-a
132+
result.activeC2 === "b2" && // client-two restored inside swarm-b
133+
result.activeC1b === "b1" && // same client in ANOTHER swarm starts from default
134+
Array.isArray(result.c2Storage) &&
135+
result.c2Storage.length === 0; // client-two never sees client-one data
136+
137+
if (ok) {
138+
pass();
139+
return;
140+
}
141+
fail(JSON.stringify(result));
142+
});
143+
144+
test("Will share client-scoped history, storage and state across swarms by contract", async ({ pass, fail }) => {
145+
const result = runCrashScenario();
146+
147+
// Contract pin: history/storage/state buckets are keyed by clientId (+schema
148+
// name) WITHOUT swarm separation — reusing one clientId across swarms shares
149+
// the dialog history, storage items and state between them, identical to the
150+
// in-memory behavior. Different clientIds stay fully isolated.
151+
const ok =
152+
result.historyShared === true &&
153+
result.storageInB.length === 1 &&
154+
result.storageInB[0].text === "from-swarm-a" &&
155+
result.stateInB.v === "written-in-a";
156+
157+
if (ok) {
158+
pass();
159+
return;
160+
}
161+
fail(JSON.stringify(result));
162+
});

0 commit comments

Comments
 (0)