Skip to content

Commit 6923bc8

Browse files
committed
patch
1 parent f48c0de commit 6923bc8

6 files changed

Lines changed: 325 additions & 9 deletions

File tree

TODO.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,33 @@ shared state/storage и кэш эмбеддингов. Багов не найд
387387
- бан политики бьёт только по забаненному клиенту, сосед работает;
388388
- dispose одного клиента посреди медленного выполнения другого не ломает его.
389389

390-
## Найденные и исправленные баги (21 итого)
390+
## 19-й заход: двойная отправка одной сессией без ожидания ответа (+5 тестов, +1 баг)
391+
392+
### Новые тесты (5), сьют вырос до 223/223 — test/spec/doublesend.test.mjs
393+
- второй complete во время МЕДЛЕННОГО ТУЛА первого → busy-lock сериализует,
394+
попарность сохранена;
395+
- fire-and-forget: три complete без ожидания предыдущих → каждый получает свой ответ;
396+
- makeConnection: два send без await → connector получает оба ответа по порядку;
397+
- cancelOutput посреди выполнения → первый complete возвращает "", следующий
398+
получает СВОЙ ответ (регрессионный тест бага №22);
399+
- emit-подмена посреди выполнения → первый complete возвращает подменённый текст,
400+
следующий получает СВОЙ ответ (регрессионный тест бага №22).
401+
402+
### Баг №22: осиротевший вывод после cancelOutput/emit отравлял следующий обмен
403+
Файлы: `src/client/ClientAgent.ts`, `src/client/ClientSwarm.ts` (найден D4/D5)
404+
- cancelOutput (или emit-подмена) резолвили ожидателя текущего complete, но само
405+
выполнение продолжало жить и ПОЗЖЕ эмитило свой результат в _outputSubject.
406+
К этому моменту уже был подписан ожидатель СЛЕДУЮЩЕГО сообщения — пользователь
407+
получал ответ на отменённый вопрос (r2="STALE-ANSWER" вместо echo).
408+
- Исправление: эпоха вывода _outputEpoch в ClientAgent. EXECUTE_FN захватывает
409+
эпоху на старте и передаёт её во все _emitOutput (включая late-recovery в
410+
createToolCall); commitCancelOutput инкрементит эпоху;
411+
ClientAgent.commitOutputSubstituted() — то же для emit (вызывается из
412+
ClientSwarm.emit по всем агентам). _emitOutput с несовпавшей эпохой молча
413+
дропает stale-результат (двойная проверка: на входе и прямо перед
414+
_outputSubject.next, т.к. между ними есть await'ы).
415+
416+
## Найденные и исправленные баги (22 итого)
391417

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

src/client/ClientAgent.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ const createToolCall = async (
110110
targetFn: IAgentTool,
111111
reason: string,
112112
mode: ExecutionMode,
113+
outputEpoch: number,
113114
self: ClientAgent
114115
) => {
115116
self._runningToolCalls += 1;
@@ -192,7 +193,7 @@ const createToolCall = async (
192193
tool.function.name
193194
} error=${getErrorMessage(error)}`
194195
);
195-
await self._emitOutput(mode, result);
196+
await self._emitOutput(mode, result, outputEpoch);
196197
}
197198
} finally {
198199
self._runningToolCalls -= 1;
@@ -359,6 +360,7 @@ const EXECUTE_FN = async (
359360
incoming,
360361
mode
361362
);
363+
const outputEpoch = self._outputEpoch;
362364
await self.params.history.push({
363365
role: "user",
364366
mode,
@@ -431,7 +433,7 @@ const EXECUTE_FN = async (
431433
self.params.logger.debug(
432434
`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`
433435
);
434-
await self._emitOutput(mode, result);
436+
await self._emitOutput(mode, result, outputEpoch);
435437
run(false);
436438
return;
437439
}
@@ -485,7 +487,7 @@ const EXECUTE_FN = async (
485487
self.params.logger.debug(
486488
`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`
487489
);
488-
await self._emitOutput(mode, result);
490+
await self._emitOutput(mode, result, outputEpoch);
489491
run(false);
490492
return;
491493
}
@@ -556,6 +558,7 @@ const EXECUTE_FN = async (
556558
targetFn,
557559
message.content || "",
558560
mode,
561+
outputEpoch,
559562
self
560563
);
561564
const status = await statusAwaiter;
@@ -626,7 +629,7 @@ const EXECUTE_FN = async (
626629
self.params.logger.debug(
627630
`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`
628631
);
629-
await self._emitOutput(mode, result);
632+
await self._emitOutput(mode, result, outputEpoch);
630633
}
631634
return status;
632635
});
@@ -669,14 +672,14 @@ const EXECUTE_FN = async (
669672
mode,
670673
`Invalid model output: ${result}`
671674
);
672-
await self._emitOutput(mode, result1);
675+
await self._emitOutput(mode, result1, outputEpoch);
673676
return;
674677
}
675678
GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
676679
self.params.logger.debug(
677680
`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`
678681
);
679-
await self._emitOutput(mode, result);
682+
await self._emitOutput(mode, result, outputEpoch);
680683
};
681684

682685
/**
@@ -710,6 +713,15 @@ export class ClientAgent implements IAgent {
710713
*/
711714
_activeToolChains = 0;
712715

716+
/**
717+
* Generation counter for output emissions. Bumped by commitCancelOutput and by
718+
* swarm-level output substitution (emit). Executions capture the epoch at start
719+
* and _emitOutput drops their result when the epoch moved on — otherwise the
720+
* stale output of a cancelled/substituted execution would resolve the waiter
721+
* of the NEXT message, poisoning that exchange.
722+
*/
723+
_outputEpoch = 0;
724+
713725
/**
714726
* Subject for signaling agent changes, halting subsequent tool executions via commitAgentChange.
715727
* @readonly
@@ -915,7 +927,19 @@ export class ClientAgent implements IAgent {
915927
* Supports SwarmConnectionService by broadcasting agent outputs within the swarm.
916928
* @throws {Error} If validation fails after model resurrection, indicating an unrecoverable state.
917929
**/
918-
async _emitOutput(mode: ExecutionMode, rawResult: string): Promise<void> {
930+
async _emitOutput(
931+
mode: ExecutionMode,
932+
rawResult: string,
933+
outputEpoch = this._outputEpoch
934+
): Promise<void> {
935+
if (outputEpoch !== this._outputEpoch) {
936+
GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
937+
this.params.logger.debug(
938+
`ClientAgent agentName=${this.params.agentName} clientId=${this.params.clientId} _emitOutput skipped for stale execution (cancelled or substituted)`,
939+
{ mode, rawResult }
940+
);
941+
return;
942+
}
919943
const result = await this.params.transform(
920944
rawResult,
921945
this.params.clientId,
@@ -939,6 +963,9 @@ export class ClientAgent implements IAgent {
939963
`agent-swarm-kit ClientAgent agentName=${this.params.agentName} clientId=${this.params.clientId} model resurrect failed: ${validation}`
940964
);
941965
}
966+
if (outputEpoch !== this._outputEpoch) {
967+
return;
968+
}
942969
this.params.onOutput &&
943970
this.params.onOutput(
944971
this.params.clientId,
@@ -969,6 +996,9 @@ export class ClientAgent implements IAgent {
969996
});
970997
return;
971998
}
999+
if (outputEpoch !== this._outputEpoch) {
1000+
return;
1001+
}
9721002
this.params.onOutput &&
9731003
this.params.onOutput(this.params.clientId, this.params.agentName, result);
9741004
await this._outputSubject.next(result);
@@ -1094,6 +1124,19 @@ export class ClientAgent implements IAgent {
10941124
return placeholder;
10951125
}
10961126

1127+
/**
1128+
* Marks in-flight executions as substituted: the swarm emitted a replacement
1129+
* output (ClientSwarm.emit), so results of executions started earlier must not
1130+
* reach _outputSubject — they would pair with the next waiter otherwise.
1131+
*/
1132+
commitOutputSubstituted(): void {
1133+
GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
1134+
this.params.logger.debug(
1135+
`ClientAgent agentName=${this.params.agentName} clientId=${this.params.clientId} commitOutputSubstituted`
1136+
);
1137+
this._outputEpoch += 1;
1138+
}
1139+
10971140
/**
10981141
* Waits for the next output to be emitted via _outputSubject, typically after execute or run.
10991142
* Useful for external consumers (e.g., SwarmConnectionService) awaiting agent responses.
@@ -1366,6 +1409,7 @@ export class ClientAgent implements IAgent {
13661409
this.params.logger.debug(
13671410
`ClientAgent agentName=${this.params.agentName} clientId=${this.params.clientId} commitCancelOutput`
13681411
);
1412+
this._outputEpoch += 1;
13691413
this._toolAbortController.abort();
13701414
await this._cancelOutputSubject.next(CANCEL_OUTPUT_SYMBOL);
13711415
await this.params.bus.emit<IBusEvent>(this.params.clientId, {

src/client/ClientSwarm.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,15 @@ export class ClientSwarm implements ISwarm {
367367
this.params.logger.debug(
368368
`ClientSwarm swarmName=${this.params.swarmName} clientId=${this.params.clientId} emit`
369369
);
370+
// The emitted message substitutes the output of any in-flight execution:
371+
// invalidate agents' pending emissions so a stale result cannot resolve
372+
// the waiter of the next message.
373+
for (const [, agent] of this._agentList) {
374+
const agentRef = agent as unknown as {
375+
commitOutputSubstituted?: () => void;
376+
};
377+
agentRef.commitOutputSubstituted?.();
378+
}
370379
await this._emitSubject.next({
371380
agentName: await this.getAgentName(),
372381
output: message,

test/index.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import "./spec/loop.test.mjs";
3131
import "./spec/toolguard.test.mjs";
3232
import "./spec/aliastools.test.mjs";
3333
import "./spec/multiclient.test.mjs";
34+
import "./spec/doublesend.test.mjs";
3435

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

0 commit comments

Comments
 (0)