@adhdev/daemon-core 1.0.28-rc.10 → 1.0.28-rc.11

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.
@@ -1,23 +1,50 @@
1
1
  /**
2
2
  * RF-ROUTER MED family — coordinator-triggered daemon restart.
3
3
  *
4
- * restart_daemon_node exposes the existing dashboard "preview update" path
5
- * (low-family daemon_upgrade: update-to-latest-on-channel + detached restart) as
6
- * a mesh command, so a coordinator can roll a worker daemon onto a freshly
7
- * deployed version without a manual restart round-trip. It mirrors
8
- * fast_forward_mesh_node's remote-forward shape — resolve the target node, and
9
- * if it belongs to a remote daemon forward the command there so the owning
10
- * daemon (not the coordinator) restarts itself — and adds an idle-gate: a node
11
- * with a generating / waiting_approval / starting session is refused so an
12
- * in-flight turn is never killed mid-restart.
4
+ * restart_daemon_node exposes the daemon lifecycle paths (low-family
5
+ * daemon_upgrade / daemon_restart) as a mesh command, so a coordinator can roll
6
+ * a worker daemon onto a freshly deployed version or just reset its state —
7
+ * without a manual restart round-trip. It mirrors fast_forward_mesh_node's
8
+ * remote-forward shape — resolve the target node, and if it belongs to a remote
9
+ * daemon forward the command there so the owning daemon (not the coordinator)
10
+ * restarts itself — and adds an idle-gate: a node with a generating /
11
+ * waiting_approval / starting session is refused so an in-flight turn is never
12
+ * killed mid-restart.
13
13
  *
14
- * v1 reuses daemon_upgrade verbatim rather than adding a restart-only path:
15
- * the goal is "pick up a just-deployed version", which inherently needs the
16
- * npm reinstall the upgrade helper already performs. Already-latest is a no-op
17
- * (no restart), matching the dashboard button.
14
+ * Modes and gate overrides (all opt-in; a bare call behaves exactly like v1):
15
+ *
16
+ * - mode: 'upgrade' (default) reuses daemon_upgrade verbatim update to the
17
+ * latest published version on the resolved channel, then detached-restart.
18
+ * Already-latest is a no-op (no restart), matching the dashboard button.
19
+ * mode: 'restart' uses daemon_restart — a pure re-spawn with no npm
20
+ * reinstall, so it restarts even when already latest and the downtime is
21
+ * just the detached re-spawn. Use it to reset wedged daemon state.
22
+ *
23
+ * - selfOnly: waives blocking sessions that are THIS mesh's own coordinator
24
+ * session (settings.meshCoordinatorFor === meshId). This breaks the
25
+ * structural deadlock where the coordinator can never restart its own
26
+ * daemon because its calling turn is always 'generating'. Other nodes'
27
+ * blocking sessions still refuse. Ad-hoc coordinator sessions launched
28
+ * without mesh_coordinator_launch carry no marker — use force for those.
29
+ *
30
+ * - force: bypasses the idle-gate entirely. Destructive: in-flight turns are
31
+ * killed and the in-memory pendingOutboundQueue is lost (it has no
32
+ * persistence/restore path). Audit-logged.
33
+ *
34
+ * - whenIdle: instead of refusing, schedule the restart and run it
35
+ * automatically once all blocking sessions go idle (the safest path — the
36
+ * pendingOutboundQueue is empty at that point). The schedule expires after
37
+ * timeoutMs (default 30 min). cancelWhenIdle cancels it; every response
38
+ * carries the current schedule under `deferredRestart` for visibility.
39
+ *
40
+ * - killSessionHost: also stop the session-host process, destroying EVERY
41
+ * hosted CLI session (no idle-gate — SessionHostServer.stop kills all PTYs).
42
+ * Mirrors what Windows already does on upgrade (conpty.node lock). Default
43
+ * off; POSIX daemons otherwise keep hosted sessions alive across a restart.
18
44
  */
19
45
  import { daemonIdsEquivalent, meshNodeIdMatches } from '@adhdev/mesh-shared';
20
46
  import { daemonLifecycleHandlers } from '../low-family/daemon-lifecycle.js';
47
+ import { LOG } from '../../logging/logger.js';
21
48
  import type { CommandRouterResult } from '../router.js';
22
49
  import type { MedFamilyContext, MedFamilyHandler } from './types.js';
23
50
 
@@ -26,18 +53,167 @@ import type { MedFamilyContext, MedFamilyHandler } from './types.js';
26
53
  // daemon-cloud mandatory-update idle-gate (hasBlockingSessionsForMandatoryUpdate).
27
54
  const RESTART_BLOCKING_STATES = new Set(['generating', 'waiting_approval', 'starting']);
28
55
 
29
- function hasBlockingSessions(ctx: MedFamilyContext): boolean {
56
+ const DEFERRED_RESTART_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
57
+ const DEFERRED_RESTART_MAX_TIMEOUT_MS = 6 * 60 * 60 * 1000;
58
+ const DEFERRED_RESTART_POLL_MS = 10_000;
59
+
60
+ type BlockingSession = {
61
+ instanceId: string | null;
62
+ status: string;
63
+ /** True when this session is the calling mesh's own coordinator session. */
64
+ selfCoordinator: boolean;
65
+ };
66
+
67
+ function collectBlockingSessions(ctx: MedFamilyContext, meshId: string): BlockingSession[] {
68
+ const blocking: BlockingSession[] = [];
30
69
  const states = ctx.deps.instanceManager.collectAllStates();
70
+ const consider = (state: any) => {
71
+ const status = String(state?.status || '');
72
+ if (!RESTART_BLOCKING_STATES.has(status)) return;
73
+ blocking.push({
74
+ instanceId: typeof state?.instanceId === 'string' ? state.instanceId : null,
75
+ status,
76
+ // The coordinator marker is stamped by mesh_coordinator_launch and
77
+ // re-stamped on restore (cli-manager.restoreHostedSessions). Ad-hoc
78
+ // coordinator sessions have no marker and are NOT waived by selfOnly.
79
+ selfCoordinator: !!meshId && state?.settings?.meshCoordinatorFor === meshId,
80
+ });
81
+ };
31
82
  for (const state of states) {
32
- if (RESTART_BLOCKING_STATES.has(String(state.status || ''))) return true;
83
+ consider(state);
33
84
  const childStates = 'extensions' in state && Array.isArray((state as any).extensions)
34
85
  ? (state as any).extensions
35
86
  : [];
36
- for (const child of childStates) {
37
- if (RESTART_BLOCKING_STATES.has(String(child?.status || ''))) return true;
38
- }
87
+ for (const child of childStates) consider(child);
88
+ }
89
+ return blocking;
90
+ }
91
+
92
+ type RestartMode = 'upgrade' | 'restart';
93
+
94
+ type PendingDeferredRestart = {
95
+ meshId: string;
96
+ nodeId: string;
97
+ mode: RestartMode;
98
+ killSessionHost: boolean;
99
+ scheduledAt: number;
100
+ expiresAt: number;
101
+ timer: NodeJS.Timeout;
102
+ };
103
+
104
+ // One scheduled restart per daemon process. The record lives on the OWNING
105
+ // daemon (the command is forwarded there before scheduling), which is also the
106
+ // process whose session states the poll inspects.
107
+ let pendingDeferredRestart: PendingDeferredRestart | null = null;
108
+
109
+ function deferredRestartInfo(): Record<string, unknown> | null {
110
+ if (!pendingDeferredRestart) return null;
111
+ return {
112
+ meshId: pendingDeferredRestart.meshId,
113
+ nodeId: pendingDeferredRestart.nodeId,
114
+ mode: pendingDeferredRestart.mode,
115
+ killSessionHost: pendingDeferredRestart.killSessionHost,
116
+ scheduledAt: new Date(pendingDeferredRestart.scheduledAt).toISOString(),
117
+ expiresAt: new Date(pendingDeferredRestart.expiresAt).toISOString(),
118
+ runCondition: 'executes automatically once no session is generating / waiting_approval / starting',
119
+ };
120
+ }
121
+
122
+ function clearPendingDeferredRestart(): void {
123
+ if (pendingDeferredRestart) clearInterval(pendingDeferredRestart.timer);
124
+ pendingDeferredRestart = null;
125
+ }
126
+
127
+ function normalizeRestartMode(value: unknown): RestartMode {
128
+ return value === 'restart' ? 'restart' : 'upgrade';
129
+ }
130
+
131
+ function restartWarnings(args: { killSessionHost: boolean; forced: boolean }): string[] {
132
+ const warnings: string[] = [];
133
+ if (args.forced) {
134
+ warnings.push('forced restart over active sessions — in-flight turns were interrupted and the in-memory pendingOutboundQueue is permanently lost (no persistence/restore path).');
135
+ }
136
+ if (args.killSessionHost) {
137
+ warnings.push('killSessionHost: the session-host process was stopped — ALL hosted CLI sessions on this machine are destroyed (hard refresh).');
138
+ }
139
+ if (process.platform === 'win32') {
140
+ warnings.push('Windows: the daemon restart/upgrade path stops the session-host regardless of options — all hosted sessions terminate.');
141
+ } else {
142
+ warnings.push('POSIX: hosted PTY sessions survive a plain daemon restart and rebind on next boot (unless killSessionHost was set).');
143
+ }
144
+ return warnings;
145
+ }
146
+
147
+ async function executeRestart(ctx: MedFamilyContext, args: any, opts: { forced: boolean }): Promise<CommandRouterResult> {
148
+ const mode = normalizeRestartMode(args?.mode);
149
+ const killSessionHost = args?.killSessionHost === true;
150
+ const result = mode === 'restart'
151
+ ? await daemonLifecycleHandlers.daemon_restart({ deps: ctx.deps }, { killSessionHost })
152
+ : await daemonLifecycleHandlers.daemon_upgrade({ deps: ctx.deps }, args);
153
+ const restarting = (result as any)?.restarting === true;
154
+ return {
155
+ ...result,
156
+ mode,
157
+ restarted: restarting,
158
+ ...(restarting ? {
159
+ warnings: restartWarnings({ killSessionHost, forced: opts.forced }),
160
+ // MCP IPC has no automatic reconnect/retry — tell the caller when to
161
+ // talk to this daemon again.
162
+ clientHint: 'daemon restarting — retry your next call in ~10s',
163
+ } : {}),
164
+ } as CommandRouterResult;
165
+ }
166
+
167
+ function scheduleDeferredRestart(ctx: MedFamilyContext, args: any, meshId: string, nodeId: string): CommandRouterResult {
168
+ clearPendingDeferredRestart();
169
+ const requestedTimeout = typeof args?.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)
170
+ ? args.timeoutMs
171
+ : DEFERRED_RESTART_DEFAULT_TIMEOUT_MS;
172
+ const timeoutMs = Math.min(Math.max(requestedTimeout, DEFERRED_RESTART_POLL_MS), DEFERRED_RESTART_MAX_TIMEOUT_MS);
173
+ const record: PendingDeferredRestart = {
174
+ meshId,
175
+ nodeId,
176
+ mode: normalizeRestartMode(args?.mode),
177
+ killSessionHost: args?.killSessionHost === true,
178
+ scheduledAt: Date.now(),
179
+ expiresAt: Date.now() + timeoutMs,
180
+ timer: setInterval(() => { void deferredRestartTick(ctx); }, DEFERRED_RESTART_POLL_MS),
181
+ };
182
+ record.timer.unref?.();
183
+ pendingDeferredRestart = record;
184
+ LOG.info('MeshRestart', `Deferred restart scheduled for node ${nodeId} (mode=${record.mode}, expires in ${Math.round(timeoutMs / 60000)}min)`);
185
+ return {
186
+ success: true,
187
+ restarted: false,
188
+ scheduled: true,
189
+ code: 'restart_scheduled_when_idle',
190
+ reason: 'Restart scheduled — it will execute automatically as soon as no session on this daemon is generating / waiting_approval / starting. Running at an idle point also avoids pendingOutboundQueue loss, making this the safest restart path.',
191
+ deferredRestart: deferredRestartInfo(),
192
+ } as CommandRouterResult;
193
+ }
194
+
195
+ async function deferredRestartTick(ctx: MedFamilyContext): Promise<void> {
196
+ const record = pendingDeferredRestart;
197
+ if (!record) return;
198
+ if (Date.now() >= record.expiresAt) {
199
+ LOG.warn('MeshRestart', `Deferred restart for node ${record.nodeId} expired without reaching an idle point; dropping the schedule`);
200
+ clearPendingDeferredRestart();
201
+ return;
202
+ }
203
+ const blocking = collectBlockingSessions(ctx, record.meshId);
204
+ if (blocking.length > 0) return;
205
+ LOG.info('MeshRestart', `Deferred restart for node ${record.nodeId} executing — daemon is idle`);
206
+ clearPendingDeferredRestart();
207
+ try {
208
+ await executeRestart(ctx, {
209
+ meshId: record.meshId,
210
+ nodeId: record.nodeId,
211
+ mode: record.mode,
212
+ killSessionHost: record.killSessionHost,
213
+ }, { forced: false });
214
+ } catch (e: any) {
215
+ LOG.error('MeshRestart', `Deferred restart execution failed: ${e?.message || String(e)}`);
39
216
  }
40
- return false;
41
217
  }
42
218
 
43
219
  export const meshRestartHandlers: Record<string, MedFamilyHandler> = {
@@ -69,24 +245,51 @@ export const meshRestartHandlers: Record<string, MedFamilyHandler> = {
69
245
  return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
70
246
  }
71
247
 
248
+ // Deferred-schedule management on the owning daemon.
249
+ if (args?.cancelWhenIdle === true) {
250
+ const had = pendingDeferredRestart !== null;
251
+ clearPendingDeferredRestart();
252
+ return { success: true, restarted: false, cancelled: had, deferredRestart: null } as CommandRouterResult;
253
+ }
254
+ if (args?.whenIdleStatus === true) {
255
+ return { success: true, restarted: false, deferredRestart: deferredRestartInfo() } as CommandRouterResult;
256
+ }
257
+
72
258
  // Idle-gate: refuse if any session on THIS daemon is mid-turn / awaiting
73
- // approval / starting. The coordinator restarts other (idle) nodes freely;
74
- // restarting the coordinator's OWN daemon is naturally refused while its
75
- // calling turn is 'generating' (accepted v1 limitation — call other nodes
76
- // first, the coordinator last when it has gone idle).
77
- if (hasBlockingSessions(ctx)) {
259
+ // approval / starting unless an explicit opt-in waives it.
260
+ const blocking = collectBlockingSessions(ctx, meshId);
261
+ if (blocking.length > 0) {
262
+ if (args?.force === true) {
263
+ // Bypass the gate entirely. Audit-logged: this kills in-flight
264
+ // turns and drops the unpersisted pendingOutboundQueue.
265
+ LOG.warn('MeshRestart', `force restart over ${blocking.length} blocking session(s): ${blocking.map((b) => `${b.instanceId || '?'}(${b.status})`).join(', ')} — pendingOutboundQueue will be lost`);
266
+ return executeRestart(ctx, args, { forced: true });
267
+ }
268
+ const foreignBlocking = blocking.filter((b) => !b.selfCoordinator);
269
+ if (args?.selfOnly === true && foreignBlocking.length === 0) {
270
+ // Only the calling mesh's own coordinator session is blocking —
271
+ // the structural self-deadlock case. Waive exactly those.
272
+ LOG.info('MeshRestart', `selfOnly restart: waiving ${blocking.length} self-coordinator session(s) for mesh ${meshId}`);
273
+ return executeRestart(ctx, args, { forced: false });
274
+ }
275
+ if (args?.whenIdle === true) {
276
+ return scheduleDeferredRestart(ctx, args, meshId, nodeId);
277
+ }
78
278
  return {
79
279
  success: false,
80
280
  restarted: false,
81
281
  code: 'blocking_sessions',
82
282
  reason: 'Daemon has an active session (generating / waiting_approval / starting); restart refused to avoid interrupting in-flight work. Retry when the node is idle.',
83
- };
283
+ blockingSessions: blocking,
284
+ options: {
285
+ selfOnly: 'waive only this mesh\'s own coordinator session (settings.meshCoordinatorFor === meshId)',
286
+ force: 'bypass the idle-gate entirely — kills in-flight turns and loses the unpersisted pendingOutboundQueue',
287
+ whenIdle: 'schedule the restart to run automatically once the daemon goes idle (safest)',
288
+ },
289
+ deferredRestart: deferredRestartInfo(),
290
+ } as CommandRouterResult;
84
291
  }
85
292
 
86
- // Reuse the battle-tested dashboard "preview update" path: update to the
87
- // latest published version on the resolved channel, then detached-restart.
88
- // Already-latest is a no-op (no restart), matching the dashboard button.
89
- const result = await daemonLifecycleHandlers.daemon_upgrade({ deps: ctx.deps }, args);
90
- return { ...result, restarted: (result as any)?.restarting === true };
293
+ return executeRestart(ctx, args, { forced: false });
91
294
  },
92
295
  };
@@ -39,6 +39,20 @@ export interface DaemonUpgradeHelperPayload {
39
39
  restartArgv: string[];
40
40
  cwd?: string;
41
41
  sessionHostAppName?: string;
42
+ /**
43
+ * Restart-only mode: wait for the parent daemon to exit, then re-spawn it
44
+ * without running any npm install. Used by daemon_restart (mesh
45
+ * restart_daemon_node mode="restart") to reset daemon state (memory leaks,
46
+ * zombie sessions, wedged internals) with minimal downtime.
47
+ */
48
+ skipInstall?: boolean;
49
+ /**
50
+ * Opt-in hard refresh: also stop the session-host process, which destroys
51
+ * EVERY hosted CLI session (no idle-gate — see SessionHostServer.stop).
52
+ * Mirrors what Windows already does unconditionally on upgrade (conpty.node
53
+ * lock). Default off: POSIX leaves the host running so sessions rebind.
54
+ */
55
+ killSessionHost?: boolean;
42
56
  }
43
57
 
44
58
  export interface CurrentGlobalInstallSurface {
@@ -605,12 +619,25 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
605
619
  // host running on POSIX; on the next boot `ensureSessionHostReady()` reuses
606
620
  // the still-listening socket and `cliManager.restoreHostedSessions()` rebinds
607
621
  // the live runtimes. Windows keeps the kill unchanged.
608
- if (process.platform === 'win32') {
622
+ // killSessionHost is an explicit opt-in hard refresh (mesh restart_daemon_node
623
+ // kill_session_host) that forces the same teardown on any platform.
624
+ if (process.platform === 'win32' || payload.killSessionHost === true) {
609
625
  await stopSessionHostProcesses(sessionHostAppName);
610
626
  } else {
611
627
  appendUpgradeLog('POSIX — session-host left running (survives upgrade; sessions rebind on next boot)');
612
628
  }
613
629
  removeDaemonPidFile();
630
+
631
+ // Restart-only mode (daemon_restart): the parent has exited and the pid file
632
+ // is gone — re-spawn the daemon as-is. No npm install, no prefix rotation, so
633
+ // there is no Windows lock hazard and downtime is just the re-spawn.
634
+ if (payload.skipInstall) {
635
+ appendUpgradeLog('Restart-only mode — package install skipped, re-spawning daemon');
636
+ spawnDetachedDaemonRestart(restartArgv, payload.cwd);
637
+ try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
638
+ return;
639
+ }
640
+
614
641
  // Scope the Windows atomic-upgrade layout to THIS daemon's instance so
615
642
  // `adhdev-preview update` rotates only the preview prefix/pointer/tools tree
616
643
  // and never touches the stable install (and vice-versa). Stable / no-override
@@ -758,6 +785,11 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
758
785
  appendUpgradeLog('Post-install staging cleanup complete');
759
786
  }
760
787
 
788
+ spawnDetachedDaemonRestart(restartArgv, payload.cwd);
789
+ try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
790
+ }
791
+
792
+ function spawnDetachedDaemonRestart(restartArgv: string[], cwd?: string): void {
761
793
  if (restartArgv.length > 0) {
762
794
  const env = { ...process.env };
763
795
  delete env[UPGRADE_HELPER_ENV];
@@ -766,14 +798,13 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
766
798
  detached: true,
767
799
  stdio: 'ignore',
768
800
  windowsHide: true,
769
- cwd: payload.cwd || process.cwd(),
801
+ cwd: cwd || process.cwd(),
770
802
  env,
771
803
  });
772
804
  child.unref();
773
805
  } else {
774
806
  appendUpgradeLog('No restart argv provided; upgrade completed without restart');
775
807
  }
776
- try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
777
808
  }
778
809
 
779
810
  export async function maybeRunDaemonUpgradeHelperFromEnv(): Promise<boolean> {
@@ -18,6 +18,7 @@ import { shortHash } from '../system/hash.js';
18
18
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
19
19
  import type { MeshSendKeyItem, MeshSendKeyName } from '../cli-adapters/provider-cli-shared.js';
20
20
  import { resolveTranscriptAuthorityProfile } from './transcript-evidence.js';
21
+ import { TranscriptSignalSource } from './transcript-signal-source.js';
21
22
  import { createCliAdapter } from './spec/route.js';
22
23
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
23
24
  import { StatusMonitor } from './status-monitor.js';
@@ -1426,6 +1427,10 @@ export class CliProviderInstance implements ProviderInstance {
1426
1427
  private completedDebounceTimer: NodeJS.Timeout | null = null;
1427
1428
  private completedDebouncePending: CompletedDebouncePending | null = null;
1428
1429
  private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
1430
+ /** TX-FSM Stage 0 (shadow): lazily-created transcript signal normalizer.
1431
+ * Fed ONLY by transcript reads this instance already performs — it adds
1432
+ * zero I/O and its output never feeds back into any verdict. */
1433
+ private transcriptSignalSource: TranscriptSignalSource | null = null;
1429
1434
  /**
1430
1435
  * The final assistant summary of the last completed turn, cached at
1431
1436
  * completion-emit time. For a native-source provider (antigravity) whose
@@ -1649,6 +1654,7 @@ export class CliProviderInstance implements ProviderInstance {
1649
1654
  });
1650
1655
  if (restoredHistory.source !== 'provider-native') {
1651
1656
  this.lastExternalCompletionProbe = null;
1657
+ this.publishTranscriptSignalObservation(null);
1652
1658
  return null;
1653
1659
  }
1654
1660
  this.lastExternalCompletionProbe = buildExternalTranscriptProbe(
@@ -1656,9 +1662,49 @@ export class CliProviderInstance implements ProviderInstance {
1656
1662
  restoredHistory.sourcePath,
1657
1663
  restoredHistory.sourceMtimeMs,
1658
1664
  );
1665
+ this.publishTranscriptSignalObservation(restoredHistory.messages);
1659
1666
  return restoredHistory.messages;
1660
1667
  }
1661
1668
 
1669
+ /**
1670
+ * TX-FSM Stage 0 (shadow): normalize the transcript read that JUST
1671
+ * happened into a SignalSnapshot and inject it into the FSM driver as a
1672
+ * pure observation (daemon → SpecCliAdapter → FsmDriver). Fed ONLY by
1673
+ * reads this method's caller already performs — it adds zero I/O, so the
1674
+ * getState() zero-native-read invariant and the stall-path read cadence
1675
+ * are untouched. The snapshot is shadow data: nothing on this path feeds
1676
+ * back into completion/stall/redrive verdicts. Fail-open end to end — a
1677
+ * missing adapter hook (non-spec providers), an unresolved transcript, or
1678
+ * any throw degrades to "no observation", never to a wedge.
1679
+ */
1680
+ private publishTranscriptSignalObservation(messages: unknown[] | null, error = false): void {
1681
+ const adapter = this.adapter as { setSignalObservation?: (snapshot: unknown) => void } | null | undefined;
1682
+ if (typeof adapter?.setSignalObservation !== 'function') return; // non-spec path: no FSM consumer
1683
+ try {
1684
+ if (!this.transcriptSignalSource) {
1685
+ this.transcriptSignalSource = new TranscriptSignalSource({
1686
+ label: this.type,
1687
+ // Choke point: class/timing come from the P0 profile
1688
+ // resolver, never from raw predicates or provider names.
1689
+ profile: resolveTranscriptAuthorityProfile(this.provider),
1690
+ turnStartedAt: () => {
1691
+ const t = (this.adapter as any)?.currentTurnStartedAt;
1692
+ return typeof t === 'number' && Number.isFinite(t) ? t : undefined;
1693
+ },
1694
+ // Reuse the exact completion machinery (I1) for the
1695
+ // final_assistant_present signal rather than duplicating
1696
+ // the message scan.
1697
+ finalAssistantPresent: (msgs, ts) => this.completionHasFinalAssistantMessage(msgs, ts),
1698
+ growthQuietMs: MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS,
1699
+ });
1700
+ }
1701
+ const snapshot = this.transcriptSignalSource.update(
1702
+ { messages, probe: this.lastExternalCompletionProbe, error },
1703
+ );
1704
+ adapter.setSignalObservation(snapshot);
1705
+ } catch { /* shadow-only: signal collection must never break the read path */ }
1706
+ }
1707
+
1662
1708
  /**
1663
1709
  * The content of the LAST visible assistant bubble in a message list, or ''
1664
1710
  * when the tail is not an assistant reply. Skips trailing system/tool/activity
@@ -864,6 +864,16 @@ export class SpecCliAdapter implements CliAdapter {
864
864
  }
865
865
  refreshProviderDefinition(): void { /* hot reload handled by SpecDriver fs.watch */ }
866
866
 
867
+ /**
868
+ * TX-FSM Stage 0 (shadow): forward the daemon's normalized signal
869
+ * observation into the FSM driver. Observation-only — failures here must
870
+ * never break the adapter, and the driver treats the envelope as a pure
871
+ * injected value (no reads cross the engine boundary).
872
+ */
873
+ setSignalObservation(snapshot: import('./signal-envelope.js').SignalSnapshot | null): void {
874
+ try { this.driver.setSignalObservation?.(snapshot); } catch { /* shadow-only: never break the adapter */ }
875
+ }
876
+
867
877
  private handleEvent(ev: DashboardEvent): void {
868
878
  this.lastEvent = ev;
869
879
  switch (ev.kind) {
@@ -30,6 +30,7 @@ import {
30
30
  type ResolvedSection, type TraceEntry,
31
31
  } from './evaluator.js';
32
32
  import { evaluateFsm, stableRegionKey, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
33
+ import type { SignalSnapshot } from './signal-envelope.js';
33
34
  import {
34
35
  type CliSpecV4, type FsmState, type FsmTransition,
35
36
  initialState, stateById, statusForState, modalKindForState, outgoingTransitions,
@@ -144,6 +145,14 @@ export interface ISpecDriver {
144
145
  getFsmDebug?(): unknown;
145
146
  getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
146
147
  getEventTimeline?(limit?: number): ReadonlyArray<SpecPtyEvent>;
148
+ /**
149
+ * TX-FSM Stage 0 (shadow): inject the daemon-normalized signal observation.
150
+ * Observation ONLY — the driver receives the envelope, never a reader; all
151
+ * file discovery / session pinning / parsing stays daemon-side so the FSM
152
+ * engine remains a generic PTY engine. The snapshot feeds the shadow
153
+ * verdict of `signal` conditions exclusively; it cannot gate a transition.
154
+ */
155
+ setSignalObservation?(snapshot: SignalSnapshot | null): void;
147
156
  }
148
157
 
149
158
  export interface SpecDriverOpts {
@@ -367,6 +376,15 @@ export class FsmDriver implements ISpecDriver {
367
376
  * transition — the rich pre-transition table that lastFsmEval only keeps
368
377
  * for the single most recent evaluation. Separate from stateHistory. */
369
378
  private fsmSnapshotHistory: FsmSnapshotEntry[] = [];
379
+ /** TX-FSM Stage 0 (shadow): the latest daemon-injected signal observation.
380
+ * Read by evalFsmNow for the shadow verdict of `signal` conditions ONLY —
381
+ * the Stage-0 pass-through in the evaluator means it can never alter
382
+ * which transition fires. */
383
+ private signalObservation: SignalSnapshot | null = null;
384
+ /** Per-transition last-logged shadow divergence (`${from}→${to}` → whether
385
+ * the shadow verdict currently disagrees with the real one), so the
386
+ * shadow log emits on FLIP only, not every frame. */
387
+ private shadowDivergenceLast = new Map<string, boolean>();
370
388
 
371
389
  constructor(private readonly opts: SpecDriverOpts) {
372
390
  this.loadSpecOrThrow();
@@ -451,6 +469,17 @@ export class FsmDriver implements ISpecDriver {
451
469
  this.adapter.updateMeta(meta, replace);
452
470
  }
453
471
 
472
+ /**
473
+ * TX-FSM Stage 0 (shadow): receive the daemon-normalized signal
474
+ * observation. The driver stores it verbatim — it does NOT poll, parse,
475
+ * or read anything itself (generic-PTY-engine boundary). The observation
476
+ * only feeds the shadow verdict of `signal` conditions; the evaluator's
477
+ * Stage-0 pass-through guarantees it cannot change a transition.
478
+ */
479
+ setSignalObservation(snapshot: SignalSnapshot | null): void {
480
+ this.signalObservation = snapshot ?? null;
481
+ }
482
+
454
483
  snapshot(): string { return this.adapter.snapshot(); }
455
484
  getCursorPosition(): { row: number; col: number } { return this.adapter.getCursorPosition(); }
456
485
  getScreen(): string { return this.adapter.snapshot(); }
@@ -643,7 +672,38 @@ export class FsmDriver implements ISpecDriver {
643
672
 
644
673
  private evalFsmNow(screen: string, cursor: { row: number; col: number }, now: number) {
645
674
  const prev = this.prevScreenLines.length > 0 ? this.prevScreenLines : undefined;
646
- return evaluateFsm(this.spec, this.currentStateId, screen, cursor, prev, this.buildClock(now));
675
+ return evaluateFsm(this.spec, this.currentStateId, screen, cursor, prev, this.buildClock(now), this.signalObservation);
676
+ }
677
+
678
+ /**
679
+ * TX-FSM Stage 0 (shadow): compare each signal-guarded transition's
680
+ * counterfactual verdict against the real (PTY-only) one and log on FLIP.
681
+ * This is the "만약 적용했다면" half of the shadow log — the Stage 1-3
682
+ * evidence for whether signal gating would have changed any verdict, and
683
+ * in which direction. Read-only: runs after the evaluation is complete
684
+ * and never feeds back into it.
685
+ */
686
+ private logShadowDivergence(ev: FsmEvaluation): void {
687
+ for (const t of ev.transitions) {
688
+ if (!t.shadow) continue;
689
+ const key = `${this.currentStateId}→${t.to}`;
690
+ const diverges = t.shadow.fires !== t.fires;
691
+ if ((this.shadowDivergenceLast.get(key) ?? false) === diverges) continue;
692
+ this.shadowDivergenceLast.set(key, diverges);
693
+ if (diverges) {
694
+ LOG.info(
695
+ 'FsmDriver',
696
+ `[${this.specTag()}] [shadow] ${key} (${t.label}): real fires=${t.fires}`
697
+ + ` but signal-gated verdict would be fires=${t.shadow.fires}`
698
+ + ` (shadowCond=${t.shadow.condResult}${t.shadow.unknown ? ', signal unknown/fail-open' : ''})`,
699
+ );
700
+ } else {
701
+ LOG.info(
702
+ 'FsmDriver',
703
+ `[${this.specTag()}] [shadow] ${key} (${t.label}): shadow verdict realigned with real fires=${t.fires}`,
704
+ );
705
+ }
706
+ }
647
707
  }
648
708
 
649
709
  private reevaluate(forceEmit = false): void {
@@ -659,6 +719,7 @@ export class FsmDriver implements ISpecDriver {
659
719
  const ev = this.evalFsmNow(screen, cursor, now);
660
720
  this.lastFsmEval = ev;
661
721
  this.prevScreenLines = currentLines;
722
+ this.logShadowDivergence(ev);
662
723
 
663
724
  if (ev.fired) {
664
725
  this.commitTransition(ev.fired, now, ev);