@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.
- package/dist/cli-adapter-types.d.ts +7 -0
- package/dist/commands/upgrade-helper.d.ts +14 -0
- package/dist/index.js +519 -27
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +519 -27
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +16 -0
- package/dist/providers/spec/cli-adapter.d.ts +7 -0
- package/dist/providers/spec/fsm-driver.d.ts +35 -0
- package/dist/providers/spec/fsm-evaluator.d.ts +28 -2
- package/dist/providers/spec/fsm-types.d.ts +19 -1
- package/dist/providers/spec/signal-envelope.d.ts +59 -0
- package/dist/providers/transcript-signal-source.d.ts +59 -0
- package/package.json +3 -3
- package/src/cli-adapter-types.ts +7 -0
- package/src/commands/low-family/daemon-lifecycle.ts +41 -0
- package/src/commands/med-family/mesh-restart.ts +233 -30
- package/src/commands/upgrade-helper.ts +34 -3
- package/src/providers/cli-provider-instance.ts +46 -0
- package/src/providers/spec/cli-adapter.ts +10 -0
- package/src/providers/spec/fsm-driver.ts +62 -1
- package/src/providers/spec/fsm-evaluator.ts +110 -5
- package/src/providers/spec/fsm-loader.ts +7 -0
- package/src/providers/spec/fsm-types.ts +20 -0
- package/src/providers/spec/signal-envelope.ts +136 -0
- package/src/providers/transcript-signal-source.ts +171 -0
|
@@ -1,23 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RF-ROUTER MED family — coordinator-triggered daemon restart.
|
|
3
3
|
*
|
|
4
|
-
* restart_daemon_node exposes the
|
|
5
|
-
*
|
|
6
|
-
* a
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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);
|