@pellux/goodvibes-tui 1.18.0 → 1.19.1
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/CHANGELOG.md +164 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1421 -896
- package/package.json +7 -7
- package/src/cli/endpoints.ts +50 -4
- package/src/cli/help.ts +29 -2
- package/src/cli/management-commands.ts +17 -8
- package/src/cli/service-posture.ts +5 -3
- package/src/cli/status.ts +27 -6
- package/src/cli/surface-command.ts +29 -15
- package/src/core/memory-diagnostics-gateway.ts +99 -0
- package/src/core/memory-status.ts +118 -0
- package/src/core/voice-provision-gateway.ts +202 -0
- package/src/core/voice-provision-status.ts +197 -0
- package/src/daemon/cli.ts +80 -4
- package/src/input/commands/experience-runtime.ts +25 -4
- package/src/input/commands/health-runtime.ts +15 -1
- package/src/input/tts-settings-actions.ts +27 -8
- package/src/input/voice-local-settings.ts +52 -0
- package/src/runtime/code-index-services.ts +15 -1
- package/src/runtime/knowledge-services.ts +94 -0
- package/src/runtime/legacy-daemon-migration.ts +38 -6
- package/src/runtime/legacy-daemon-reconcile.ts +439 -0
- package/src/runtime/memory-governance-services.ts +79 -0
- package/src/runtime/notification-dispatch.ts +28 -0
- package/src/runtime/services.ts +70 -73
- package/src/runtime/voice-setup-services.ts +52 -0
- package/src/version.ts +11 -2
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unattended startup reconcile for the redundant install-script
|
|
3
|
+
* `goodvibes-daemon.service` unit — split out of `legacy-daemon-migration.ts`
|
|
4
|
+
* (which owns the shared unit definition, detection, and the CONSENTED
|
|
5
|
+
* `migrate-service` engine) so each module stays within the architecture
|
|
6
|
+
* gate's file-size cap. Same layer (`src/runtime/`), same injectable-seam
|
|
7
|
+
* discipline; see the sibling module's banner for the naming and layering
|
|
8
|
+
* rationale.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
11
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
12
|
+
import {
|
|
13
|
+
defaultActionRunner,
|
|
14
|
+
defaultPortProbe,
|
|
15
|
+
legacyUnitPath,
|
|
16
|
+
parseMainPid,
|
|
17
|
+
LEGACY_SERVICE_UNIT_NAME,
|
|
18
|
+
SYSTEMCTL_TIMEOUT_MS,
|
|
19
|
+
type ManagedServiceActionRunner,
|
|
20
|
+
} from './legacy-daemon-migration.ts';
|
|
21
|
+
|
|
22
|
+
type ManagedServiceActionResult = ReturnType<ManagedServiceActionRunner>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The exact marker string scripts/install.sh writes into every unit it
|
|
26
|
+
* creates (as a `# managed by goodvibes install.sh` comment). The reconcile
|
|
27
|
+
* check below keys on it to tell an installer-created legacy unit — safe to
|
|
28
|
+
* auto-retire — apart from a hand-written one that must only ever be reported.
|
|
29
|
+
* Kept in lockstep with `INSTALLER_MARKER` in scripts/install.sh.
|
|
30
|
+
*/
|
|
31
|
+
export const INSTALLER_UNIT_MARKER = 'managed by goodvibes install.sh';
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* CUMULATIVE ceiling on the reconcile's whole boot-path pass. Per-call
|
|
37
|
+
* timeouts alone still allow ~5 sequential slow-but-completing calls to stack
|
|
38
|
+
* up (a degraded — not wedged — user bus), blocking the already-listening
|
|
39
|
+
* daemon's event loop for tens of seconds. One overall deadline covers the
|
|
40
|
+
* pass: once it is exceeded, every remaining call is skipped (reported as
|
|
41
|
+
* `status: null`, which the guards treat as unknown → refusal) and the result
|
|
42
|
+
* carries a notice saying so.
|
|
43
|
+
*/
|
|
44
|
+
export const RECONCILE_DEADLINE_MS = 8_000;
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Wrap a runner with the reconcile's cumulative deadline: calls after the
|
|
50
|
+
* deadline are skipped outright; calls near it get only the remaining budget
|
|
51
|
+
* as their per-call timeout (default runner only — injected runners are test
|
|
52
|
+
* fakes that answer instantly).
|
|
53
|
+
*/
|
|
54
|
+
function makeDeadlineBoundRunner(
|
|
55
|
+
injected: ManagedServiceActionRunner | undefined,
|
|
56
|
+
perCallTimeoutMs: number,
|
|
57
|
+
deadlineMs: number,
|
|
58
|
+
): { run: ManagedServiceActionRunner; deadlineHit: () => boolean } {
|
|
59
|
+
const startedAt = Date.now();
|
|
60
|
+
let hit = false;
|
|
61
|
+
const run: ManagedServiceActionRunner = (command, args) => {
|
|
62
|
+
const remaining = deadlineMs - (Date.now() - startedAt);
|
|
63
|
+
if (remaining <= 0) {
|
|
64
|
+
hit = true;
|
|
65
|
+
return { status: null, stdout: '', stderr: 'reconcile time budget exceeded — call skipped' } as ManagedServiceActionResult;
|
|
66
|
+
}
|
|
67
|
+
if (injected) return injected(command, args);
|
|
68
|
+
return defaultActionRunner(Math.min(perCallTimeoutMs, remaining))(command, args);
|
|
69
|
+
};
|
|
70
|
+
return { run, deadlineHit: () => hit };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
export interface ReconcileRedundantLegacyUnitInput {
|
|
76
|
+
readonly homeDir: string;
|
|
77
|
+
/** The unit name this tool manages (e.g. 'goodvibes'). */
|
|
78
|
+
readonly trackedServiceName: string;
|
|
79
|
+
/**
|
|
80
|
+
* The endpoint CLIENTS resolve from settings.json (no runtime flag
|
|
81
|
+
* overrides). When provided, the reconcile requires this endpoint to be
|
|
82
|
+
* answering before it retires anything: a canonical daemon that is alive on
|
|
83
|
+
* some other port must never retire the unit clients actually reach.
|
|
84
|
+
*/
|
|
85
|
+
readonly configuredEndpoint?: { readonly host: string; readonly port: number } | undefined;
|
|
86
|
+
/** Injectable probe of the configured endpoint. Defaults to `defaultPortProbe`. */
|
|
87
|
+
readonly endpointProbe?: ((host: string, port: number) => boolean | Promise<boolean>) | undefined;
|
|
88
|
+
/** Injectable existsSync so tests never touch the host filesystem. */
|
|
89
|
+
readonly legacyUnitFileExists?: ((path: string) => boolean) | undefined;
|
|
90
|
+
/** Injectable unit-file read for the installer-marker check. */
|
|
91
|
+
readonly legacyUnitFileRead?: ((path: string) => string) | undefined;
|
|
92
|
+
/** Injectable removal of the legacy unit file. Defaults to a real `rmSync`. */
|
|
93
|
+
readonly legacyUnitFileRemove?: ((path: string) => void) | undefined;
|
|
94
|
+
/** Injectable systemctl runner (is-active/MainPID probes + disable/daemon-reload). */
|
|
95
|
+
readonly actionRunner?: ManagedServiceActionRunner | undefined;
|
|
96
|
+
/** Injectable liveness check for unit MainPIDs. Defaults to a signal-0 probe. */
|
|
97
|
+
readonly processAlive?: ((pid: number) => boolean) | undefined;
|
|
98
|
+
/** This process's pid, for the self-supervision guard. Defaults to `process.pid`. */
|
|
99
|
+
readonly ownPid?: number | undefined;
|
|
100
|
+
/** Injectable /proc/self/cgroup read, for the self-supervision guard. */
|
|
101
|
+
readonly readOwnCgroup?: (() => string) | undefined;
|
|
102
|
+
/** Timeout applied to the DEFAULT systemctl runner only. Defaults to SYSTEMCTL_TIMEOUT_MS. */
|
|
103
|
+
readonly systemctlTimeoutMs?: number | undefined;
|
|
104
|
+
/** Cumulative budget for the whole pass. Defaults to RECONCILE_DEADLINE_MS. */
|
|
105
|
+
readonly deadlineMs?: number | undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type ReconcileRedundantLegacyUnitReason =
|
|
109
|
+
| 'no-legacy-unit'
|
|
110
|
+
| 'canonical-not-active'
|
|
111
|
+
| 'canonical-mainpid-not-alive'
|
|
112
|
+
| 'self-supervised-by-legacy'
|
|
113
|
+
| 'legacy-state-unknown'
|
|
114
|
+
| 'legacy-running'
|
|
115
|
+
| 'configured-endpoint-unserved'
|
|
116
|
+
| 'hand-written'
|
|
117
|
+
| 'marker-unreadable'
|
|
118
|
+
| 'disable-failed'
|
|
119
|
+
| 'disable-timeout'
|
|
120
|
+
| 'retired';
|
|
121
|
+
|
|
122
|
+
export interface ReconcileRedundantLegacyUnitResult {
|
|
123
|
+
/**
|
|
124
|
+
* 'removed' = legacy unit auto-retired; 'notice' = left alone with a printed
|
|
125
|
+
* hint (hand-written, or unreadable); 'failed' = retirement was attempted but
|
|
126
|
+
* a destructive step failed or its outcome could not be confirmed (this tool
|
|
127
|
+
* removed nothing); 'noop' = guard refused or nothing to do.
|
|
128
|
+
*/
|
|
129
|
+
readonly action: 'removed' | 'notice' | 'noop' | 'failed';
|
|
130
|
+
/** Machine-readable why, so callers can leave a breadcrumb for every refusal. */
|
|
131
|
+
readonly reason: ReconcileRedundantLegacyUnitReason;
|
|
132
|
+
readonly lines: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function defaultProcessAlive(pid: number): boolean {
|
|
136
|
+
try {
|
|
137
|
+
process.kill(pid, 0);
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function defaultReadOwnCgroup(): string {
|
|
145
|
+
try {
|
|
146
|
+
return readFileSync('/proc/self/cgroup', 'utf-8');
|
|
147
|
+
} catch {
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Cheap, unattended startup reconcile for the exact host state the production
|
|
154
|
+
* incident surfaced: an installer-managed legacy `goodvibes-daemon.service`
|
|
155
|
+
* unit sitting ENABLED alongside the canonical `goodvibes.service` that is
|
|
156
|
+
* ENABLED + ACTIVE. Nothing ever disabled the redundant legacy unit, so a
|
|
157
|
+
* bare-args second daemon could boot and fight the real one for the port.
|
|
158
|
+
*
|
|
159
|
+
* This runs on daemon startup (no user invocation needed) and, guard-railed:
|
|
160
|
+
* - Only acts when the canonical unit is CONFIRMED serving: `is-active`
|
|
161
|
+
* reports active AND its MainPID resolves to a live process. `is-active`
|
|
162
|
+
* alone is not proof — a Type=simple unit reports active from fork onward,
|
|
163
|
+
* including the pre-bind window of a daemon that is about to crash-loop.
|
|
164
|
+
* - NEVER acts from inside the legacy unit itself: if this process IS the
|
|
165
|
+
* legacy unit's MainPID, or /proc/self/cgroup names the legacy unit,
|
|
166
|
+
* running `disable --now` would SIGTERM the very daemon executing this
|
|
167
|
+
* code (mid-boot, from inside a blocking spawnSync). Refuses instead.
|
|
168
|
+
* - NEVER stops a RUNNING legacy daemon: if the legacy unit's MainPID is a
|
|
169
|
+
* live process, it may be the daemon serving the endpoint clients are
|
|
170
|
+
* configured to reach (the wrong-port two-daemon state) — the unattended
|
|
171
|
+
* path only retires enabled-but-NOT-running legacy units; a running one
|
|
172
|
+
* gets a notice pointing at the consented migrate-service.
|
|
173
|
+
* - Requires the CONFIGURED endpoint (the one clients resolve from
|
|
174
|
+
* settings.json, no runtime overrides) to be answering before retiring —
|
|
175
|
+
* a canonical daemon alive on some other port proves nothing about the
|
|
176
|
+
* endpoint clients use.
|
|
177
|
+
* - AUTO-RETIRES only an installer-MARKER-managed legacy unit, and only
|
|
178
|
+
* removes its unit file AFTER `disable --now` reports success. A NONZERO
|
|
179
|
+
* exit leaves everything in place and says so; a TIMED-OUT disable
|
|
180
|
+
* (status null) has an UNKNOWN outcome — the enablement state is
|
|
181
|
+
* re-inspected before anything is printed, and the receipt states what
|
|
182
|
+
* was actually confirmed, never a blanket claim.
|
|
183
|
+
* - A hand-written legacy unit (no marker) is never touched — a one-line
|
|
184
|
+
* actionable notice. An UNREADABLE unit file is reported as unreadable,
|
|
185
|
+
* never misdiagnosed as hand-written.
|
|
186
|
+
* - The whole pass shares ONE cumulative time budget (RECONCILE_DEADLINE_MS)
|
|
187
|
+
* on top of the per-call timeout, so even a degraded-but-completing user
|
|
188
|
+
* bus cannot stack five slow calls into a half-minute boot stall.
|
|
189
|
+
* Every side effect goes through the same injectable seams the migration engine
|
|
190
|
+
* uses, so tests exercise it deterministically and never touch a real service.
|
|
191
|
+
*/
|
|
192
|
+
export async function reconcileRedundantLegacyUnit(
|
|
193
|
+
input: ReconcileRedundantLegacyUnitInput,
|
|
194
|
+
): Promise<ReconcileRedundantLegacyUnitResult> {
|
|
195
|
+
const path = legacyUnitPath(input.homeDir);
|
|
196
|
+
const fileExists = input.legacyUnitFileExists ?? existsSync;
|
|
197
|
+
if (!fileExists(path)) return { action: 'noop', reason: 'no-legacy-unit', lines: [] };
|
|
198
|
+
|
|
199
|
+
const { run, deadlineHit } = makeDeadlineBoundRunner(
|
|
200
|
+
input.actionRunner,
|
|
201
|
+
input.systemctlTimeoutMs ?? SYSTEMCTL_TIMEOUT_MS,
|
|
202
|
+
input.deadlineMs ?? RECONCILE_DEADLINE_MS,
|
|
203
|
+
);
|
|
204
|
+
const canonicalUnit = `${input.trackedServiceName}.service`;
|
|
205
|
+
const legacyUnit = `${LEGACY_SERVICE_UNIT_NAME}.service`;
|
|
206
|
+
const deadlineNote = (): string[] =>
|
|
207
|
+
deadlineHit()
|
|
208
|
+
? ['note: the reconcile hit its overall time budget — remaining checks were skipped; it will re-run at the next daemon start.']
|
|
209
|
+
: [];
|
|
210
|
+
|
|
211
|
+
// Guard 1: the canonical unit must report active. (A timed-out, skipped, or
|
|
212
|
+
// failed probe — e.g. a wedged user bus — lands here too and refuses.)
|
|
213
|
+
const probe = run('systemctl', ['--user', 'is-active', canonicalUnit]);
|
|
214
|
+
const canonicalActive = (probe.status ?? 1) === 0 && (probe.stdout ?? '').trim() === 'active';
|
|
215
|
+
if (!canonicalActive) {
|
|
216
|
+
return {
|
|
217
|
+
action: 'noop',
|
|
218
|
+
reason: 'canonical-not-active',
|
|
219
|
+
lines: [
|
|
220
|
+
`legacy-unit reconcile: a ${legacyUnit} unit file exists at ${path} but ${canonicalUnit} is not confirmably active — ` +
|
|
221
|
+
'left untouched (it may be the only daemon).',
|
|
222
|
+
...deadlineNote(),
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Guard 2: 'active' alone does not prove the canonical unit is the daemon
|
|
228
|
+
// actually serving (Type=simple reports active from fork onward). Require
|
|
229
|
+
// its MainPID to resolve and be a live process.
|
|
230
|
+
const canonicalPid = parseMainPid(run('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', canonicalUnit]));
|
|
231
|
+
const alive = input.processAlive ?? defaultProcessAlive;
|
|
232
|
+
if (canonicalPid === undefined || !alive(canonicalPid)) {
|
|
233
|
+
return {
|
|
234
|
+
action: 'noop',
|
|
235
|
+
reason: 'canonical-mainpid-not-alive',
|
|
236
|
+
lines: [
|
|
237
|
+
`legacy-unit reconcile: ${canonicalUnit} reports active but its MainPID could not be confirmed alive — ` +
|
|
238
|
+
`left the ${legacyUnit} unit untouched.`,
|
|
239
|
+
...deadlineNote(),
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Guard 3: never disable the unit that launched THIS process. `disable
|
|
245
|
+
// --now` on our own supervising unit would SIGTERM this daemon's whole
|
|
246
|
+
// cgroup mid-boot, from inside the blocking systemctl call.
|
|
247
|
+
//
|
|
248
|
+
// The legacy MainPID probe is TRI-STATE, exactly like guards 1-2: a reply
|
|
249
|
+
// whose status is non-zero or null (bus flap, timeout, skipped-by-deadline)
|
|
250
|
+
// does NOT mean "MainPID=0, unit affirmatively stopped" — reading it that
|
|
251
|
+
// way let one transient systemctl failure on exactly this call authorize
|
|
252
|
+
// `disable --now` against a LIVE legacy daemon (and guard 5 cannot catch
|
|
253
|
+
// it: in the wrong-port state the legacy daemon itself answers the
|
|
254
|
+
// configured-endpoint probe). Unknown refuses.
|
|
255
|
+
const ownPid = input.ownPid ?? process.pid;
|
|
256
|
+
const legacyPidReply = run('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', legacyUnit]);
|
|
257
|
+
const legacyPidKnown = (legacyPidReply.status ?? 1) === 0;
|
|
258
|
+
const legacyPid = parseMainPid(legacyPidReply);
|
|
259
|
+
const ownCgroup = (input.readOwnCgroup ?? defaultReadOwnCgroup)();
|
|
260
|
+
if (legacyPid === ownPid || ownCgroup.includes(legacyUnit)) {
|
|
261
|
+
return {
|
|
262
|
+
action: 'noop',
|
|
263
|
+
reason: 'self-supervised-by-legacy',
|
|
264
|
+
lines: [
|
|
265
|
+
`legacy-unit reconcile: this daemon appears to be running UNDER ${legacyUnit} itself — refusing to disable ` +
|
|
266
|
+
`the unit supervising the current process. Migrate from the canonical side instead: goodvibes-daemon migrate-service`,
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (!legacyPidKnown) {
|
|
271
|
+
return {
|
|
272
|
+
action: 'noop',
|
|
273
|
+
reason: 'legacy-state-unknown',
|
|
274
|
+
lines: [
|
|
275
|
+
`legacy-unit reconcile: could not determine whether ${legacyUnit} has a running daemon (the MainPID query ` +
|
|
276
|
+
'failed or timed out) — refusing to act on a guess; it will be re-checked at the next daemon start.',
|
|
277
|
+
...deadlineNote(),
|
|
278
|
+
],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Guard 4: never stop a RUNNING legacy daemon from the unattended path. A
|
|
283
|
+
// live legacy MainPID means a second daemon is actually serving something —
|
|
284
|
+
// possibly the endpoint clients resolve from settings.json (the wrong-port
|
|
285
|
+
// two-daemon state). Retiring an enabled-but-idle unit needs no --now kill;
|
|
286
|
+
// stopping a serving one needs consent. (A KNOWN reply of MainPID=0 is the
|
|
287
|
+
// affirmative "stopped" that permits proceeding.)
|
|
288
|
+
if (legacyPid !== undefined && alive(legacyPid)) {
|
|
289
|
+
return {
|
|
290
|
+
action: 'noop',
|
|
291
|
+
reason: 'legacy-running',
|
|
292
|
+
lines: [
|
|
293
|
+
`legacy-unit reconcile: ${legacyUnit} has a live main process (pid ${legacyPid}) — a second daemon is ` +
|
|
294
|
+
'actually running, and it may be the one serving the endpoint your clients are configured to reach. ' +
|
|
295
|
+
'Refusing to stop it unattended; migrate deliberately with: goodvibes-daemon migrate-service',
|
|
296
|
+
],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Guard 5: the endpoint clients resolve from settings.json must be answered
|
|
301
|
+
// before anything is retired. The canonical daemon being alive proves only
|
|
302
|
+
// that A daemon runs — not that the configured endpoint is served (its unit
|
|
303
|
+
// may pin different launch args).
|
|
304
|
+
if (input.configuredEndpoint) {
|
|
305
|
+
const endpointProbe = input.endpointProbe ?? defaultPortProbe;
|
|
306
|
+
const served = await endpointProbe(input.configuredEndpoint.host, input.configuredEndpoint.port);
|
|
307
|
+
if (!served) {
|
|
308
|
+
return {
|
|
309
|
+
action: 'noop',
|
|
310
|
+
reason: 'configured-endpoint-unserved',
|
|
311
|
+
lines: [
|
|
312
|
+
`legacy-unit reconcile: nothing is answering on the CONFIGURED endpoint ` +
|
|
313
|
+
`${input.configuredEndpoint.host}:${input.configuredEndpoint.port} — the canonical daemon is alive but not ` +
|
|
314
|
+
`provably serving what clients resolve from settings. Left the ${legacyUnit} unit untouched; check the ` +
|
|
315
|
+
"canonical unit's launch arguments against the controlPlane settings.",
|
|
316
|
+
],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const readFile = input.legacyUnitFileRead ?? ((p: string) => readFileSync(p, 'utf-8'));
|
|
322
|
+
let marked = false;
|
|
323
|
+
let readError: string | undefined;
|
|
324
|
+
try {
|
|
325
|
+
marked = readFile(path).includes(INSTALLER_UNIT_MARKER);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
readError = summarizeError(error);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (readError !== undefined) {
|
|
331
|
+
// Fail closed, and say what actually happened: the file could not be READ.
|
|
332
|
+
// Asserting "hand-written (no installer marker)" here would be a false
|
|
333
|
+
// provenance claim about a file whose contents were never established.
|
|
334
|
+
return {
|
|
335
|
+
action: 'notice',
|
|
336
|
+
reason: 'marker-unreadable',
|
|
337
|
+
lines: [
|
|
338
|
+
`note: ${canonicalUnit} is active and a separate ${legacyUnit} exists at ${path}, but its unit file could not ` +
|
|
339
|
+
`be read (${readError}) — left untouched. Inspect it yourself; if it is redundant, retire it with: ` +
|
|
340
|
+
`systemctl --user disable --now ${legacyUnit} && rm ${path} && systemctl --user daemon-reload`,
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!marked) {
|
|
346
|
+
return {
|
|
347
|
+
action: 'notice',
|
|
348
|
+
reason: 'hand-written',
|
|
349
|
+
lines: [
|
|
350
|
+
`note: ${canonicalUnit} is active but a separate ${legacyUnit} also exists at ${path}. ` +
|
|
351
|
+
'It is hand-written (no installer marker) so it was left untouched; retire it yourself to stop two daemons ' +
|
|
352
|
+
`competing for the same port: systemctl --user disable --now ${legacyUnit} && rm ${path} && ` +
|
|
353
|
+
'systemctl --user daemon-reload',
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Installer-marker-managed and redundant: disable first, and only remove the
|
|
359
|
+
// unit file if the disable actually succeeded — otherwise the enablement
|
|
360
|
+
// symlink dangles at a deleted file and this tool can never repair it (the
|
|
361
|
+
// next pass no-ops at the file-exists check).
|
|
362
|
+
const disableResult = run('systemctl', ['--user', 'disable', '--now', legacyUnit]);
|
|
363
|
+
const disableStatus = disableResult.status ?? 1;
|
|
364
|
+
if (disableStatus !== 0) {
|
|
365
|
+
if (disableResult.status === null) {
|
|
366
|
+
// TIMED OUT: the outcome is UNKNOWN, not failed — `disable --now`
|
|
367
|
+
// removes the enablement symlinks synchronously BEFORE waiting on the
|
|
368
|
+
// stop job, so the disable may well have taken effect even though the
|
|
369
|
+
// client was killed. Re-inspect instead of printing a blanket denial.
|
|
370
|
+
const reinspect = run('systemctl', ['--user', 'is-enabled', legacyUnit]);
|
|
371
|
+
const reinspectOut = (reinspect.stdout ?? '').trim();
|
|
372
|
+
const confirmedDisabled = (reinspect.status !== null)
|
|
373
|
+
&& (reinspectOut === 'disabled' || reinspectOut === 'not-found');
|
|
374
|
+
if (confirmedDisabled) {
|
|
375
|
+
// The disable took effect; only the client timed out waiting on the
|
|
376
|
+
// stop job. Proceed as success, saying exactly that.
|
|
377
|
+
const removeFileAfterTimeout = input.legacyUnitFileRemove ?? ((p: string) => rmSync(p, { force: true }));
|
|
378
|
+
let removeErrorAfterTimeout: string | undefined;
|
|
379
|
+
try {
|
|
380
|
+
removeFileAfterTimeout(path);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
removeErrorAfterTimeout = summarizeError(error);
|
|
383
|
+
}
|
|
384
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
385
|
+
const lines = [
|
|
386
|
+
`reconciled: the disable command timed out waiting on the stop job, but re-inspection confirms the ` +
|
|
387
|
+
`installer-managed ${legacyUnit} is no longer enabled${removeErrorAfterTimeout ? '' : ` — its unit file was removed (${path})`}. ` +
|
|
388
|
+
'Its stop may still be completing.',
|
|
389
|
+
];
|
|
390
|
+
if (removeErrorAfterTimeout) {
|
|
391
|
+
lines.push(`note: could not remove ${path}: ${removeErrorAfterTimeout} — remove it by hand.`);
|
|
392
|
+
}
|
|
393
|
+
lines.push(...deadlineNote());
|
|
394
|
+
return { action: 'removed', reason: 'retired', lines };
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
action: 'failed',
|
|
398
|
+
reason: 'disable-timeout',
|
|
399
|
+
lines: [
|
|
400
|
+
`legacy-unit reconcile: the disable of ${legacyUnit} timed out and its outcome is UNKNOWN — the enablement ` +
|
|
401
|
+
`state could not be re-confirmed. This tool removed nothing; the unit file at ${path} was left in place ` +
|
|
402
|
+
'and will be re-checked at the next daemon start.',
|
|
403
|
+
`Verify it yourself: systemctl --user is-enabled ${legacyUnit}`,
|
|
404
|
+
...deadlineNote(),
|
|
405
|
+
],
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const detail = (disableResult.stderr ?? disableResult.stdout ?? '').trim() || 'no output';
|
|
409
|
+
return {
|
|
410
|
+
action: 'failed',
|
|
411
|
+
reason: 'disable-failed',
|
|
412
|
+
lines: [
|
|
413
|
+
`legacy-unit reconcile: the disable of the redundant installer-managed ${legacyUnit} reported failure ` +
|
|
414
|
+
`(${detail}) — this tool removed nothing; the unit file at ${path} was left in place.`,
|
|
415
|
+
`Verify its state and retire it yourself: systemctl --user is-enabled ${legacyUnit} ; ` +
|
|
416
|
+
`systemctl --user disable --now ${legacyUnit} && rm ${path} && systemctl --user daemon-reload`,
|
|
417
|
+
],
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const removeFile = input.legacyUnitFileRemove ?? ((p: string) => rmSync(p, { force: true }));
|
|
422
|
+
let removeError: string | undefined;
|
|
423
|
+
try {
|
|
424
|
+
removeFile(path);
|
|
425
|
+
} catch (error) {
|
|
426
|
+
removeError = summarizeError(error);
|
|
427
|
+
}
|
|
428
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
429
|
+
|
|
430
|
+
const lines = [
|
|
431
|
+
`reconciled: ${canonicalUnit} is active and serving, so the redundant installer-managed ${legacyUnit} ` +
|
|
432
|
+
`was disabled${removeError ? '' : ` and removed (${path})`}.`,
|
|
433
|
+
];
|
|
434
|
+
if (removeError) {
|
|
435
|
+
lines.push(`note: could not remove ${path}: ${removeError} — remove it by hand.`);
|
|
436
|
+
}
|
|
437
|
+
lines.push(...deadlineNote());
|
|
438
|
+
return { action: 'removed', reason: 'retired', lines };
|
|
439
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// memory-governance-services.ts — the daemon-composition tail that constructs
|
|
3
|
+
// and starts the SDK's MemoryGovernor with the TUI fork's REAL cache adapters.
|
|
4
|
+
//
|
|
5
|
+
// Mirrors the SDK's own createRuntimeServices: the CacheRegistry, PauseController
|
|
6
|
+
// and the admission-gate holder are built EARLY in services.ts (so the
|
|
7
|
+
// scheduler gates and the knowledge background jobs can consult the pause
|
|
8
|
+
// controller before the governor exists); this helper is the TAIL step that
|
|
9
|
+
// hands them to wireDaemonMemoryGovernance with the fork's knowledge stores +
|
|
10
|
+
// shared session broker as the real, reclaiming cache adapters, and starts the
|
|
11
|
+
// governor. It is default ON — the governor is a safety feature the owner
|
|
12
|
+
// confirmed starts by default (wireDaemonMemoryGovernance starts it unless a
|
|
13
|
+
// caller opts out), so every daemon composition defends its own footprint.
|
|
14
|
+
//
|
|
15
|
+
// Kept as a one-function extraction (like wireIdlePowerAndLiveTurn) so the
|
|
16
|
+
// composition-parity test can pin the wiring from a single file.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
wireDaemonMemoryGovernance,
|
|
21
|
+
type CacheRegistry,
|
|
22
|
+
type PauseController,
|
|
23
|
+
type MemoryGovernor,
|
|
24
|
+
} from '@pellux/goodvibes-sdk/platform/runtime/memory';
|
|
25
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
26
|
+
import type { RuntimeEventBus } from '@/runtime/index.ts';
|
|
27
|
+
|
|
28
|
+
export interface MemoryGovernanceServicesDeps {
|
|
29
|
+
readonly configManager: ConfigManager;
|
|
30
|
+
readonly runtimeBus: RuntimeEventBus;
|
|
31
|
+
/** Built early in services.ts so scheduler gates can consult it before the governor. */
|
|
32
|
+
readonly cacheRegistry: CacheRegistry;
|
|
33
|
+
readonly pauseController: PauseController;
|
|
34
|
+
/** The deferrable background jobs the governor pauses under pressure. */
|
|
35
|
+
readonly jobIds: readonly string[];
|
|
36
|
+
/** Where the tripwire receipt is written so a supervisor sees the exit reason. */
|
|
37
|
+
readonly receiptPath: string;
|
|
38
|
+
/** The fork's three knowledge stores: real retained-entry counts + a real job-run trim. */
|
|
39
|
+
readonly knowledgeStores: ReadonlyArray<{ retainedEntryCount(): number; pruneJobRuns(keep: number): number }>;
|
|
40
|
+
/** The shared session broker: real retained-record count + a real GC/truncate trim. */
|
|
41
|
+
readonly sessionBroker: { retainedRecordCount(): number; trimRetained(level: 'floor' | 'flush'): void };
|
|
42
|
+
/** Graceful tripwire shutdown flush (async store snapshots), run before a tripwire exit. */
|
|
43
|
+
readonly onTripwireShutdown?: (() => Promise<void> | void) | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MemoryGovernanceServices {
|
|
47
|
+
readonly memoryGovernor: MemoryGovernor;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Construct + start the MemoryGovernor with the standard KNOWN cache adapters
|
|
52
|
+
* (knowledge-store + session-union), reading the memory.* config domain. The
|
|
53
|
+
* governor starts by default — it is a safety feature.
|
|
54
|
+
*/
|
|
55
|
+
export function wireMemoryGovernance(deps: MemoryGovernanceServicesDeps): MemoryGovernanceServices {
|
|
56
|
+
const { configManager } = deps;
|
|
57
|
+
const { memoryGovernor } = wireDaemonMemoryGovernance({
|
|
58
|
+
config: {
|
|
59
|
+
budgetMb: configManager.get('memory.budgetMb'),
|
|
60
|
+
elevatedPct: configManager.get('memory.tier.elevatedPct'),
|
|
61
|
+
highPct: configManager.get('memory.tier.highPct'),
|
|
62
|
+
criticalPct: configManager.get('memory.tier.criticalPct'),
|
|
63
|
+
tripwireRateMbPerSec: configManager.get('memory.tripwire.rateMbPerSec'),
|
|
64
|
+
tripwireSustainSec: configManager.get('memory.tripwire.sustainSec'),
|
|
65
|
+
hardLimitPct: configManager.get('memory.hardLimitPct'),
|
|
66
|
+
},
|
|
67
|
+
runtimeBus: deps.runtimeBus,
|
|
68
|
+
cacheRegistry: deps.cacheRegistry,
|
|
69
|
+
pauseController: deps.pauseController,
|
|
70
|
+
jobIds: deps.jobIds,
|
|
71
|
+
receiptPath: deps.receiptPath,
|
|
72
|
+
// REAL cache adapters: genuine counts + trims that reclaim (knowledge
|
|
73
|
+
// job-run history pruning; session broker GC + bucket truncation).
|
|
74
|
+
knowledgeStores: deps.knowledgeStores,
|
|
75
|
+
sessionBroker: deps.sessionBroker,
|
|
76
|
+
onTripwireShutdown: deps.onTripwireShutdown,
|
|
77
|
+
});
|
|
78
|
+
return { memoryGovernor };
|
|
79
|
+
}
|
|
@@ -15,6 +15,7 @@ import { createNotificationRouter, type NotificationRouter } from '@pellux/goodv
|
|
|
15
15
|
import type { Notification, RoutingDecision, RuntimeEventBus, RuntimeEventDomain } from '@/runtime/index.ts';
|
|
16
16
|
import type { ConfigManager } from '../config/index.ts';
|
|
17
17
|
import { getSharedNotificationFeed, type PanelNotificationFeed } from '../panels/notifications-feed.ts';
|
|
18
|
+
import { memoryPressureLine, memoryPressureLevel, type MemoryPressurePayload } from '../core/memory-status.ts';
|
|
18
19
|
|
|
19
20
|
export interface NotificationDispatcher {
|
|
20
21
|
/** Route a notification; a panel_only (or burst-collapsed) decision lands in the feed. Returns the decision. */
|
|
@@ -85,6 +86,33 @@ export function createNotificationDispatcher(
|
|
|
85
86
|
};
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Bridge OPS_MEMORY_PRESSURE specifically into the notification feed as an
|
|
91
|
+
* attention line. The MemoryGovernor emits this on the 'ops' domain when the
|
|
92
|
+
* pressure tier changes or the leak tripwire fires; that domain also carries
|
|
93
|
+
* high-churn audit/metric events, so it is deliberately NOT in
|
|
94
|
+
* NOTIFICATION_BRIDGE_DOMAINS — this targeted bridge lifts only the
|
|
95
|
+
* memory-pressure event into notices (critical at the critical tier / on a
|
|
96
|
+
* tripwire, warning at high), leaving the rest of the ops churn out of the
|
|
97
|
+
* feed. Returns an unsubscribe function.
|
|
98
|
+
*/
|
|
99
|
+
export function wireMemoryPressureNotice(
|
|
100
|
+
runtimeBus: RuntimeEventBus,
|
|
101
|
+
dispatcher: Pick<NotificationDispatcher, 'dispatch'>,
|
|
102
|
+
): () => void {
|
|
103
|
+
return runtimeBus.onDomain('ops', (envelope) => {
|
|
104
|
+
if (envelope.type !== 'OPS_MEMORY_PRESSURE') return;
|
|
105
|
+
const payload = envelope.payload as MemoryPressurePayload;
|
|
106
|
+
dispatcher.dispatch({
|
|
107
|
+
id: envelope.traceId ?? `ops-OPS_MEMORY_PRESSURE-${envelope.ts}`,
|
|
108
|
+
domain: 'ops',
|
|
109
|
+
level: memoryPressureLevel(payload),
|
|
110
|
+
title: memoryPressureLine(payload),
|
|
111
|
+
timestamp: envelope.ts,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
/**
|
|
89
117
|
* Bridge the runtime event bus to the dispatcher: each event in a curated
|
|
90
118
|
* domain becomes a notification and is routed. Returns an unsubscribe function
|