@adhdev/daemon-core 0.9.82-rc.463 → 0.9.82-rc.465
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/index.js +880 -820
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +886 -826
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-pending.d.ts +19 -8
- package/dist/mesh/mesh-reconcile-acked-hold.d.ts +17 -0
- package/dist/mesh/mesh-reconcile-identity.d.ts +6 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +2 -14
- package/dist/mesh/mesh-reconcile-v2-backstop.d.ts +16 -0
- package/dist/providers/cli-provider-history-dedup.d.ts +17 -0
- package/dist/providers/cli-provider-input-prompt.d.ts +12 -0
- package/dist/providers/cli-provider-instance.d.ts +3 -35
- package/dist/providers/cli-provider-status-helpers.d.ts +46 -0
- package/package.json +3 -3
- package/src/mesh/mesh-events-pending.ts +69 -15
- package/src/mesh/mesh-reconcile-acked-hold.ts +230 -0
- package/src/mesh/mesh-reconcile-identity.ts +103 -0
- package/src/mesh/mesh-reconcile-loop.ts +28 -392
- package/src/mesh/mesh-reconcile-v2-backstop.ts +62 -0
- package/src/providers/cli-provider-history-dedup.ts +75 -0
- package/src/providers/cli-provider-input-prompt.ts +133 -0
- package/src/providers/cli-provider-instance.ts +23 -295
- package/src/providers/cli-provider-status-helpers.ts +123 -0
|
@@ -56,15 +56,16 @@ export interface PendingEventEmitHint {
|
|
|
56
56
|
* T6 (B3c) enforce switch. When ON, the drain path stops passing an unversioned
|
|
57
57
|
* (v1) or a validation-failing v2 event through to the coordinator — it QUARANTINES
|
|
58
58
|
* it instead (excluded from the delivered batch + WARN + counter), and unicast
|
|
59
|
-
* routing is the only delivery path (there is no v1 broadcast fallback).
|
|
60
|
-
* default
|
|
59
|
+
* routing is the only delivery path (there is no v1 broadcast fallback). On by
|
|
60
|
+
* default; set MESH_PROTOCOL_V2_ENFORCE=0/false/off/no to disable and restore the
|
|
61
|
+
* accept-and-warn rollout behaviour exactly.
|
|
61
62
|
*
|
|
62
|
-
* Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env)
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* no data migration
|
|
67
|
-
* operator can toggle it without a restart.
|
|
63
|
+
* Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env).
|
|
64
|
+
* Now that every node emits v2 (§배포 게이트 1 / risk §4), the code default is ON —
|
|
65
|
+
* a manual env injection is no longer required to get enforce behaviour. Rollback to
|
|
66
|
+
* accept mode is a pure-env step: set `MESH_PROTOCOL_V2_ENFORCE=0` (or `false`/`off`/
|
|
67
|
+
* `no`) — no code change, no data migration (the schema is additive). Read at call
|
|
68
|
+
* time so a test / operator can toggle it without a restart.
|
|
68
69
|
*
|
|
69
70
|
* Quarantine (not drop) keeps the loss-free invariant. The DESTRUCTIVE drain has
|
|
70
71
|
* already consumed the event from its store by the time routing runs, so "held
|
|
@@ -182,6 +183,16 @@ export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordin
|
|
|
182
183
|
* (including drained fingerprint history) and JSONL files.
|
|
183
184
|
*/
|
|
184
185
|
export declare function __clearMeshPendingEventsForTests(meshId: string): void;
|
|
186
|
+
/**
|
|
187
|
+
* Test helper: persist a pending event VERBATIM, skipping the emit-time v2 stamp.
|
|
188
|
+
* The local emit path (queuePendingMeshCoordinatorEvent → stampPendingEventV2) now
|
|
189
|
+
* always mints a v2 envelope (self-daemon broadcast fallback when no coordinator
|
|
190
|
+
* identity is present), so a genuinely-unversioned (v1) row can no longer be produced
|
|
191
|
+
* through the normal queue. The drain-side v1 handling (accept-broadcast / enforce-
|
|
192
|
+
* quarantine) still matters for durable v1 rows written by a pre-v2 daemon and for
|
|
193
|
+
* version-skewed remote relays, so tests inject those rows directly through this.
|
|
194
|
+
*/
|
|
195
|
+
export declare function __persistUnstampedPendingEventForTests(event: PendingMeshCoordinatorEvent): boolean;
|
|
185
196
|
/** Explicitly clear all pending coordinator events for a mesh (and coordinator if scoped). */
|
|
186
197
|
export declare function clearPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): void;
|
|
187
198
|
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const ACKED_DEATH_CONSECUTIVE_READ_FAILURES = 3;
|
|
2
|
+
export declare function resolveTunedReconcileMs(envName: string, def: number, min: number, max: number): number;
|
|
3
|
+
export declare function resolveAckedDeathDeadlineMs(): number;
|
|
4
|
+
export declare function resolveAckedTranscriptFastTrackGraceMs(): number;
|
|
5
|
+
export interface AckedHoldState {
|
|
6
|
+
liveConfirmedSinceAck: boolean;
|
|
7
|
+
consecutiveReadFailures: number;
|
|
8
|
+
transcriptIdleSinceMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const inFlightAckedHoldState: Map<string, AckedHoldState>;
|
|
11
|
+
export declare function inFlightSynthKey(meshId: string, taskId: string): string;
|
|
12
|
+
export declare function getHoldState(synthKey: string, meshId: string): AckedHoldState | undefined;
|
|
13
|
+
export declare function setHoldState(synthKey: string, meshId: string, state: AckedHoldState): void;
|
|
14
|
+
export declare function deleteHoldState(synthKey: string, meshId: string): void;
|
|
15
|
+
export declare function rehydrateAckedHoldsForMesh(meshId: string): void;
|
|
16
|
+
export declare function collectHeldSynthKeysForMesh(meshId: string): Set<string>;
|
|
17
|
+
export declare function __resetReconcileInFlightSynthDebounceForTests(): void;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
2
|
+
import type { LocalMeshEntry } from '../repo-mesh-types.js';
|
|
3
|
+
export declare function resolveCoordinatorDaemonIds(components: DaemonComponents): string[];
|
|
4
|
+
export declare function daemonHostsMesh(mesh: LocalMeshEntry, daemonIds: string[]): boolean;
|
|
5
|
+
export declare function daemonIdListIncludes(ids: readonly string[], id: string | undefined): boolean;
|
|
6
|
+
export declare function resolveCoordinatorSelfIds(mesh: LocalMeshEntry, drainDaemonIds: string[]): string[];
|
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
phase4SynthesisFired: number;
|
|
5
|
-
/** Acked-hold transcript fast-track promoted a synth ahead of the death deadline. */
|
|
6
|
-
ackedHoldFastTrackFired: number;
|
|
7
|
-
/** Acked-hold death-deadline backstop released a held synth (the 8-min net). */
|
|
8
|
-
ackedHoldDeathDeadlineFired: number;
|
|
9
|
-
};
|
|
10
|
-
/** Test/observability accessor for the v2 last-resort backstop counters (snapshot). */
|
|
11
|
-
export declare function getMeshV2BackstopCounters(): Readonly<typeof meshV2BackstopCounters>;
|
|
12
|
-
/** Test helper: zero the backstop counters so a case starts from a clean slate. */
|
|
13
|
-
export declare function __resetMeshV2BackstopCountersForTests(): void;
|
|
14
|
-
export declare function __resetReconcileInFlightSynthDebounceForTests(): void;
|
|
2
|
+
export { getMeshV2BackstopCounters, __resetMeshV2BackstopCountersForTests } from './mesh-reconcile-v2-backstop.js';
|
|
3
|
+
export { __resetReconcileInFlightSynthDebounceForTests } from './mesh-reconcile-acked-hold.js';
|
|
15
4
|
/**
|
|
16
5
|
* DRAIN-WITHOUT-INJECT guard. Classify, for a mesh on THIS daemon, whether a
|
|
17
6
|
* queue-drain caller (the MCP `get_pending_mesh_events` poll) may safely consume
|
|
@@ -89,4 +78,3 @@ interface ReconcileLoopHandle {
|
|
|
89
78
|
stop(): void;
|
|
90
79
|
}
|
|
91
80
|
export declare function setupMeshReconcileLoop(components: DaemonComponents): ReconcileLoopHandle;
|
|
92
|
-
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
declare const meshV2BackstopCounters: {
|
|
2
|
+
/** PHASE-4 transcript synthesis actually reconciled a missing completion. */
|
|
3
|
+
phase4SynthesisFired: number;
|
|
4
|
+
/** Acked-hold transcript fast-track promoted a synth ahead of the death deadline. */
|
|
5
|
+
ackedHoldFastTrackFired: number;
|
|
6
|
+
/** Acked-hold death-deadline backstop released a held synth (the 8-min net). */
|
|
7
|
+
ackedHoldDeathDeadlineFired: number;
|
|
8
|
+
};
|
|
9
|
+
/** Test/observability accessor for the v2 last-resort backstop counters (snapshot). */
|
|
10
|
+
export declare function getMeshV2BackstopCounters(): Readonly<typeof meshV2BackstopCounters>;
|
|
11
|
+
/** Test helper: zero the backstop counters so a case starts from a clean slate. */
|
|
12
|
+
export declare function __resetMeshV2BackstopCountersForTests(): void;
|
|
13
|
+
/** Bump a backstop counter and, under enforce, WARN that a last-resort net fired —
|
|
14
|
+
* which under a healthy v2 contract should not happen (the real emit was lost). */
|
|
15
|
+
export declare function recordBackstopFire(kind: keyof typeof meshV2BackstopCounters, detail: string): void;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI provider persisted-history dedup — incremental append computation.
|
|
3
|
+
*
|
|
4
|
+
* Pure move out of cli-provider-instance.ts (no behavior change): the
|
|
5
|
+
* shared-prefix diff that turns a full parsed transcript into the newly-added
|
|
6
|
+
* tail to append to the persisted chat history. cli-provider-instance
|
|
7
|
+
* re-exports buildIncrementalHistoryAppendMessages so existing importers/tests
|
|
8
|
+
* keep their path.
|
|
9
|
+
*/
|
|
10
|
+
export type PersistableCliHistoryMessage = {
|
|
11
|
+
role: string;
|
|
12
|
+
content: string;
|
|
13
|
+
kind?: string;
|
|
14
|
+
senderName?: string;
|
|
15
|
+
receivedAt?: number;
|
|
16
|
+
};
|
|
17
|
+
export declare function buildIncrementalHistoryAppendMessages(previousMessages: PersistableCliHistoryMessage[], currentMessages: PersistableCliHistoryMessage[]): PersistableCliHistoryMessage[];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI provider structured-input helpers — image materialization + prompt build.
|
|
3
|
+
*
|
|
4
|
+
* Pure move out of cli-provider-instance.ts (no behavior change): the input
|
|
5
|
+
* envelope → CLI prompt string construction and its image-materialization
|
|
6
|
+
* support. cli-provider-instance re-exports buildCliStructuredInputPrompt so
|
|
7
|
+
* existing importers/tests keep their path.
|
|
8
|
+
*/
|
|
9
|
+
import { type InputEnvelope } from './contracts.js';
|
|
10
|
+
export declare function buildCliStructuredInputPrompt(input: InputEnvelope, options?: {
|
|
11
|
+
materializeDir?: string;
|
|
12
|
+
}): string;
|
|
@@ -9,40 +9,9 @@ import type { ProviderInstance, ProviderState, InstanceContext, HotChatSessionSt
|
|
|
9
9
|
import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
|
|
10
10
|
import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
|
|
11
11
|
import type { ChatMessage } from '../types.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
kind?: string;
|
|
16
|
-
senderName?: string;
|
|
17
|
-
receivedAt?: number;
|
|
18
|
-
};
|
|
19
|
-
/**
|
|
20
|
-
* NOTIF Defect-2a: the REPORTED short-generating duration, anchored on the IMMUTABLE turn
|
|
21
|
-
* start. generatingStartedAt is reset to 0 on every mid-turn waiting_approval/idle blip and
|
|
22
|
-
* re-armed on the next →generating, so a long turn that blips would otherwise measure only the
|
|
23
|
-
* final 1.5-2.5s sliver. engine.currentTurnStartedAt (set once at onTurnStarted, surviving
|
|
24
|
-
* mid-turn blips until the next turn starts) is preferred; generatingStartedAt is the fallback
|
|
25
|
-
* for turns that never recorded an engine turn start. Returns 0 when neither anchor is set.
|
|
26
|
-
* Pure / unit-testable.
|
|
27
|
-
*/
|
|
28
|
-
export declare function computeTurnAnchoredDurationMs(engineTurnStartedAt: number | undefined, generatingStartedAt: number, now: number): {
|
|
29
|
-
durationMs: number;
|
|
30
|
-
anchor: 'turn-start' | 'generatingStartedAt' | 'none';
|
|
31
|
-
};
|
|
32
|
-
export declare function buildCliStructuredInputPrompt(input: InputEnvelope, options?: {
|
|
33
|
-
materializeDir?: string;
|
|
34
|
-
}): string;
|
|
35
|
-
export declare function buildIncrementalHistoryAppendMessages(previousMessages: PersistableCliHistoryMessage[], currentMessages: PersistableCliHistoryMessage[]): PersistableCliHistoryMessage[];
|
|
36
|
-
export declare function getForcedNewSessionScriptName(provider: ProviderModule | undefined, launchMode: 'new' | 'resume' | 'manual'): string | null;
|
|
37
|
-
export declare function waitForCliAdapterReady(adapter: {
|
|
38
|
-
isReady?: () => boolean;
|
|
39
|
-
getStatus?: () => {
|
|
40
|
-
status?: string;
|
|
41
|
-
};
|
|
42
|
-
}, options?: {
|
|
43
|
-
timeoutMs?: number;
|
|
44
|
-
pollMs?: number;
|
|
45
|
-
}): Promise<void>;
|
|
12
|
+
export { buildCliStructuredInputPrompt } from './cli-provider-input-prompt.js';
|
|
13
|
+
export { buildIncrementalHistoryAppendMessages } from './cli-provider-history-dedup.js';
|
|
14
|
+
export { computeTurnAnchoredDurationMs, getForcedNewSessionScriptName, waitForCliAdapterReady, } from './cli-provider-status-helpers.js';
|
|
46
15
|
export declare class CliProviderInstance implements ProviderInstance {
|
|
47
16
|
private provider;
|
|
48
17
|
private workingDir;
|
|
@@ -429,4 +398,3 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
429
398
|
private buildSqlPlaceholderList;
|
|
430
399
|
private querySqliteText;
|
|
431
400
|
}
|
|
432
|
-
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI provider status/launch pure helpers.
|
|
3
|
+
*
|
|
4
|
+
* Pure move out of cli-provider-instance.ts (no behavior change): the
|
|
5
|
+
* side-effect-free status predicates, the turn-anchored duration computation,
|
|
6
|
+
* the forced-new-session script resolver, the adapter-ready poll, and the lazy
|
|
7
|
+
* node:sqlite DatabaseSync loader. cli-provider-instance re-exports the
|
|
8
|
+
* public symbols (computeTurnAnchoredDurationMs, getForcedNewSessionScriptName,
|
|
9
|
+
* waitForCliAdapterReady) so existing importers/tests keep their path.
|
|
10
|
+
*/
|
|
11
|
+
import type { ProviderModule } from './contracts.js';
|
|
12
|
+
export declare function isIdleStatus(value: unknown): boolean;
|
|
13
|
+
export declare function getMessageTime(message: unknown): number;
|
|
14
|
+
export declare function hasNonEmptyCliModalButtons(activeModal: unknown): boolean;
|
|
15
|
+
export declare function isCliGeneratingLikeStatus(status: unknown): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* NOTIF Defect-2a: the REPORTED short-generating duration, anchored on the IMMUTABLE turn
|
|
18
|
+
* start. generatingStartedAt is reset to 0 on every mid-turn waiting_approval/idle blip and
|
|
19
|
+
* re-armed on the next →generating, so a long turn that blips would otherwise measure only the
|
|
20
|
+
* final 1.5-2.5s sliver. engine.currentTurnStartedAt (set once at onTurnStarted, surviving
|
|
21
|
+
* mid-turn blips until the next turn starts) is preferred; generatingStartedAt is the fallback
|
|
22
|
+
* for turns that never recorded an engine turn start. Returns 0 when neither anchor is set.
|
|
23
|
+
* Pure / unit-testable.
|
|
24
|
+
*/
|
|
25
|
+
export declare function computeTurnAnchoredDurationMs(engineTurnStartedAt: number | undefined, generatingStartedAt: number, now: number): {
|
|
26
|
+
durationMs: number;
|
|
27
|
+
anchor: 'turn-start' | 'generatingStartedAt' | 'none';
|
|
28
|
+
};
|
|
29
|
+
export declare function getDatabaseSync(): new (path: string, options?: {
|
|
30
|
+
readOnly?: boolean;
|
|
31
|
+
}) => {
|
|
32
|
+
prepare(sql: string): {
|
|
33
|
+
get(...params: Array<string | number>): unknown;
|
|
34
|
+
};
|
|
35
|
+
close(): void;
|
|
36
|
+
};
|
|
37
|
+
export declare function getForcedNewSessionScriptName(provider: ProviderModule | undefined, launchMode: 'new' | 'resume' | 'manual'): string | null;
|
|
38
|
+
export declare function waitForCliAdapterReady(adapter: {
|
|
39
|
+
isReady?: () => boolean;
|
|
40
|
+
getStatus?: () => {
|
|
41
|
+
status?: string;
|
|
42
|
+
};
|
|
43
|
+
}, options?: {
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
pollMs?: number;
|
|
46
|
+
}): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.465",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.465",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.465",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -2,6 +2,7 @@ import { appendFileSync, existsSync, readFileSync, renameSync, statSync, unlinkS
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { LOG } from '../logging/logger.js';
|
|
5
|
+
import { loadConfig } from '../config/config.js';
|
|
5
6
|
import { getLedgerDir, readLedgerEntries, appendLedgerEntry } from './mesh-ledger.js';
|
|
6
7
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
7
8
|
import { buildMeshSystemMessage, readNonEmptyString, readRecord, resolveEventSessionId, readMeshCompletionSummary, isWeakCompletionMetadata } from './mesh-events-utils.js';
|
|
@@ -138,15 +139,16 @@ function normalizeCoordinatorDaemonIds(
|
|
|
138
139
|
* T6 (B3c) enforce switch. When ON, the drain path stops passing an unversioned
|
|
139
140
|
* (v1) or a validation-failing v2 event through to the coordinator — it QUARANTINES
|
|
140
141
|
* it instead (excluded from the delivered batch + WARN + counter), and unicast
|
|
141
|
-
* routing is the only delivery path (there is no v1 broadcast fallback).
|
|
142
|
-
* default
|
|
142
|
+
* routing is the only delivery path (there is no v1 broadcast fallback). On by
|
|
143
|
+
* default; set MESH_PROTOCOL_V2_ENFORCE=0/false/off/no to disable and restore the
|
|
144
|
+
* accept-and-warn rollout behaviour exactly.
|
|
143
145
|
*
|
|
144
|
-
* Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env)
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* no data migration
|
|
149
|
-
* operator can toggle it without a restart.
|
|
146
|
+
* Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env).
|
|
147
|
+
* Now that every node emits v2 (§배포 게이트 1 / risk §4), the code default is ON —
|
|
148
|
+
* a manual env injection is no longer required to get enforce behaviour. Rollback to
|
|
149
|
+
* accept mode is a pure-env step: set `MESH_PROTOCOL_V2_ENFORCE=0` (or `false`/`off`/
|
|
150
|
+
* `no`) — no code change, no data migration (the schema is additive). Read at call
|
|
151
|
+
* time so a test / operator can toggle it without a restart.
|
|
150
152
|
*
|
|
151
153
|
* Quarantine (not drop) keeps the loss-free invariant. The DESTRUCTIVE drain has
|
|
152
154
|
* already consumed the event from its store by the time routing runs, so "held
|
|
@@ -159,9 +161,9 @@ function normalizeCoordinatorDaemonIds(
|
|
|
159
161
|
*/
|
|
160
162
|
export function isMeshProtocolV2EnforceEnabled(): boolean {
|
|
161
163
|
const raw = readNonEmptyString(process.env.MESH_PROTOCOL_V2_ENFORCE);
|
|
162
|
-
if (!raw) return
|
|
164
|
+
if (!raw) return true; // unset/blank = default ON
|
|
163
165
|
const v = raw.trim().toLowerCase();
|
|
164
|
-
return v === '
|
|
166
|
+
return !(v === '0' || v === 'false' || v === 'off' || v === 'no'); // only explicit off = false
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
/**
|
|
@@ -745,23 +747,51 @@ export function stampPendingEventV2(
|
|
|
745
747
|
return event;
|
|
746
748
|
}
|
|
747
749
|
|
|
748
|
-
const
|
|
750
|
+
const coordinatorIdentity = hint?.dispatchedBy ?? coordinatorIdentityFromEmitFields({
|
|
749
751
|
daemonId: event.targetCoordinatorDaemonId,
|
|
750
752
|
coordinatorRunId: hint?.coordinatorRunId,
|
|
751
753
|
sessionId: event.targetCoordinatorSessionId,
|
|
752
754
|
});
|
|
755
|
+
|
|
756
|
+
// A/C ROOT FIX: an emit site with NO coordinator identity (direct-dispatch /
|
|
757
|
+
// refine notification / any path where the worker session never carried a
|
|
758
|
+
// meshCoordinatorDaemonId) used to leave the event UNVERSIONED (v1). Under v2
|
|
759
|
+
// enforce (default ON) routeV2EventsForDrainer QUARANTINES every unversioned
|
|
760
|
+
// event — so a summary-less completion (agent:generating_completed) and every
|
|
761
|
+
// refine terminal notification (refine:accepted/completed/failed) were held
|
|
762
|
+
// back and never reached the coordinator; only the backstop papered over it.
|
|
763
|
+
//
|
|
764
|
+
// Fall back to THIS daemon's own id as the dispatcher so a v2 envelope can
|
|
765
|
+
// still be minted. There is no addressable coordinator, so we intentionally
|
|
766
|
+
// leave intendedFor empty and let buildPendingEventEmitStamp downgrade the
|
|
767
|
+
// (unicast-defaulting) terminal event to a BROADCAST — deliverable to whatever
|
|
768
|
+
// coordinator drains on this machine, instead of an undeliverable v1 event.
|
|
769
|
+
// When a real coordinator identity DOES exist the unicast path below is
|
|
770
|
+
// unchanged (no regression). loadConfig().machineId is the same self-id source
|
|
771
|
+
// resolveCoordinatorDaemonIds / the local queue-assignment stamp use, so the
|
|
772
|
+
// broadcast dispatcher matches the drainer's own identity form.
|
|
773
|
+
const selfFallback = !coordinatorIdentity;
|
|
774
|
+
const dispatchedBy = coordinatorIdentity ?? coordinatorIdentityFromEmitFields({
|
|
775
|
+
daemonId: readNonEmptyString(loadConfig().machineId),
|
|
776
|
+
});
|
|
753
777
|
// The unicast target is, by default, the same coordinator the event is already
|
|
754
|
-
// routed to (its originating coordinator). A hint may override it.
|
|
755
|
-
|
|
778
|
+
// routed to (its originating coordinator). A hint may override it. In the
|
|
779
|
+
// self-fallback case there is no originating coordinator to address, so leave
|
|
780
|
+
// it empty → broadcast (never a self-unicast that a sibling session's drainer
|
|
781
|
+
// would skip).
|
|
782
|
+
const intendedFor: CoordinatorIdentity | undefined = hint?.intendedFor
|
|
783
|
+
?? (selfFallback ? undefined : coordinatorIdentity);
|
|
756
784
|
|
|
757
785
|
const stamp = buildPendingEventEmitStamp({
|
|
758
786
|
eventName: event.event,
|
|
759
787
|
eventId: randomUUID(),
|
|
760
788
|
dispatchedBy,
|
|
761
789
|
intendedFor,
|
|
762
|
-
|
|
790
|
+
// Force broadcast for the self-fallback so a unicast-defaulting terminal
|
|
791
|
+
// event isn't addressed to this daemon alone; an explicit hint still wins.
|
|
792
|
+
scope: hint?.scope ?? (selfFallback ? 'broadcast' : undefined),
|
|
763
793
|
});
|
|
764
|
-
if (!stamp) return event; // no coordinator identity → stays a v1 event
|
|
794
|
+
if (!stamp) return event; // no coordinator identity at all (no self id) → stays a v1 event
|
|
765
795
|
|
|
766
796
|
return {
|
|
767
797
|
...event,
|
|
@@ -848,6 +878,17 @@ export function queuePendingMeshCoordinatorEvent(
|
|
|
848
878
|
// B2a: stamp the v2 envelope before dedup/persist so the eventId/scope ride
|
|
849
879
|
// into both stores and the fingerprint/dedup logic sees the final shape.
|
|
850
880
|
const event = stampPendingEventV2(rawEvent, hint);
|
|
881
|
+
return persistPendingMeshCoordinatorEvent(event);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Persist an ALREADY-STAMPED pending event to both stores (dedup + SQLite + JSONL),
|
|
886
|
+
* without re-running the emit stamp. queuePendingMeshCoordinatorEvent stamps then
|
|
887
|
+
* calls this; the only other caller is the test helper below, which needs to inject
|
|
888
|
+
* a genuinely-unversioned (v1) row to exercise the drain-side v1 handling now that
|
|
889
|
+
* the emit path never produces one (self-daemon fallback stamps every local emit).
|
|
890
|
+
*/
|
|
891
|
+
function persistPendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean {
|
|
851
892
|
try {
|
|
852
893
|
if (hasPendingRefineTerminalEventDuplicate(event)) {
|
|
853
894
|
LOG.info('MeshEvents', `Suppressed duplicate pending ${event.event} for refine job ${readRefineJobId(event)}`);
|
|
@@ -1211,6 +1252,19 @@ export function __clearMeshPendingEventsForTests(meshId: string): void {
|
|
|
1211
1252
|
clearPendingMeshCoordinatorEvents(meshId);
|
|
1212
1253
|
}
|
|
1213
1254
|
|
|
1255
|
+
/**
|
|
1256
|
+
* Test helper: persist a pending event VERBATIM, skipping the emit-time v2 stamp.
|
|
1257
|
+
* The local emit path (queuePendingMeshCoordinatorEvent → stampPendingEventV2) now
|
|
1258
|
+
* always mints a v2 envelope (self-daemon broadcast fallback when no coordinator
|
|
1259
|
+
* identity is present), so a genuinely-unversioned (v1) row can no longer be produced
|
|
1260
|
+
* through the normal queue. The drain-side v1 handling (accept-broadcast / enforce-
|
|
1261
|
+
* quarantine) still matters for durable v1 rows written by a pre-v2 daemon and for
|
|
1262
|
+
* version-skewed remote relays, so tests inject those rows directly through this.
|
|
1263
|
+
*/
|
|
1264
|
+
export function __persistUnstampedPendingEventForTests(event: PendingMeshCoordinatorEvent): boolean {
|
|
1265
|
+
return persistPendingMeshCoordinatorEvent(event);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1214
1268
|
/** Explicitly clear all pending coordinator events for a mesh (and coordinator if scoped). */
|
|
1215
1269
|
export function clearPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): void {
|
|
1216
1270
|
if (!meshId) return;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// mesh-reconcile-acked-hold — in-flight acked-hold state (persistence + tuning)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Pure move out of mesh-reconcile-loop.ts (no behavior change). This is the
|
|
5
|
+
// read-through/write-through cache over the mesh_inflight_hold table plus the
|
|
6
|
+
// env-tunable timers (death deadline, transcript fast-track grace) that govern
|
|
7
|
+
// when an acked dispatch's indefinite synth hold is released. See the R4f and
|
|
8
|
+
// ACKED-HOLD-IDLE-OVERTRUST design commentary inline.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import { LOG } from '../logging/logger.js';
|
|
12
|
+
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
13
|
+
import { readNonEmptyString } from './mesh-events-utils.js';
|
|
14
|
+
|
|
15
|
+
// R4f backstop (a): how many CONSECUTIVE read_chat failures (transport error / success:false /
|
|
16
|
+
// no payload) for an acked task are treated as a death signal that releases the indefinite hold.
|
|
17
|
+
// A single failed read is a transient probe blip; a session that genuinely died reads-fail every
|
|
18
|
+
// tick, so a small streak distinguishes the two without racing a live-but-slow worker.
|
|
19
|
+
export const ACKED_DEATH_CONSECUTIVE_READ_FAILURES = 3;
|
|
20
|
+
|
|
21
|
+
// R4f backstop (b): the absolute death-deadline. An acked task is held indefinitely until this much
|
|
22
|
+
// time has elapsed since its generating_started ack (dispatch.updatedAt); past it, a persistently
|
|
23
|
+
// idle session is synthesized as a notification-loss net. This is set FAR above any observed emit
|
|
24
|
+
// latency (R4e's worst case was ~16s) so it does NOT race a normal slow turn — it only catches a
|
|
25
|
+
// genuinely wedged worker or a permanently-lost emit. Read at call time so tests can tune it.
|
|
26
|
+
export function resolveTunedReconcileMs(envName: string, def: number, min: number, max: number): number {
|
|
27
|
+
const raw = readNonEmptyString(process.env[envName]);
|
|
28
|
+
if (raw) {
|
|
29
|
+
const parsed = Number.parseInt(raw, 10);
|
|
30
|
+
if (Number.isFinite(parsed) && parsed >= min && parsed <= max) return parsed;
|
|
31
|
+
}
|
|
32
|
+
return def;
|
|
33
|
+
}
|
|
34
|
+
export function resolveAckedDeathDeadlineMs(): number {
|
|
35
|
+
// Default 8 min — FAR above the variable emit latency the finite R4..R4e timers raced (R4e's
|
|
36
|
+
// worst case was ~16s); by the time this fires a live worker would long since have emitted its
|
|
37
|
+
// real terminal. The env-override floor is 0 so tests can force the deadline (production never
|
|
38
|
+
// sets it that low); the ceiling is 60min so a mis-set env cannot disable the loss-net forever.
|
|
39
|
+
return resolveTunedReconcileMs('MESH_INFLIGHT_ACKED_DEATH_DEADLINE_MS', 8 * 60_000, 0, 60 * 60_000);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ACKED-HOLD-IDLE-OVERTRUST (transcript-completion fast-track). The indefinite acked-hold above is
|
|
43
|
+
// safe but SLOW: when the worker's real generating_completed emit is dropped/lost, the only thing
|
|
44
|
+
// that promotes the missing completion is the 8-min death backstop — even though the answer has been
|
|
45
|
+
// FULLY rendered in the transcript for minutes (read_chat reports idle WITH a final visible assistant
|
|
46
|
+
// message every ~4s). Observed live: completions surfaced 144s / 492s late, both incompatible with the
|
|
47
|
+
// provider's own emit ceiling (COMPLETED_FINALIZATION_MAX_WAIT_MS 30s + NATIVE_HISTORY_MESH_IDLE_SETTLE
|
|
48
|
+
// 4s ≈ 34s). That gap = a worker that finished, whose PTY generating→idle edge / real emit was lost,
|
|
49
|
+
// held hostage to the 8-min net.
|
|
50
|
+
//
|
|
51
|
+
// Fast-track: when an acked task reads idle AND a final visible assistant message is present (the same
|
|
52
|
+
// transcript-completion evidence PHASE 4 already requires to synth), and that idle-with-final-assistant
|
|
53
|
+
// state has PERSISTED for a short continuous grace, promote the synth EARLY — ahead of the 8-min
|
|
54
|
+
// backstop. The grace is the correctness gate: a SINGLE idle read could be a mid-turn blip (PTY
|
|
55
|
+
// inter-tool-call settle, or final text rendered while the next tool call is about to start), so we
|
|
56
|
+
// require the idle-with-final-assistant signal to hold continuously for the grace window before
|
|
57
|
+
// trusting it as a genuine turn-end. Any non-idle read (generating / waiting_approval), a read
|
|
58
|
+
// failure, or the disappearance of the final assistant message RESETS the streak — so an actively
|
|
59
|
+
// streaming worker that momentarily reads idle never crosses the grace.
|
|
60
|
+
//
|
|
61
|
+
// Safety: this only changes WHEN an acked synth fires (earlier), never WHETHER it is correct —
|
|
62
|
+
// reconcileDirectDispatchCompletionFromTranscript's hasTerminalLedgerAfterDispatch makes a real
|
|
63
|
+
// emit that lands later an idempotent no-op, exactly as the death-backstop synth relies on. The
|
|
64
|
+
// death backstop (8 min) is PRESERVED unchanged as the final net; the fast-track is a faster path in
|
|
65
|
+
// front of it. The grace is set ABOVE the provider's own emit ceiling (~34s) so a worker still inside
|
|
66
|
+
// its normal finalization window is never pre-empted — we only fast-track once enough continuous idle
|
|
67
|
+
// has elapsed that a live emit would already have arrived.
|
|
68
|
+
export function resolveAckedTranscriptFastTrackGraceMs(): number {
|
|
69
|
+
// Default 40s — above the provider emit ceiling (30s COMPLETED_FINALIZATION_MAX_WAIT_MS + 4s
|
|
70
|
+
// NATIVE_HISTORY_MESH_IDLE_SETTLE ≈ 34s): a genuinely-live worker would have emitted its real
|
|
71
|
+
// terminal within that window, so 40s of CONTINUOUS idle-with-final-assistant means the emit was
|
|
72
|
+
// lost, not late. Far below the 8-min death backstop, so the fast-track is the dominant path for a
|
|
73
|
+
// lost emit while the backstop remains the last-resort net. Floor 0 lets tests force an immediate
|
|
74
|
+
// fast-track; ceiling 5min keeps a mis-set env from collapsing it into the death backstop.
|
|
75
|
+
return resolveTunedReconcileMs('MESH_INFLIGHT_ACKED_TRANSCRIPT_FASTTRACK_GRACE_MS', 40_000, 0, 5 * 60_000);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Per-task in-flight hold state for an acked dispatch:
|
|
79
|
+
// - liveConfirmedSinceAck: we have seen at least one conclusive read (idle OR generating) since
|
|
80
|
+
// the ack — proves the session is reachable, so a later read FAILURE is a genuine liveness loss
|
|
81
|
+
// rather than a node that was never reachable.
|
|
82
|
+
// - consecutiveReadFailures: streak of inconclusive read_chat results (death backstop (a)).
|
|
83
|
+
// - transcriptIdleSinceMs: the timestamp of the FIRST tick in the current continuous run of
|
|
84
|
+
// idle-with-final-assistant reads (ACKED-HOLD-IDLE-OVERTRUST fast-track). Cleared to undefined
|
|
85
|
+
// whenever the signal breaks (non-idle read, read failure, or no final assistant message), so a
|
|
86
|
+
// mid-turn idle blip never accumulates grace. When `now - transcriptIdleSinceMs` exceeds the
|
|
87
|
+
// fast-track grace the synth is promoted ahead of the death backstop.
|
|
88
|
+
export interface AckedHoldState {
|
|
89
|
+
liveConfirmedSinceAck: boolean;
|
|
90
|
+
consecutiveReadFailures: number;
|
|
91
|
+
transcriptIdleSinceMs?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// T2 (B2b): acked-hold state persistence. The Map below is a process-local CACHE;
|
|
95
|
+
// the SSOT is the mesh_inflight_hold table in MeshRuntimeStore. Every read goes
|
|
96
|
+
// read-through (Map miss → load from store, then cache), every mutation goes
|
|
97
|
+
// write-through (Map set → store upsert; Map delete → store delete). On daemon
|
|
98
|
+
// boot the reconcile loop rehydrates the Map from the store per-mesh the first
|
|
99
|
+
// time it touches that mesh (rehydrateAckedHoldsForMesh), so a hold established
|
|
100
|
+
// before a restart survives it — closing the duplicate-emit / drop window the
|
|
101
|
+
// PHASE-4 transcript synth backstop otherwise had to correct after the fact.
|
|
102
|
+
//
|
|
103
|
+
// Store row ↔ AckedHoldState mapping:
|
|
104
|
+
// hold_reason 'live'|'unconfirmed' ↔ liveConfirmedSinceAck (boolean)
|
|
105
|
+
// read_failure_count ↔ consecutiveReadFailures
|
|
106
|
+
// first_idle_since_ack ↔ transcriptIdleSinceMs (undefined ⇒ NULL)
|
|
107
|
+
// mesh_id = the owning mesh (for listByMesh / prune)
|
|
108
|
+
// held_at = ms the hold was first created (store-managed)
|
|
109
|
+
export const inFlightAckedHoldState = new Map<string, AckedHoldState>();
|
|
110
|
+
// Meshes whose store rows have already been rehydrated into the Map this process.
|
|
111
|
+
// A restart resets this set, so the first touch of each mesh reloads from disk.
|
|
112
|
+
const rehydratedHoldMeshes = new Set<string>();
|
|
113
|
+
|
|
114
|
+
export function inFlightSynthKey(meshId: string, taskId: string): string {
|
|
115
|
+
return `${meshId}::${taskId}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Extract the taskId back out of a `${meshId}::${taskId}` synth key. The meshId
|
|
119
|
+
// prefix can itself contain '::' only if the caller passed one (mesh ids are
|
|
120
|
+
// config-derived and never do), so split on the FIRST '::' and treat the remainder
|
|
121
|
+
// as the taskId.
|
|
122
|
+
function taskIdFromSynthKey(meshId: string, synthKey: string): string {
|
|
123
|
+
const prefix = `${meshId}::`;
|
|
124
|
+
return synthKey.startsWith(prefix) ? synthKey.slice(prefix.length) : synthKey;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function holdStore(): MeshRuntimeStore | undefined {
|
|
128
|
+
try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Read-through: Map hit returns the cached state; a miss consults the store and,
|
|
132
|
+
// when a row exists, hydrates the Map from it before returning. A store failure
|
|
133
|
+
// degrades to Map-only (returns undefined on a miss) — identical to the pre-T2
|
|
134
|
+
// in-memory behavior, never worse.
|
|
135
|
+
export function getHoldState(synthKey: string, meshId: string): AckedHoldState | undefined {
|
|
136
|
+
const cached = inFlightAckedHoldState.get(synthKey);
|
|
137
|
+
if (cached) return cached;
|
|
138
|
+
const store = holdStore();
|
|
139
|
+
if (!store) return undefined;
|
|
140
|
+
let row;
|
|
141
|
+
try { row = store.getInflightHold(taskIdFromSynthKey(meshId, synthKey)); } catch { return undefined; }
|
|
142
|
+
if (!row) return undefined;
|
|
143
|
+
const state: AckedHoldState = {
|
|
144
|
+
liveConfirmedSinceAck: row.holdReason === 'live',
|
|
145
|
+
consecutiveReadFailures: row.readFailureCount ?? 0,
|
|
146
|
+
...(row.firstIdleSinceAck !== null && row.firstIdleSinceAck !== undefined
|
|
147
|
+
? { transcriptIdleSinceMs: row.firstIdleSinceAck }
|
|
148
|
+
: {}),
|
|
149
|
+
};
|
|
150
|
+
inFlightAckedHoldState.set(synthKey, state);
|
|
151
|
+
return state;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Write-through: update the Map cache AND the store row. A store failure leaves the
|
|
155
|
+
// Map authoritative for this process (degrade, never crash the tick).
|
|
156
|
+
export function setHoldState(synthKey: string, meshId: string, state: AckedHoldState): void {
|
|
157
|
+
inFlightAckedHoldState.set(synthKey, state);
|
|
158
|
+
const store = holdStore();
|
|
159
|
+
if (!store) return;
|
|
160
|
+
try {
|
|
161
|
+
store.upsertInflightHold({
|
|
162
|
+
taskId: taskIdFromSynthKey(meshId, synthKey),
|
|
163
|
+
meshId,
|
|
164
|
+
holdReason: state.liveConfirmedSinceAck ? 'live' : 'unconfirmed',
|
|
165
|
+
firstIdleSinceAck: state.transcriptIdleSinceMs ?? null,
|
|
166
|
+
readFailureCount: state.consecutiveReadFailures,
|
|
167
|
+
});
|
|
168
|
+
} catch { /* degrade to Map-only */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Write-through delete: drop the Map entry AND the store row.
|
|
172
|
+
export function deleteHoldState(synthKey: string, meshId: string): void {
|
|
173
|
+
inFlightAckedHoldState.delete(synthKey);
|
|
174
|
+
const store = holdStore();
|
|
175
|
+
if (!store) return;
|
|
176
|
+
try { store.deleteInflightHold(taskIdFromSynthKey(meshId, synthKey)); } catch { /* degrade */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Restart rehydration: on the first touch of a mesh this process, pull its persisted
|
|
180
|
+
// acked-hold rows from the store into the Map cache so a hold that outlived a daemon
|
|
181
|
+
// restart is honored again. Idempotent per process via rehydratedHoldMeshes. A store
|
|
182
|
+
// failure just skips rehydration (Map starts empty for the mesh — pre-T2 behavior).
|
|
183
|
+
export function rehydrateAckedHoldsForMesh(meshId: string): void {
|
|
184
|
+
if (rehydratedHoldMeshes.has(meshId)) return;
|
|
185
|
+
rehydratedHoldMeshes.add(meshId);
|
|
186
|
+
const store = holdStore();
|
|
187
|
+
if (!store) return;
|
|
188
|
+
let rows;
|
|
189
|
+
try { rows = store.listInflightHoldsByMesh(meshId); } catch { return; }
|
|
190
|
+
for (const row of rows) {
|
|
191
|
+
const synthKey = inFlightSynthKey(meshId, row.taskId);
|
|
192
|
+
if (inFlightAckedHoldState.has(synthKey)) continue; // a live tick already set fresher state
|
|
193
|
+
inFlightAckedHoldState.set(synthKey, {
|
|
194
|
+
liveConfirmedSinceAck: row.holdReason === 'live',
|
|
195
|
+
consecutiveReadFailures: row.readFailureCount ?? 0,
|
|
196
|
+
...(row.firstIdleSinceAck !== null && row.firstIdleSinceAck !== undefined
|
|
197
|
+
? { transcriptIdleSinceMs: row.firstIdleSinceAck }
|
|
198
|
+
: {}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (rows.length > 0) {
|
|
202
|
+
LOG.info('MeshReconcile', `Rehydrated ${rows.length} persisted acked-hold row(s) for mesh ${meshId} after (re)start`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Collect the union of held synth keys for a mesh — the Map cache entries plus every
|
|
207
|
+
// persisted store row — so the PHASE-4 prune can drop a hold that exists ONLY on disk
|
|
208
|
+
// (not yet cached). A store failure degrades to the Map-only key set (never throws).
|
|
209
|
+
export function collectHeldSynthKeysForMesh(meshId: string): Set<string> {
|
|
210
|
+
const heldKeys = new Set<string>();
|
|
211
|
+
for (const key of inFlightAckedHoldState.keys()) {
|
|
212
|
+
if (key.startsWith(`${meshId}::`)) heldKeys.add(key);
|
|
213
|
+
}
|
|
214
|
+
const store = holdStore();
|
|
215
|
+
if (store) {
|
|
216
|
+
try {
|
|
217
|
+
for (const row of store.listInflightHoldsByMesh(meshId)) {
|
|
218
|
+
heldKeys.add(inFlightSynthKey(meshId, row.taskId));
|
|
219
|
+
}
|
|
220
|
+
} catch { /* degrade — prune only what's in the Map */ }
|
|
221
|
+
}
|
|
222
|
+
return heldKeys;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Test hook: clear the in-flight acked-hold state between cases (both the Map cache
|
|
226
|
+
// and the per-mesh rehydrate guard, so each case starts from a clean read-through).
|
|
227
|
+
export function __resetReconcileInFlightSynthDebounceForTests(): void {
|
|
228
|
+
inFlightAckedHoldState.clear();
|
|
229
|
+
rehydratedHoldMeshes.clear();
|
|
230
|
+
}
|