@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.
@@ -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). Off (the
60
- * default) preserves the accept-and-warn rollout behaviour exactly.
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
- * its activation is a deliberate operational step taken ONLY after daemonBuilds
64
- * confirms every node emits v2 (§배포 게이트 1 / risk §4). So the code default is
65
- * OFF; flipping the env back to accept mode is a pure-env rollback (no code change,
66
- * no data migration the schema is additive). Read at call time so a test /
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
- declare const meshV2BackstopCounters: {
3
- /** PHASE-4 transcript synthesis actually reconciled a missing completion. */
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
- type PersistableCliHistoryMessage = {
13
- role: string;
14
- content: string;
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.463",
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.463",
51
- "@adhdev/session-host-core": "0.9.82-rc.463",
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). Off (the
142
- * default) preserves the accept-and-warn rollout behaviour exactly.
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
- * its activation is a deliberate operational step taken ONLY after daemonBuilds
146
- * confirms every node emits v2 (§배포 게이트 1 / risk §4). So the code default is
147
- * OFF; flipping the env back to accept mode is a pure-env rollback (no code change,
148
- * no data migration the schema is additive). Read at call time so a test /
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 false;
164
+ if (!raw) return true; // unset/blank = default ON
163
165
  const v = raw.trim().toLowerCase();
164
- return v === '1' || v === 'true' || v === 'on' || v === 'yes';
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 dispatchedBy = hint?.dispatchedBy ?? coordinatorIdentityFromEmitFields({
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
- const intendedFor: CoordinatorIdentity | undefined = hint?.intendedFor ?? dispatchedBy;
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
- scope: hint?.scope,
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
+ }