Skip to content

Commit 91df319

Browse files
committed
wip
1 parent 6923bc8 commit 91df319

4 files changed

Lines changed: 249 additions & 1 deletion

File tree

TODO.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,40 @@ shared state/storage и кэш эмбеддингов. Багов не найд
413413
дропает stale-результат (двойная проверка: на входе и прямо перед
414414
_outputSubject.next, т.к. между ними есть await'ы).
415415

416-
## Найденные и исправленные баги (22 итого)
416+
## 20-й заход: жизненный цикл сессии и контракты истории (+5 тестов, +1 баг)
417+
418+
### Новые тесты (5), сьют вырос до 228/228 — test/spec/lifecycle.test.mjs
419+
- dispose при собственном in-flight complete → complete резолвится "" (не виснет);
420+
- тот же clientId после dispose-mid-flight открывает новую рабочую сессию;
421+
- disposeConnection резолвит pending send() у makeConnection;
422+
- КОНТРАКТ: системные сообщения переживают окно keepMessages (срез только для
423+
common-сообщений) — «долговременные инструкции» by design;
424+
- КОНТРАКТ: flush чистит накопленные системные сообщения.
425+
426+
### Баг №23: dispose сессии с собственным in-flight complete вешал его навсегда
427+
Файл: `src/client/ClientSwarm.ts` (подтверждён probe-репро: dispose=ok,
428+
complete=HANG)
429+
- dispose делал unsubscribeAll на сабжектах свармы/агентов — pending-ожидатель
430+
вывода молча терялся, промис complete() не резолвился никогда (сценарий
431+
«пользователь закрыл чат посреди ответа»; авейтящий код застревал).
432+
- Исправление: ClientSwarm.dispose при непустом _pendingOutputAwaiters сначала
433+
эмитит cancel (пустой вывод, тот же контракт что cancelOutput), затем
434+
отписывает сабжекты.
435+
436+
### Отмечено без фикса (осознанные ограничения, задокументированы)
437+
- ClientPolicy._banSet кэшируется навсегда после первой загрузки: изменения
438+
бан-листа, записанные ИЗВНЕ (другой процесс/нода через persist-адаптер), не
439+
видны живому инстансу до пересоздания. Для одного процесса корректно; для
440+
мульти-нод конфигураций требуется внешняя инвалидация.
441+
- ClientHistory.toArrayForAgent: ветка `!payload → continue` подразумевает
442+
выживание payload-only сообщений, но следующий фильтр `!!content ||
443+
!!tool_calls` их выбрасывает — мёртвая ветка, поведение не меняли.
444+
- WAIT_FOR_OUTPUT_FN: отменённые race-подписки на _outputSubject живут до
445+
следующего эмита — накопление при штормах setAgentRef (память, не корректность).
446+
- AQUIRE_LOCK: поллинг sleep(100) без таймаута; _navigationStack растёт на
447+
каждый переход (write amplification при persist).
448+
449+
## Найденные и исправленные баги (23 итого)
417450

418451
### 1. Дедлок waitForOutput при functools-kit v4 (причина 39 упавших тестов)
419452
Файл: `src/client/ClientSwarm.ts`

src/client/ClientSwarm.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,19 @@ export class ClientSwarm implements ISwarm {
656656
this.params.logger.debug(
657657
`ClientSession clientId=${this.params.clientId} dispose`
658658
);
659+
if (this._pendingOutputAwaiters.length) {
660+
// Resolve in-flight waitForOutput calls with an empty output (same
661+
// contract as cancelOutput) before unsubscribing — otherwise a complete()
662+
// pending at dispose time would never settle.
663+
const agentName =
664+
this._activeAgent === AGENT_NEED_FETCH
665+
? this.params.defaultAgent
666+
: this._activeAgent;
667+
await this._cancelOutputSubject.next({
668+
agentName,
669+
output: "",
670+
});
671+
}
659672
{
660673
this._agentChangedSubject.unsubscribeAll();
661674
this._emitSubject.unsubscribeAll();

test/index.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import "./spec/toolguard.test.mjs";
3232
import "./spec/aliastools.test.mjs";
3333
import "./spec/multiclient.test.mjs";
3434
import "./spec/doublesend.test.mjs";
35+
import "./spec/lifecycle.test.mjs";
3536

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

test/spec/lifecycle.test.mjs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { test } from "worker-testbed";
2+
3+
import {
4+
addAgent,
5+
addCompletion,
6+
addSwarm,
7+
commitSystemMessage,
8+
commitFlushForce,
9+
disposeConnection,
10+
makeConnection,
11+
session,
12+
setConfig,
13+
} from "../../build/index.mjs";
14+
import { randomString, sleep } from "functools-kit";
15+
16+
const HANG = Symbol("hang");
17+
const raceHang = (p, ms = 8000) => Promise.race([p, sleep(ms).then(() => HANG)]);
18+
19+
test("Will resolve own in-flight complete with empty output on dispose", async ({ pass, fail }) => {
20+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
21+
22+
addCompletion({
23+
completionName: "e1-completion",
24+
getCompletion: async ({ agentName, messages }) => {
25+
await sleep(300);
26+
const [last] = messages.slice(-1);
27+
return { agentName, content: `echo:${last.content}`, role: "assistant" };
28+
},
29+
});
30+
const AGENT = addAgent({ agentName: "e1-agent", completion: "e1-completion", prompt: "" });
31+
const SWARM = addSwarm({ swarmName: "e1-swarm", agentList: [AGENT], defaultAgent: AGENT });
32+
33+
const chatSession = session(randomString(), SWARM);
34+
const pending = chatSession.complete("question");
35+
await sleep(50);
36+
const disposeResult = await raceHang(chatSession.dispose());
37+
const result = await raceHang(pending);
38+
await sleep(400);
39+
40+
const ok =
41+
disposeResult !== HANG && result === "";
42+
43+
if (ok) {
44+
pass();
45+
return;
46+
}
47+
fail(`dispose=${String(disposeResult)} result=${String(result)}`);
48+
});
49+
50+
test("Will let the same client start fresh session after dispose mid-flight", async ({ pass, fail }) => {
51+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
52+
53+
addCompletion({
54+
completionName: "e2-completion",
55+
getCompletion: async ({ agentName, messages }) => {
56+
await sleep(200);
57+
const [last] = messages.slice(-1);
58+
return { agentName, content: `echo:${last.content}`, role: "assistant" };
59+
},
60+
});
61+
const AGENT = addAgent({ agentName: "e2-agent", completion: "e2-completion", prompt: "" });
62+
const SWARM = addSwarm({ swarmName: "e2-swarm", agentList: [AGENT], defaultAgent: AGENT });
63+
64+
const CLIENT_ID = randomString();
65+
const first = session(CLIENT_ID, SWARM);
66+
const pending = first.complete("question");
67+
await sleep(30);
68+
await first.dispose();
69+
const stale = await raceHang(pending);
70+
71+
const second = session(CLIENT_ID, SWARM);
72+
const fresh = await raceHang(second.complete("again"), 6000);
73+
await second.dispose();
74+
await sleep(300);
75+
76+
const ok =
77+
stale === "" && fresh === "echo:again";
78+
79+
if (ok) {
80+
pass();
81+
return;
82+
}
83+
fail(`stale=${String(stale)} fresh=${String(fresh)}`);
84+
});
85+
86+
test("Will resolve pending makeConnection send on disposeConnection", async ({ pass, fail }) => {
87+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
88+
89+
addCompletion({
90+
completionName: "e3-completion",
91+
getCompletion: async ({ agentName, messages }) => {
92+
await sleep(250);
93+
const [last] = messages.slice(-1);
94+
return { agentName, content: `echo:${last.content}`, role: "assistant" };
95+
},
96+
});
97+
const AGENT = addAgent({ agentName: "e3-agent", completion: "e3-completion", prompt: "" });
98+
const SWARM = addSwarm({ swarmName: "e3-swarm", agentList: [AGENT], defaultAgent: AGENT });
99+
100+
const CLIENT_ID = randomString();
101+
const received = [];
102+
const send = makeConnection((msg) => received.push(msg.data), CLIENT_ID, SWARM);
103+
const pending = send("question");
104+
await sleep(30);
105+
await raceHang(disposeConnection(CLIENT_ID, SWARM));
106+
const result = await raceHang(pending);
107+
await sleep(300);
108+
109+
const ok =
110+
result !== HANG;
111+
112+
if (ok) {
113+
pass();
114+
return;
115+
}
116+
fail(`result=${String(result)} received=${JSON.stringify(received)}`);
117+
});
118+
119+
test("Will keep system messages beyond keepMessages window", async ({ pass, fail }) => {
120+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
121+
122+
let systemSeen = null;
123+
let commonCount = null;
124+
addCompletion({
125+
completionName: "e4-completion",
126+
getCompletion: async ({ agentName, messages }) => {
127+
const [last] = messages.slice(-1);
128+
if (last.content === "turn4") {
129+
systemSeen = messages
130+
.filter((m) => m.role === "system")
131+
.map((m) => m.content);
132+
commonCount = messages.filter((m) => m.role !== "system").length;
133+
}
134+
return { agentName, content: `echo:${last.content}`, role: "assistant" };
135+
},
136+
});
137+
const AGENT = addAgent({
138+
agentName: "e4-agent",
139+
completion: "e4-completion",
140+
prompt: "",
141+
keepMessages: 2,
142+
});
143+
const SWARM = addSwarm({ swarmName: "e4-swarm", agentList: [AGENT], defaultAgent: AGENT });
144+
145+
const CLIENT_ID = randomString();
146+
const chatSession = session(CLIENT_ID, SWARM);
147+
await commitSystemMessage("rule-one", CLIENT_ID, AGENT);
148+
await chatSession.complete("turn1");
149+
await commitSystemMessage("rule-two", CLIENT_ID, AGENT);
150+
await chatSession.complete("turn2");
151+
await chatSession.complete("turn3");
152+
await chatSession.complete("turn4");
153+
await chatSession.dispose();
154+
155+
const ok =
156+
Array.isArray(systemSeen) &&
157+
systemSeen.includes("rule-one") &&
158+
systemSeen.includes("rule-two") &&
159+
commonCount === 2;
160+
if (ok) {
161+
pass();
162+
return;
163+
}
164+
fail(`system=${JSON.stringify(systemSeen)} commonCount=${commonCount}`);
165+
});
166+
167+
test("Will clear accumulated system messages on flush", async ({ pass, fail }) => {
168+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
169+
170+
let systemAfterFlush = null;
171+
addCompletion({
172+
completionName: "e5-completion",
173+
getCompletion: async ({ agentName, messages }) => {
174+
const [last] = messages.slice(-1);
175+
if (last.content === "after-flush") {
176+
systemAfterFlush = messages
177+
.filter((m) => m.role === "system")
178+
.map((m) => m.content);
179+
}
180+
return { agentName, content: `echo:${last.content}`, role: "assistant" };
181+
},
182+
});
183+
const AGENT = addAgent({ agentName: "e5-agent", completion: "e5-completion", prompt: "" });
184+
const SWARM = addSwarm({ swarmName: "e5-swarm", agentList: [AGENT], defaultAgent: AGENT });
185+
186+
const CLIENT_ID = randomString();
187+
const chatSession = session(CLIENT_ID, SWARM);
188+
await commitSystemMessage("stale-rule", CLIENT_ID, AGENT);
189+
await chatSession.complete("turn1");
190+
await commitFlushForce(CLIENT_ID);
191+
await chatSession.complete("after-flush");
192+
await chatSession.dispose();
193+
194+
const ok = Array.isArray(systemAfterFlush) && !systemAfterFlush.includes("stale-rule");
195+
if (ok) {
196+
pass();
197+
return;
198+
}
199+
fail(`system=${JSON.stringify(systemAfterFlush)}`);
200+
});
201+

0 commit comments

Comments
 (0)