@ouro.bot/cli 0.1.0-alpha.515 → 0.1.0-alpha.517
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.json +22 -0
- package/dist/heart/daemon/agent-config-check.js +30 -8
- package/dist/heart/daemon/cli-exec.js +97 -1
- package/dist/heart/daemon/cli-render.js +89 -1
- package/dist/heart/daemon/daemon-entry.js +60 -1
- package/dist/heart/daemon/daemon-health.js +42 -1
- package/dist/heart/daemon/daemon-rollup.js +58 -0
- package/dist/heart/daemon/drift-detection.js +146 -0
- package/dist/heart/daemon/inner-status.js +13 -0
- package/dist/heart/outlook/readers/runtime-readers.js +7 -1
- package/dist/nerves/coverage/file-completeness.js +16 -0
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.517",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Layer 4 of the harness-hardening sequence (PR 2 of 4 in 1→4→2→3 from `docs/planning/2026-04-28-1900-planning-harness-hardening-and-repairguide.md`). Detects per-lane drift between each agent's intent (committed `agent.json`) and the observed binding on this machine (`state/providers.json`), surfaces the drift through the existing `EffectiveProviderReadiness.reason: \"provider-model-changed\"` vocabulary, and emits a copy-pasteable `ouro use` repair proposal. Read-only: the PR never mutates `state/providers.json` and never invokes the `ouro use` CLI surface.",
|
|
8
|
+
"New module `src/heart/daemon/drift-detection.ts`. `detectProviderBindingDrift(input)` is a pure intent-vs-observed comparator — it tolerates legacy `humanFacing`/`agentFacing` AND new `outward`/`inner` keys in `agent.json` with a 'new key wins, fall back to legacy' precedence rule (the rename is in flight; mixed `agent.json` files must work). `loadDriftInputsForAgent(bundlesRoot, agentName)` is the I/O wrapper that reads both files off disk, mapping missing/invalid `state/providers.json` to a `null` providerState (the comparator interprets `null` as 'no observation, nothing to drift against' — fresh-install case).",
|
|
9
|
+
"`checkAgentConfigWithProviderHealth` gains an additive optional `driftFindings: DriftFinding[]` field on `ConfigCheckResult`. Drift detection runs once after state setup so findings ride along with both success and failure return paths. Drift is advisory and never flips `ok` to false. Non-breaking: 7 existing tests using strict `toEqual({ok:true})` were loosened to `toMatchObject({ok:true})` to accept the additive field — no behavior change.",
|
|
10
|
+
"`computeDaemonRollup` (Layer 1) gains an optional `driftDetected: boolean`. When true, `healthy` → `partial` (same downgrade rule as `bootstrapDegraded`). `degraded` and `safe-mode` rollups are unaffected — drift never escalates past `partial` and never un-downgrades. `daemon-entry.ts` probes each enabled agent for drift before computing the rollup; a single agent's read failure is best-effort and does not block the scan.",
|
|
11
|
+
"`buildInnerStatusOutput` gains an optional `driftFindings` field and renders a `drift advisory` section per finding (lane, intent vs observed, copy-pasteable `ouro use`). `cli-exec.ts` adds `collectAgentDriftAdvisories` + `writeDriftAdvisorySummary` helpers; wired into the `--no-repair` boot path (preflight provider-degraded, post-startup degraded, AND the all-clear path) and the `inner.status` command. Operators see drift advisories without running `ouro inner status` per agent.",
|
|
12
|
+
"Daemon-wide drift visibility (post-review fix from ouroboros): `DaemonHealthState` gains a required `drift: DriftFinding[]` field, populated by `buildDaemonHealthState` from the per-agent drift probe. `renderRollupStatusLine`'s `partial` branch now distinguishes three sub-cases — agents-unhealthy-only, drift-only, and both — so a drift-induced `partial` rollup carries a clear cause rather than the misleading 'some agents unhealthy' copy. `readHealth` tolerates legacy cached health files missing the field (defaults to `[]`).",
|
|
13
|
+
"9874 tests pass. 100% coverage on all new and changed source files. Typecheck clean. Lint clean. Layer 3 (RepairGuide) consumes the `driftFindings` array surfaced here — Layer 3 is where drift findings cease to be advisory and become actionable. Layer 2 (sync probe) is independent of this PR."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.1.0-alpha.516",
|
|
18
|
+
"changes": [
|
|
19
|
+
"Layer 1 of the harness-hardening sequence (1→4→2→3 from `docs/planning/2026-04-28-1900-planning-harness-hardening-and-repairguide.md`). Replaces the daemon-wide rollup at `daemon-entry.ts` (the binary `degraded.length > 0 ? \"degraded\" : \"ok\"` literal) with a five-state vocabulary: `healthy / partial / degraded / safe-mode / down`. A single sick agent no longer tips the whole daemon to `degraded`.",
|
|
20
|
+
"Type structure: `RollupStatus` (4-state, returned by the new pure `computeDaemonRollup` decision function in `daemon-rollup.ts`) and `DaemonStatus = RollupStatus | \"down\"` (full daemon-status; `down` is caller-owned because it represents pre-inventory failure, before the rollup is reachable). Both unions project from a single source-of-truth literal tuple so future widening touches one site.",
|
|
21
|
+
"`renderRollupStatusLine` in `cli-render.ts` uses a compiler-forced `never`-typed exhaustive switch — adding a future state compile-errors at every consumer using the pattern. The `degraded` literal carries three copy variants picked by inspecting cached agent statuses: empty map (fresh install, prompts `ouro hatch`), non-empty + any running agent (legacy stale cache from pre-Layer-1 daemons, prompts `ouro up` refresh), non-empty + zero running (all-failed live-check, prompts `ouro doctor`).",
|
|
22
|
+
"`runtime-readers.ts:readDaemonHealthDeep` parse tightened to use `isDaemonStatus`. `OutlookDaemonHealthDeep.status` widened to `DaemonStatus | \"unknown\"` so legacy serialized strings (`\"running\"`, `\"ok\"`) coerce defensively rather than failing the parse during rollout.",
|
|
23
|
+
"9759 tests pass (508 test files); coverage gate clean. The per-agent live-check loop in `cli-exec.ts` is intentionally untouched — it was already try/catch-isolated; the bug was in how its output rolled up. Subsequent PRs (layers 4, 2, 3) build on this PR's vocabulary."
|
|
24
|
+
]
|
|
25
|
+
},
|
|
4
26
|
{
|
|
5
27
|
"version": "0.1.0-alpha.515",
|
|
6
28
|
"changes": [
|
|
@@ -47,6 +47,7 @@ const provider_credentials_1 = require("../provider-credentials");
|
|
|
47
47
|
const vault_unlock_1 = require("../../repertoire/vault-unlock");
|
|
48
48
|
const readiness_repair_1 = require("./readiness-repair");
|
|
49
49
|
const provider_ping_progress_1 = require("./provider-ping-progress");
|
|
50
|
+
const drift_detection_1 = require("./drift-detection");
|
|
50
51
|
function isAgentProvider(value) {
|
|
51
52
|
return Object.prototype.hasOwnProperty.call(identity_1.PROVIDER_CREDENTIALS, value);
|
|
52
53
|
}
|
|
@@ -387,6 +388,26 @@ async function checkAgentConfigWithProviderHealth(agentName, bundlesRoot, deps =
|
|
|
387
388
|
return stateResult.result;
|
|
388
389
|
if (stateResult.disabled)
|
|
389
390
|
return { ok: true };
|
|
391
|
+
// Layer 4 drift detection. Runs once per call after state setup so that
|
|
392
|
+
// drift findings ride along regardless of ping outcome — consumers
|
|
393
|
+
// (Layer 4 rollup, Layer 3 RepairGuide) want to see drift even when a
|
|
394
|
+
// live-check is failing for unrelated reasons. Drift detection is pure
|
|
395
|
+
// and never throws on a malformed agent.json: any read error here would
|
|
396
|
+
// indicate the bundle disappeared between state setup and now (a
|
|
397
|
+
// race), which we surface as `[]` rather than a hard failure.
|
|
398
|
+
let driftFindings = [];
|
|
399
|
+
try {
|
|
400
|
+
const inputs = (0, drift_detection_1.loadDriftInputsForAgent)(bundlesRoot, agentName);
|
|
401
|
+
driftFindings = (0, drift_detection_1.detectProviderBindingDrift)({
|
|
402
|
+
agentName,
|
|
403
|
+
agentJson: inputs.agentJson,
|
|
404
|
+
providerState: inputs.providerState,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
/* v8 ignore next 1 -- defensive race-window guard: agent.json went away after state setup. Tested via Unit 5 integration when at all. @preserve */
|
|
409
|
+
driftFindings = [];
|
|
410
|
+
}
|
|
390
411
|
deps.onProgress?.(selectedProviderPlan(agentName, stateResult.state));
|
|
391
412
|
const ping = deps.pingProvider ?? (await Promise.resolve().then(() => __importStar(require("../provider-ping")))).pingProvider;
|
|
392
413
|
const shouldRecordReadiness = deps.recordReadiness ?? true;
|
|
@@ -402,16 +423,16 @@ async function checkAgentConfigWithProviderHealth(agentName, bundlesRoot, deps =
|
|
|
402
423
|
const binding = stateResult.state.lanes[lane];
|
|
403
424
|
if (!poolResult.ok) {
|
|
404
425
|
if (poolResult.reason === "missing") {
|
|
405
|
-
return missingCredentialResult(agentName, lane, binding.provider, binding.model, poolResult.poolPath);
|
|
426
|
+
return { ...missingCredentialResult(agentName, lane, binding.provider, binding.model, poolResult.poolPath), driftFindings };
|
|
406
427
|
}
|
|
407
|
-
return invalidPoolResult(agentName, lane, binding.provider, binding.model, {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
428
|
+
return { ...invalidPoolResult(agentName, lane, binding.provider, binding.model, {
|
|
429
|
+
...poolResult,
|
|
430
|
+
reason: poolResult.reason,
|
|
431
|
+
}), driftFindings };
|
|
411
432
|
}
|
|
412
433
|
const record = credentialRecordForLane(poolResult.pool, binding.provider);
|
|
413
434
|
if (!record) {
|
|
414
|
-
return missingCredentialResult(agentName, lane, binding.provider, binding.model, poolResult.poolPath);
|
|
435
|
+
return { ...missingCredentialResult(agentName, lane, binding.provider, binding.model, poolResult.poolPath), driftFindings };
|
|
415
436
|
}
|
|
416
437
|
const key = `${binding.provider}\0${binding.model}\0${record.revision}`;
|
|
417
438
|
const group = pingGroups.get(key);
|
|
@@ -475,7 +496,7 @@ async function checkAgentConfigWithProviderHealth(agentName, bundlesRoot, deps =
|
|
|
475
496
|
}
|
|
476
497
|
}
|
|
477
498
|
if (firstFailure)
|
|
478
|
-
return firstFailure;
|
|
499
|
+
return { ...firstFailure, driftFindings };
|
|
479
500
|
(0, runtime_1.emitNervesEvent)({
|
|
480
501
|
component: "daemon",
|
|
481
502
|
event: "daemon.agent_config_valid",
|
|
@@ -484,7 +505,8 @@ async function checkAgentConfigWithProviderHealth(agentName, bundlesRoot, deps =
|
|
|
484
505
|
agent: agentName,
|
|
485
506
|
providers: [...new Set([...pingGroups.values()].map((group) => group.provider))],
|
|
486
507
|
liveProviderCheck: true,
|
|
508
|
+
driftCount: driftFindings.length,
|
|
487
509
|
},
|
|
488
510
|
});
|
|
489
|
-
return { ok: true };
|
|
511
|
+
return { ok: true, driftFindings };
|
|
490
512
|
}
|
|
@@ -40,6 +40,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
40
40
|
})();
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
42
|
exports.summarizeDaemonStartupFailure = summarizeDaemonStartupFailure;
|
|
43
|
+
exports.writeDriftAdvisorySummary = writeDriftAdvisorySummary;
|
|
44
|
+
exports.collectAgentDriftAdvisories = collectAgentDriftAdvisories;
|
|
43
45
|
exports.mergeStartupStability = mergeStartupStability;
|
|
44
46
|
exports.ensureDaemonRunning = ensureDaemonRunning;
|
|
45
47
|
exports.listGithubCopilotModels = listGithubCopilotModels;
|
|
@@ -94,6 +96,7 @@ const cli_help_1 = require("./cli-help");
|
|
|
94
96
|
const cli_render_1 = require("./cli-render");
|
|
95
97
|
const cli_defaults_1 = require("./cli-defaults");
|
|
96
98
|
const agent_config_check_1 = require("./agent-config-check");
|
|
99
|
+
const drift_detection_1 = require("./drift-detection");
|
|
97
100
|
const doctor_1 = require("./doctor");
|
|
98
101
|
const cli_render_doctor_1 = require("./cli-render-doctor");
|
|
99
102
|
const interactive_repair_1 = require("./interactive-repair");
|
|
@@ -408,6 +411,61 @@ function writeProviderRepairSummary(deps, title, degraded) {
|
|
|
408
411
|
const blocks = degraded.map((entry) => (0, readiness_repair_1.renderReadinessIssueNextSteps)(readinessIssueFromDegraded(entry)).join("\n"));
|
|
409
412
|
deps.writeStdout([title, ...blocks].join("\n\n"));
|
|
410
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Layer 4: render a per-agent drift advisory block to stdout. Called from
|
|
416
|
+
* the `--no-repair` summary path when one or more enabled agents have a
|
|
417
|
+
* mismatch between `agent.json` (intent) and `state/providers.json`
|
|
418
|
+
* (observation). The block surfaces the lane, intent vs observed
|
|
419
|
+
* binding, and the copy-pasteable `ouro use` repair command per finding.
|
|
420
|
+
*
|
|
421
|
+
* Drift is advisory: this helper only PRINTS the advisory; running the
|
|
422
|
+
* repair command is the operator's call (and is Layer 3 RepairGuide's
|
|
423
|
+
* domain when the daemon does it automatically).
|
|
424
|
+
*
|
|
425
|
+
* Exported for direct unit-testing; the production callers are
|
|
426
|
+
* inside this module.
|
|
427
|
+
*/
|
|
428
|
+
function writeDriftAdvisorySummary(deps, advisories) {
|
|
429
|
+
if (advisories.length === 0)
|
|
430
|
+
return;
|
|
431
|
+
const lines = ["Drift advisory: agent intent does not match this machine's observed binding"];
|
|
432
|
+
for (const advisory of advisories) {
|
|
433
|
+
lines.push("");
|
|
434
|
+
lines.push(` ${advisory.agent} (${advisory.lane}):`);
|
|
435
|
+
lines.push(` intent: ${advisory.intentProvider}/${advisory.intentModel}`);
|
|
436
|
+
lines.push(` observed: ${advisory.observedProvider}/${advisory.observedModel}`);
|
|
437
|
+
lines.push(` repair: ${advisory.repairCommand}`);
|
|
438
|
+
}
|
|
439
|
+
deps.writeStdout(lines.join("\n"));
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Collect drift findings across every enabled agent in the bundles
|
|
443
|
+
* directory. Used by the `--no-repair` summary path so that drift
|
|
444
|
+
* advisories ride along with (or stand alone in place of) the existing
|
|
445
|
+
* provider-repair summary. Errors during a single agent's load (e.g.
|
|
446
|
+
* malformed `agent.json`) are swallowed: drift detection is advisory
|
|
447
|
+
* and must not block the rest of the boot path.
|
|
448
|
+
*/
|
|
449
|
+
async function collectAgentDriftAdvisories(deps) {
|
|
450
|
+
const agents = await listCliAgents(deps);
|
|
451
|
+
const bundlesRoot = deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)();
|
|
452
|
+
const findings = [];
|
|
453
|
+
for (const agent of [...new Set(agents)]) {
|
|
454
|
+
try {
|
|
455
|
+
const inputs = (0, drift_detection_1.loadDriftInputsForAgent)(bundlesRoot, agent);
|
|
456
|
+
const agentFindings = (0, drift_detection_1.detectProviderBindingDrift)({
|
|
457
|
+
agentName: agent,
|
|
458
|
+
agentJson: inputs.agentJson,
|
|
459
|
+
providerState: inputs.providerState,
|
|
460
|
+
});
|
|
461
|
+
findings.push(...agentFindings);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// Best-effort: a per-agent read failure is not blocking.
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return findings;
|
|
468
|
+
}
|
|
411
469
|
function providerRepairCountSummary(count) {
|
|
412
470
|
if (count === 0)
|
|
413
471
|
return "selected providers answered live checks";
|
|
@@ -5661,6 +5719,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
5661
5719
|
progress.end();
|
|
5662
5720
|
if (command.noRepair) {
|
|
5663
5721
|
writeProviderRepairSummary(deps, "Provider checks need attention", preflightProviderDegraded);
|
|
5722
|
+
// Layer 4: drift advisories ride along with the provider-repair
|
|
5723
|
+
// summary under --no-repair. Non-blocking; failure to collect
|
|
5724
|
+
// findings (e.g. malformed agent.json on one bundle) is swallowed
|
|
5725
|
+
// by `collectAgentDriftAdvisories` so the rest of the boot path
|
|
5726
|
+
// is unaffected.
|
|
5727
|
+
const driftAdvisories = await collectAgentDriftAdvisories(deps);
|
|
5728
|
+
writeDriftAdvisorySummary(deps, driftAdvisories);
|
|
5664
5729
|
const message = "daemon not started: provider checks need repair. Run `ouro repair` or rerun `ouro up` to choose a repair path.";
|
|
5665
5730
|
return returnCliFailure(deps, message);
|
|
5666
5731
|
}
|
|
@@ -5716,12 +5781,19 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
5716
5781
|
if (command.noRepair) {
|
|
5717
5782
|
// --no-repair: write degraded summary and skip interactive repair
|
|
5718
5783
|
writeProviderRepairSummary(deps, "Provider checks need attention", daemonResult.stability.degraded);
|
|
5784
|
+
// Layer 4: drift advisories ride along with the post-startup
|
|
5785
|
+
// degraded summary too — same rationale as the preflight path.
|
|
5786
|
+
const driftAdvisories = await collectAgentDriftAdvisories(deps);
|
|
5787
|
+
writeDriftAdvisorySummary(deps, driftAdvisories);
|
|
5719
5788
|
(0, runtime_1.emitNervesEvent)({
|
|
5720
5789
|
level: "warn",
|
|
5721
5790
|
component: "daemon",
|
|
5722
5791
|
event: "daemon.no_repair_degraded_summary",
|
|
5723
5792
|
message: "degraded agents detected with --no-repair, skipping interactive repair",
|
|
5724
|
-
meta: {
|
|
5793
|
+
meta: {
|
|
5794
|
+
degradedCount: daemonResult.stability.degraded.length,
|
|
5795
|
+
driftCount: driftAdvisories.length,
|
|
5796
|
+
},
|
|
5725
5797
|
});
|
|
5726
5798
|
}
|
|
5727
5799
|
else {
|
|
@@ -5796,6 +5868,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
5796
5868
|
}
|
|
5797
5869
|
}
|
|
5798
5870
|
}
|
|
5871
|
+
else if (command.noRepair) {
|
|
5872
|
+
// Layer 4: no degraded agents to summarize, but --no-repair still
|
|
5873
|
+
// surfaces drift advisories so the operator sees them without
|
|
5874
|
+
// having to run `ouro inner status` per agent.
|
|
5875
|
+
const driftAdvisories = await collectAgentDriftAdvisories(deps);
|
|
5876
|
+
writeDriftAdvisorySummary(deps, driftAdvisories);
|
|
5877
|
+
}
|
|
5799
5878
|
// Persist boot startup AFTER daemon is running — bootstrap is safe now
|
|
5800
5879
|
// because the daemon socket exists, so launchd's KeepAlive registers
|
|
5801
5880
|
// for crash recovery without starting a competing process.
|
|
@@ -6903,6 +6982,22 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
6903
6982
|
catch { /* no habits — heartbeat unknown */ }
|
|
6904
6983
|
// Attention count
|
|
6905
6984
|
const activeObligations = listActiveReturnObligations(command.agent);
|
|
6985
|
+
// Layer 4 drift findings for this agent. Best-effort: if agent.json
|
|
6986
|
+
// is unreadable mid-call we just suppress the advisory rather than
|
|
6987
|
+
// failing the inner-status render.
|
|
6988
|
+
let driftFindings = [];
|
|
6989
|
+
try {
|
|
6990
|
+
const bundlesRoot = deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)();
|
|
6991
|
+
const inputs = (0, drift_detection_1.loadDriftInputsForAgent)(bundlesRoot, command.agent);
|
|
6992
|
+
driftFindings = (0, drift_detection_1.detectProviderBindingDrift)({
|
|
6993
|
+
agentName: command.agent,
|
|
6994
|
+
agentJson: inputs.agentJson,
|
|
6995
|
+
providerState: inputs.providerState,
|
|
6996
|
+
});
|
|
6997
|
+
}
|
|
6998
|
+
catch {
|
|
6999
|
+
driftFindings = [];
|
|
7000
|
+
}
|
|
6906
7001
|
const message = buildInnerStatusOutput({
|
|
6907
7002
|
agentName: command.agent,
|
|
6908
7003
|
runtimeState,
|
|
@@ -6910,6 +7005,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
6910
7005
|
heartbeat,
|
|
6911
7006
|
attentionCount: activeObligations.length,
|
|
6912
7007
|
now: Date.now(),
|
|
7008
|
+
driftFindings,
|
|
6913
7009
|
});
|
|
6914
7010
|
deps.writeStdout(message);
|
|
6915
7011
|
return message;
|
|
@@ -45,6 +45,7 @@ exports.formatTable = formatTable;
|
|
|
45
45
|
exports.formatDaemonStatusOutput = formatDaemonStatusOutput;
|
|
46
46
|
exports.formatVersionOutput = formatVersionOutput;
|
|
47
47
|
exports.buildStoppedStatusPayload = buildStoppedStatusPayload;
|
|
48
|
+
exports.renderRollupStatusLine = renderRollupStatusLine;
|
|
48
49
|
exports.daemonUnavailableStatusOutput = daemonUnavailableStatusOutput;
|
|
49
50
|
exports.isDaemonUnavailableError = isDaemonUnavailableError;
|
|
50
51
|
exports.formatMcpResponse = formatMcpResponse;
|
|
@@ -472,6 +473,93 @@ function buildStoppedStatusPayload(socketPath, syncRows = [], agentRows = []) {
|
|
|
472
473
|
providers: [],
|
|
473
474
|
};
|
|
474
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* Render the cached daemon-rollup status as a one-line string for the
|
|
478
|
+
* "daemon not running" view. Each `DaemonStatus` literal maps to a
|
|
479
|
+
* label + a brief explanatory copy fragment. The default branch is
|
|
480
|
+
* `never`-typed so future widening of `DaemonStatus` compile-errors
|
|
481
|
+
* here — Layer 1's compiler-forced exhaustiveness contract.
|
|
482
|
+
*
|
|
483
|
+
* The `degraded` literal splits into two copy variants based on the
|
|
484
|
+
* cached health file's `agents` map:
|
|
485
|
+
* - empty map → "no agents configured" (fresh-install copy).
|
|
486
|
+
* - non-empty map → "none ready" (all-agents-failed-live-check copy).
|
|
487
|
+
*
|
|
488
|
+
* The split lives at the render layer (not in the rollup status itself)
|
|
489
|
+
* so the same status can carry distinct UX copy without inflating the
|
|
490
|
+
* type union.
|
|
491
|
+
*/
|
|
492
|
+
function renderRollupStatusLine(health) {
|
|
493
|
+
const status = health.status;
|
|
494
|
+
const tail = `(pid ${health.pid}, uptime ${health.uptimeSeconds}s)`;
|
|
495
|
+
/* v8 ignore next -- v8 instruments the switch statement itself as a branch; the never-typed default below is unreachable by construction so v8 cannot observe its branch firing @preserve */
|
|
496
|
+
switch (status) {
|
|
497
|
+
case "healthy":
|
|
498
|
+
return `Last known status: healthy ${tail}`;
|
|
499
|
+
case "partial": {
|
|
500
|
+
// `partial` arises from two independent sources:
|
|
501
|
+
// (a) some enabled agents not in `running` state, or
|
|
502
|
+
// (b) Layer-4 drift between agent.json (intent) and state/providers.json (observed).
|
|
503
|
+
// Either or both can be true. Render mentions both when applicable so
|
|
504
|
+
// the operator isn't misled into thinking agents are unhealthy when the
|
|
505
|
+
// only anomaly is configuration drift.
|
|
506
|
+
const unhealthyCount = Object.values(health.agents).filter((agent) => agent.status !== "running").length;
|
|
507
|
+
const driftCount = (health.drift ?? []).length;
|
|
508
|
+
const parts = [];
|
|
509
|
+
if (unhealthyCount > 0) {
|
|
510
|
+
parts.push(`${unhealthyCount} agent${unhealthyCount === 1 ? "" : "s"} unhealthy`);
|
|
511
|
+
}
|
|
512
|
+
if (driftCount > 0) {
|
|
513
|
+
parts.push(`drift on ${driftCount} agent${driftCount === 1 ? "" : "s"}`);
|
|
514
|
+
}
|
|
515
|
+
// Fallback for legacy cached files: status="partial" with no
|
|
516
|
+
// visible cause (no unhealthy agents AND no drift array). This
|
|
517
|
+
// happens when the file pre-dates Layer 4 and the daemon flagged
|
|
518
|
+
// partial via some signal that's no longer expressible. Prompt
|
|
519
|
+
// refresh via `ouro up` rather than asserting a specific cause.
|
|
520
|
+
const detail = parts.length > 0
|
|
521
|
+
? ` — ${parts.join("; ")}`
|
|
522
|
+
: " — stale cache, run `ouro up` to refresh";
|
|
523
|
+
return `Last known status: partial${detail} ${tail}`;
|
|
524
|
+
}
|
|
525
|
+
case "degraded": {
|
|
526
|
+
// Three-way copy split based on the cached agents map:
|
|
527
|
+
// - empty map → fresh install / no agents configured.
|
|
528
|
+
// - non-empty + any agent reports "running" → legacy stale cache:
|
|
529
|
+
// pre-Layer-1, status="degraded" meant "any sick component," so a
|
|
530
|
+
// running agent could coexist with a degraded daemon. Post-Layer-1,
|
|
531
|
+
// degraded means "zero serving" — mutually exclusive with a running
|
|
532
|
+
// agent. A live disagreement therefore implies the cache pre-dates
|
|
533
|
+
// the rollup-semantics fix; prompt for `ouro up` to refresh rather
|
|
534
|
+
// than falsely claim "none ready."
|
|
535
|
+
// - non-empty + zero agents reporting "running" → all-failed copy.
|
|
536
|
+
const agentEntries = Object.values(health.agents);
|
|
537
|
+
if (agentEntries.length === 0) {
|
|
538
|
+
return `Last known status: degraded — no agents configured (run \`ouro hatch\` to add one) ${tail}`;
|
|
539
|
+
}
|
|
540
|
+
const anyRunning = agentEntries.some((agent) => agent.status === "running");
|
|
541
|
+
if (anyRunning) {
|
|
542
|
+
return `Last known status: degraded — stale cache, run \`ouro up\` to refresh ${tail}`;
|
|
543
|
+
}
|
|
544
|
+
return `Last known status: degraded — agents configured but none ready (run \`ouro doctor\`) ${tail}`;
|
|
545
|
+
}
|
|
546
|
+
case "safe-mode":
|
|
547
|
+
return `Last known status: safe-mode — crash loop tripped ${tail}`;
|
|
548
|
+
case "down":
|
|
549
|
+
return `Last known status: down ${tail}`;
|
|
550
|
+
/* v8 ignore start -- compiler-forced exhaustiveness: the never-typed default branch is unreachable by construction; if DaemonStatus widens, tsc errors at the assignment before the throw can run @preserve */
|
|
551
|
+
default: {
|
|
552
|
+
// Compiler-forced exhaustiveness. If DaemonStatus grows a new
|
|
553
|
+
// literal, this `never` cast errors at tsc, forcing every
|
|
554
|
+
// consumer to handle it explicitly. NEVER replace this with a
|
|
555
|
+
// permissive `default:` returning a fallback string — that's
|
|
556
|
+
// exactly how the old "ok | degraded" semantics leaked through.
|
|
557
|
+
const _exhaustive = status;
|
|
558
|
+
throw new Error(`unhandled daemon status: ${_exhaustive}`);
|
|
559
|
+
}
|
|
560
|
+
/* v8 ignore stop */
|
|
561
|
+
}
|
|
562
|
+
}
|
|
475
563
|
function daemonUnavailableStatusOutput(socketPath, healthFilePath) {
|
|
476
564
|
// Read per-agent sync config and bundle list from disk so the user still
|
|
477
565
|
// sees them when the daemon is down. Best-effort: any fs error returns []
|
|
@@ -515,7 +603,7 @@ function daemonUnavailableStatusOutput(socketPath, healthFilePath) {
|
|
|
515
603
|
const resolvedHealthPath = healthFilePath ?? (0, daemon_health_1.getDefaultHealthPath)();
|
|
516
604
|
const health = (0, daemon_health_1.readHealth)(resolvedHealthPath);
|
|
517
605
|
if (health) {
|
|
518
|
-
lines.push(
|
|
606
|
+
lines.push(renderRollupStatusLine(health));
|
|
519
607
|
if (health.safeMode?.active) {
|
|
520
608
|
lines.push(`SAFE MODE: ${health.safeMode.reason}`);
|
|
521
609
|
}
|
|
@@ -43,6 +43,7 @@ const index_1 = require("../../nerves/index");
|
|
|
43
43
|
const message_router_1 = require("./message-router");
|
|
44
44
|
const health_monitor_1 = require("./health-monitor");
|
|
45
45
|
const daemon_health_1 = require("./daemon-health");
|
|
46
|
+
const daemon_rollup_1 = require("./daemon-rollup");
|
|
46
47
|
const task_scheduler_1 = require("./task-scheduler");
|
|
47
48
|
const runtime_logging_1 = require("./runtime-logging");
|
|
48
49
|
const sense_manager_1 = require("./sense-manager");
|
|
@@ -55,6 +56,7 @@ const os_cron_deps_1 = require("./os-cron-deps");
|
|
|
55
56
|
const os_cron_1 = require("./os-cron");
|
|
56
57
|
const daemon_tombstone_1 = require("./daemon-tombstone");
|
|
57
58
|
const agent_config_check_1 = require("./agent-config-check");
|
|
59
|
+
const drift_detection_1 = require("./drift-detection");
|
|
58
60
|
const pulse_1 = require("./pulse");
|
|
59
61
|
const socket_client_1 = require("./socket-client");
|
|
60
62
|
const bundle_manifest_1 = require("../../mind/bundle-manifest");
|
|
@@ -173,18 +175,75 @@ function buildDaemonHealthState() {
|
|
|
173
175
|
since: snapshot.lastCrashAt ?? daemonStartedAt,
|
|
174
176
|
};
|
|
175
177
|
});
|
|
178
|
+
// Preserved for backwards-compatible inspection: callers (status
|
|
179
|
+
// command, outlook surface, etc.) may still read this combined list
|
|
180
|
+
// for per-component reasons. The rollup status field above is what
|
|
181
|
+
// changed meaning — the array is still the union of bootstrap +
|
|
182
|
+
// agent-derived degradation entries.
|
|
176
183
|
const degraded = [
|
|
177
184
|
...degradedComponents.map((entry) => ({ ...entry })),
|
|
178
185
|
...agentDegradedComponents,
|
|
179
186
|
];
|
|
187
|
+
// Layer 4: probe each enabled agent for drift between agent.json
|
|
188
|
+
// (intent) and state/providers.json (observed binding). The result is
|
|
189
|
+
// a single boolean — driftDetected — that downgrades the rollup from
|
|
190
|
+
// healthy to partial when true. Per-finding detail is consumed by
|
|
191
|
+
// render-side surfaces (inner-status, --no-repair summary) and by
|
|
192
|
+
// Layer 3 RepairGuide; the rollup itself only cares about presence.
|
|
193
|
+
// Best-effort: a single agent's read failure is not blocking — the
|
|
194
|
+
// function returns "no drift detected for that agent" and continues.
|
|
195
|
+
const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
|
|
196
|
+
const drift = [];
|
|
197
|
+
for (const snapshot of snapshots) {
|
|
198
|
+
try {
|
|
199
|
+
const inputs = (0, drift_detection_1.loadDriftInputsForAgent)(bundlesRoot, snapshot.name);
|
|
200
|
+
const findings = (0, drift_detection_1.detectProviderBindingDrift)({
|
|
201
|
+
agentName: snapshot.name,
|
|
202
|
+
agentJson: inputs.agentJson,
|
|
203
|
+
providerState: inputs.providerState,
|
|
204
|
+
});
|
|
205
|
+
if (findings.length > 0) {
|
|
206
|
+
drift.push(...findings);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// best-effort: continue scanning
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const driftDetected = drift.length > 0;
|
|
214
|
+
// Layer 1 rollup: project per-agent snapshots into the minimal
|
|
215
|
+
// AgentRollupInput shape and let computeDaemonRollup decide. The
|
|
216
|
+
// input is "every enabled agent" — managedAgents was filtered via
|
|
217
|
+
// listEnabledBundleAgents at module init, and snapshots only covers
|
|
218
|
+
// agents the process manager was told to manage, so by construction
|
|
219
|
+
// these entries are all enabled. The rollup function is a pure
|
|
220
|
+
// declarative function on the data we hand it.
|
|
221
|
+
//
|
|
222
|
+
// Note: safe-mode is wired as `false` here. Existing crash-loop
|
|
223
|
+
// detection (safe-mode.ts) already runs at the daemon-up boot path
|
|
224
|
+
// (cli-exec.ts), not from inside the daemon process itself. Once
|
|
225
|
+
// the daemon is up and reaching this rollup, safe mode no longer
|
|
226
|
+
// applies — the daemon is by definition past the crash-loop gate.
|
|
227
|
+
// If a future PR moves safe-mode signal into the running daemon,
|
|
228
|
+
// wire it through this third argument.
|
|
229
|
+
const rollupStatus = (0, daemon_rollup_1.computeDaemonRollup)({
|
|
230
|
+
enabledAgents: snapshots.map((snapshot) => ({
|
|
231
|
+
name: snapshot.name,
|
|
232
|
+
status: snapshot.status,
|
|
233
|
+
})),
|
|
234
|
+
bootstrapDegraded: degradedComponents,
|
|
235
|
+
safeMode: false,
|
|
236
|
+
driftDetected,
|
|
237
|
+
});
|
|
180
238
|
return {
|
|
181
|
-
status:
|
|
239
|
+
status: rollupStatus,
|
|
182
240
|
mode,
|
|
183
241
|
pid: process.pid,
|
|
184
242
|
startedAt: daemonStartedAt,
|
|
185
243
|
uptimeSeconds: Math.floor(process.uptime()),
|
|
186
244
|
safeMode: null,
|
|
187
245
|
degraded,
|
|
246
|
+
drift,
|
|
188
247
|
agents: Object.fromEntries(snapshots.map((snapshot) => [
|
|
189
248
|
snapshot.name,
|
|
190
249
|
{
|
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.HEALTH_TRACKED_EVENTS = exports.DaemonHealthWriter = void 0;
|
|
37
|
+
exports.isRollupStatus = isRollupStatus;
|
|
38
|
+
exports.isDaemonStatus = isDaemonStatus;
|
|
37
39
|
exports.getDefaultHealthPath = getDefaultHealthPath;
|
|
38
40
|
exports.createHealthNervesSink = createHealthNervesSink;
|
|
39
41
|
exports.readHealth = readHealth;
|
|
@@ -41,6 +43,38 @@ const fs = __importStar(require("fs"));
|
|
|
41
43
|
const os = __importStar(require("os"));
|
|
42
44
|
const path = __importStar(require("path"));
|
|
43
45
|
const runtime_1 = require("../../nerves/runtime");
|
|
46
|
+
/**
|
|
47
|
+
* Daemon-wide rollup vocabulary — locked layer-1 contract.
|
|
48
|
+
*
|
|
49
|
+
* - `RollupStatus` is what `computeDaemonRollup` returns (post-inventory,
|
|
50
|
+
* four-state). The function never returns `"down"` because by the time
|
|
51
|
+
* the rollup is reachable the daemon has already started, opened its
|
|
52
|
+
* socket, and read its agent inventory — pre-inventory failure is the
|
|
53
|
+
* caller's domain.
|
|
54
|
+
* - `DaemonStatus` is what `DaemonHealthState.status` accepts. The caller
|
|
55
|
+
* widens the rollup result with `"down"` along the daemon-entry failure
|
|
56
|
+
* path (e.g. when the daemon process can't read inventory at all).
|
|
57
|
+
*
|
|
58
|
+
* `isRollupStatus` and `isDaemonStatus` are runtime guards used both by
|
|
59
|
+
* `readHealth` (validating cached health files on disk) and by render-side
|
|
60
|
+
* consumers that want to narrow `unknown` JSON into the typed union before
|
|
61
|
+
* branching on it.
|
|
62
|
+
*/
|
|
63
|
+
// Single source of truth — the literal lists below are the runtime
|
|
64
|
+
// projection of the type unions. A future literal added to RollupStatus
|
|
65
|
+
// MUST also be added to ROLLUP_STATUS_LITERALS or `satisfies` blows up
|
|
66
|
+
// at tsc. That tightens the Layer 1 contract: producer + consumer +
|
|
67
|
+
// guard all stay in lockstep.
|
|
68
|
+
const ROLLUP_STATUS_LITERALS = ["healthy", "partial", "degraded", "safe-mode"];
|
|
69
|
+
const DAEMON_STATUS_LITERALS = [...ROLLUP_STATUS_LITERALS, "down"];
|
|
70
|
+
const ROLLUP_STATUS_VALUES = new Set(ROLLUP_STATUS_LITERALS);
|
|
71
|
+
const DAEMON_STATUS_VALUES = new Set(DAEMON_STATUS_LITERALS);
|
|
72
|
+
function isRollupStatus(value) {
|
|
73
|
+
return typeof value === "string" && ROLLUP_STATUS_VALUES.has(value);
|
|
74
|
+
}
|
|
75
|
+
function isDaemonStatus(value) {
|
|
76
|
+
return typeof value === "string" && DAEMON_STATUS_VALUES.has(value);
|
|
77
|
+
}
|
|
44
78
|
class DaemonHealthWriter {
|
|
45
79
|
healthPath;
|
|
46
80
|
constructor(healthPath) {
|
|
@@ -111,7 +145,7 @@ function readHealth(healthPath) {
|
|
|
111
145
|
try {
|
|
112
146
|
const raw = fs.readFileSync(healthPath, "utf-8");
|
|
113
147
|
const parsed = JSON.parse(raw);
|
|
114
|
-
if (
|
|
148
|
+
if (!isDaemonStatus(parsed.status) ||
|
|
115
149
|
typeof parsed.mode !== "string" ||
|
|
116
150
|
typeof parsed.pid !== "number" ||
|
|
117
151
|
typeof parsed.startedAt !== "string" ||
|
|
@@ -123,6 +157,12 @@ function readHealth(healthPath) {
|
|
|
123
157
|
parsed.habits === null) {
|
|
124
158
|
return null;
|
|
125
159
|
}
|
|
160
|
+
// `drift` is required in DaemonHealthState but absent from cached
|
|
161
|
+
// health files written by pre-Layer-4 daemons. Tolerate that legacy
|
|
162
|
+
// shape by defaulting to []; the rest of the file is still valid.
|
|
163
|
+
const drift = Array.isArray(parsed.drift)
|
|
164
|
+
? parsed.drift
|
|
165
|
+
: [];
|
|
126
166
|
return {
|
|
127
167
|
status: parsed.status,
|
|
128
168
|
mode: parsed.mode,
|
|
@@ -131,6 +171,7 @@ function readHealth(healthPath) {
|
|
|
131
171
|
uptimeSeconds: parsed.uptimeSeconds,
|
|
132
172
|
safeMode: parsed.safeMode,
|
|
133
173
|
degraded: parsed.degraded,
|
|
174
|
+
drift,
|
|
134
175
|
agents: parsed.agents,
|
|
135
176
|
habits: parsed.habits,
|
|
136
177
|
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeDaemonRollup = computeDaemonRollup;
|
|
4
|
+
/**
|
|
5
|
+
* Pure rollup decision function — given the post-inventory daemon
|
|
6
|
+
* surface, returns the daemon-wide rollup state per the locked Layer 1
|
|
7
|
+
* vocabulary table:
|
|
8
|
+
*
|
|
9
|
+
* | rollup | when |
|
|
10
|
+
* | ---------- | --------------------------------------------------------- |
|
|
11
|
+
* | healthy | every enabled agent serving + no bootstrap-degraded + no safe-mode |
|
|
12
|
+
* | partial | (≥1 serving + ≥1 not serving) OR (all serving + ≥1 bootstrap-degraded) |
|
|
13
|
+
* | degraded | zero enabled agents serving (fresh install OR all unhealthy) |
|
|
14
|
+
* | safe-mode | `safeMode === true` overrides everything else |
|
|
15
|
+
*
|
|
16
|
+
* The function NEVER returns `"down"`. By the time `computeDaemonRollup`
|
|
17
|
+
* is reachable, the daemon process has started, opened its socket, and
|
|
18
|
+
* read its agent inventory — pre-inventory failure is the caller's
|
|
19
|
+
* domain. `daemon-entry.ts`'s startup-failure path assigns `"down"` to
|
|
20
|
+
* `DaemonHealthState.status` directly without consulting this function.
|
|
21
|
+
*/
|
|
22
|
+
function computeDaemonRollup(input) {
|
|
23
|
+
// Safe mode wins, period. Crash-loop detection trumps everything —
|
|
24
|
+
// we want the human to see SAFE MODE, not a noisy partial/degraded.
|
|
25
|
+
if (input.safeMode) {
|
|
26
|
+
return "safe-mode";
|
|
27
|
+
}
|
|
28
|
+
// Count serving agents. "Serving" = "running" worker status.
|
|
29
|
+
// Anything else (crashed/stopped/starting/etc) is not serving.
|
|
30
|
+
let serving = 0;
|
|
31
|
+
let notServing = 0;
|
|
32
|
+
for (const agent of input.enabledAgents) {
|
|
33
|
+
if (agent.status === "running") {
|
|
34
|
+
serving++;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
notServing++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Zero-serving wins over bootstrap-degraded — we have no working
|
|
41
|
+
// agents to surface a "partially working" story about. This covers
|
|
42
|
+
// both fresh-install (`enabledAgents.length === 0`) and
|
|
43
|
+
// all-failed-live-check (`serving === 0` with `notServing > 0`).
|
|
44
|
+
// Render layer (cli-render.ts) splits the UX copy by inspecting the
|
|
45
|
+
// agents map; the rollup itself doesn't carry the distinction.
|
|
46
|
+
if (serving === 0) {
|
|
47
|
+
return "degraded";
|
|
48
|
+
}
|
|
49
|
+
// From here we have ≥1 serving agent. The remaining choice is
|
|
50
|
+
// healthy vs partial.
|
|
51
|
+
const hasUnhealthyAgent = notServing > 0;
|
|
52
|
+
const hasBootstrapDegraded = input.bootstrapDegraded.length > 0;
|
|
53
|
+
const hasDrift = input.driftDetected === true;
|
|
54
|
+
if (hasUnhealthyAgent || hasBootstrapDegraded || hasDrift) {
|
|
55
|
+
return "partial";
|
|
56
|
+
}
|
|
57
|
+
return "healthy";
|
|
58
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.detectProviderBindingDrift = detectProviderBindingDrift;
|
|
37
|
+
exports.loadDriftInputsForAgent = loadDriftInputsForAgent;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const provider_state_1 = require("../provider-state");
|
|
41
|
+
/**
|
|
42
|
+
* Pull the per-lane intent out of an agent.json view, preferring the new
|
|
43
|
+
* `outward`/`inner` keys over the legacy `humanFacing`/`agentFacing` keys
|
|
44
|
+
* when both are present. Returns `null` if neither set carries a usable
|
|
45
|
+
* binding for this lane (the comparator treats that as "no intent to
|
|
46
|
+
* compare against" and emits no drift for that lane).
|
|
47
|
+
*/
|
|
48
|
+
function resolveLaneIntent(agentJson, lane) {
|
|
49
|
+
const newKey = lane === "outward" ? agentJson.outward : agentJson.inner;
|
|
50
|
+
if (newKey && typeof newKey.provider === "string" && typeof newKey.model === "string") {
|
|
51
|
+
return { provider: newKey.provider, model: newKey.model };
|
|
52
|
+
}
|
|
53
|
+
const legacy = lane === "outward" ? agentJson.humanFacing : agentJson.agentFacing;
|
|
54
|
+
if (legacy && typeof legacy.provider === "string" && typeof legacy.model === "string") {
|
|
55
|
+
return { provider: legacy.provider, model: legacy.model };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function buildRepairCommand(agentName, lane, provider, model) {
|
|
60
|
+
return `ouro use --agent ${agentName} --lane ${lane} --provider ${provider} --model ${model}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Pure intent-vs-observed comparator. Emits one `DriftFinding` per lane
|
|
64
|
+
* whose intent (agent.json) does not match its observation
|
|
65
|
+
* (state/providers.json).
|
|
66
|
+
*
|
|
67
|
+
* Returns `[]` when:
|
|
68
|
+
* - `providerState === null` (fresh install, nothing to drift against), OR
|
|
69
|
+
* - both lanes match, OR
|
|
70
|
+
* - a lane has no intent in agent.json AND no observation in providerState
|
|
71
|
+
* (deferred to other layers — drift detection is silent on that lane).
|
|
72
|
+
*/
|
|
73
|
+
function detectProviderBindingDrift(input) {
|
|
74
|
+
if (input.providerState === null) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const findings = [];
|
|
78
|
+
const lanes = ["outward", "inner"];
|
|
79
|
+
for (const lane of lanes) {
|
|
80
|
+
const intent = resolveLaneIntent(input.agentJson, lane);
|
|
81
|
+
if (!intent)
|
|
82
|
+
continue;
|
|
83
|
+
const observed = input.providerState.lanes[lane];
|
|
84
|
+
if (intent.provider === observed.provider && intent.model === observed.model) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
findings.push({
|
|
88
|
+
agent: input.agentName,
|
|
89
|
+
lane,
|
|
90
|
+
intentProvider: intent.provider,
|
|
91
|
+
intentModel: intent.model,
|
|
92
|
+
observedProvider: observed.provider,
|
|
93
|
+
observedModel: observed.model,
|
|
94
|
+
reason: "provider-model-changed",
|
|
95
|
+
repairCommand: buildRepairCommand(input.agentName, lane, intent.provider, intent.model),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
100
|
+
function agentRootFor(bundlesRoot, agentName) {
|
|
101
|
+
return path.join(bundlesRoot, `${agentName}.ouro`);
|
|
102
|
+
}
|
|
103
|
+
function readAgentJson(agentJsonPath) {
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = fs.readFileSync(agentJsonPath, "utf-8");
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new Error(`agent.json not found at ${agentJsonPath}`);
|
|
110
|
+
}
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(raw);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
throw new Error(`agent.json at ${agentJsonPath} contains invalid JSON: ${String(error)}`);
|
|
117
|
+
}
|
|
118
|
+
// The drift loader is intentionally permissive: it parses the file as a
|
|
119
|
+
// typed `AgentConfig` view but does not validate every field. The
|
|
120
|
+
// comparator (Unit 1) is the one that decides which intent fields are
|
|
121
|
+
// usable; non-conforming bindings just silently skip drift detection
|
|
122
|
+
// on that lane. Stricter validation belongs to `loadAgentConfig` in
|
|
123
|
+
// identity.ts (which also has side effects we don't want here).
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Loader for the drift comparator. Reads the per-agent `agent.json` and
|
|
128
|
+
* `state/providers.json` off disk, returning typed inputs ready to feed
|
|
129
|
+
* into `detectProviderBindingDrift`.
|
|
130
|
+
*
|
|
131
|
+
* - Throws when `agent.json` is missing or unparseable. The caller decides
|
|
132
|
+
* whether to swallow (drift detection has no opinion on a broken
|
|
133
|
+
* `agent.json` — that's the existing `agent-config-check` flow's job).
|
|
134
|
+
* - Returns `providerState: null` when `state/providers.json` is missing
|
|
135
|
+
* (fresh install) or invalid (the comparator interprets `null` as "no
|
|
136
|
+
* observation, nothing to drift against").
|
|
137
|
+
* - Never writes to disk.
|
|
138
|
+
*/
|
|
139
|
+
function loadDriftInputsForAgent(bundlesRoot, agentName) {
|
|
140
|
+
const agentRoot = agentRootFor(bundlesRoot, agentName);
|
|
141
|
+
const agentJsonPath = path.join(agentRoot, "agent.json");
|
|
142
|
+
const agentJson = readAgentJson(agentJsonPath);
|
|
143
|
+
const stateResult = (0, provider_state_1.readProviderState)(agentRoot);
|
|
144
|
+
const providerState = stateResult.ok ? stateResult.state : null;
|
|
145
|
+
return { agentJson, providerState };
|
|
146
|
+
}
|
|
@@ -74,6 +74,18 @@ function buildInnerStatusOutput(input) {
|
|
|
74
74
|
// Attention
|
|
75
75
|
const thoughtWord = attentionCount === 1 ? "thought" : "thoughts";
|
|
76
76
|
lines.push(` attention: ${attentionCount} held ${thoughtWord}`);
|
|
77
|
+
// Layer 4 drift advisory. Renders one line per finding with the
|
|
78
|
+
// lane, intent vs observed binding, and the copy-pasteable repair
|
|
79
|
+
// command. Suppressed entirely when no findings exist (or the field
|
|
80
|
+
// is absent — pre-Layer-4 callers).
|
|
81
|
+
const driftFindings = input.driftFindings ?? [];
|
|
82
|
+
if (driftFindings.length > 0) {
|
|
83
|
+
lines.push(" drift advisory:");
|
|
84
|
+
for (const finding of driftFindings) {
|
|
85
|
+
lines.push(` - ${finding.lane}: intent ${finding.intentProvider}/${finding.intentModel} vs observed ${finding.observedProvider}/${finding.observedModel}`);
|
|
86
|
+
lines.push(` repair: ${finding.repairCommand}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
77
89
|
(0, runtime_1.emitNervesEvent)({
|
|
78
90
|
component: "daemon",
|
|
79
91
|
event: "daemon.inner_status_read",
|
|
@@ -83,6 +95,7 @@ function buildInnerStatusOutput(input) {
|
|
|
83
95
|
status: runtimeState?.status ?? "unknown",
|
|
84
96
|
journalCount: journalFiles.length,
|
|
85
97
|
attentionCount,
|
|
98
|
+
driftCount: driftFindings.length,
|
|
86
99
|
},
|
|
87
100
|
});
|
|
88
101
|
return lines.join("\n");
|
|
@@ -49,6 +49,7 @@ const runtime_1 = require("../../../nerves/runtime");
|
|
|
49
49
|
const habit_parser_1 = require("../../habits/habit-parser");
|
|
50
50
|
const habit_runtime_state_1 = require("../../habits/habit-runtime-state");
|
|
51
51
|
const identity_1 = require("../../identity");
|
|
52
|
+
const daemon_health_1 = require("../../daemon/daemon-health");
|
|
52
53
|
const shared_1 = require("./shared");
|
|
53
54
|
const agent_machine_1 = require("./agent-machine");
|
|
54
55
|
const sessions_1 = require("./sessions");
|
|
@@ -270,7 +271,12 @@ function readDaemonHealthDeep(healthPath) {
|
|
|
270
271
|
const raw = fs.readFileSync(resolvedPath, "utf-8");
|
|
271
272
|
const health = JSON.parse(raw);
|
|
272
273
|
return {
|
|
273
|
-
|
|
274
|
+
// Layer 1: tighten the parse so only post-Layer-1 vocabulary
|
|
275
|
+
// carries through. Stale cached files that still hold legacy
|
|
276
|
+
// string values like "ok" or "running" — written by an older
|
|
277
|
+
// daemon binary — fall back to "unknown" so downstream Outlook
|
|
278
|
+
// consumers can detect the unparseable case explicitly.
|
|
279
|
+
status: (0, daemon_health_1.isDaemonStatus)(health.status) ? health.status : "unknown",
|
|
274
280
|
mode: typeof health.mode === "string" ? health.mode : "unknown",
|
|
275
281
|
pid: typeof health.pid === "number" ? health.pid : 0,
|
|
276
282
|
startedAt: typeof health.startedAt === "string" ? health.startedAt : "",
|
|
@@ -87,6 +87,22 @@ const DISPATCH_EXEMPT_PATTERNS = [
|
|
|
87
87
|
// HTTP health probe: pure HTTP utility factory. The HealthMonitor caller
|
|
88
88
|
// owns observability via daemon.health_result events.
|
|
89
89
|
"daemon/http-health-probe",
|
|
90
|
+
// Rollup decision function: pure decision tree mapping per-agent
|
|
91
|
+
// snapshots + bootstrap-degraded entries + safe-mode flag to a
|
|
92
|
+
// RollupStatus. No side effects. The caller (daemon-entry.ts
|
|
93
|
+
// buildDaemonHealthState → DaemonHealthWriter) owns observability via
|
|
94
|
+
// daemon.health_written when the rolled-up state is persisted.
|
|
95
|
+
"daemon/daemon-rollup",
|
|
96
|
+
// Drift comparator + thin I/O loader: `detectProviderBindingDrift`
|
|
97
|
+
// is a pure intent-vs-observed comparator with no side effects;
|
|
98
|
+
// `loadDriftInputsForAgent` is a small fs-read wrapper that returns
|
|
99
|
+
// `null` on missing/invalid state rather than emitting. The caller
|
|
100
|
+
// (daemon-entry.ts buildDaemonHealthState's per-agent drift probe)
|
|
101
|
+
// owns observability — drift findings ride along through
|
|
102
|
+
// `daemon.health_written` as part of the rolled-up state, and
|
|
103
|
+
// `agent-config-check.ts` carries `driftFindings` through its
|
|
104
|
+
// existing instrumentation. Same pattern as `daemon-rollup`.
|
|
105
|
+
"daemon/drift-detection",
|
|
90
106
|
// Attachment helper modules: generic file-path/extension utilities and the
|
|
91
107
|
// source registry are pure support seams. The orchestrator/adapters that
|
|
92
108
|
// call them own the observability.
|