@ouro.bot/cli 0.1.0-alpha.516 → 0.1.0-alpha.518
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/boot-sync-probe.js +197 -0
- package/dist/heart/daemon/cli-exec.js +168 -3
- package/dist/heart/daemon/cli-render.js +26 -2
- package/dist/heart/daemon/daemon-entry.js +30 -0
- package/dist/heart/daemon/daemon-health.js +7 -0
- package/dist/heart/daemon/daemon-rollup.js +2 -1
- package/dist/heart/daemon/drift-detection.js +146 -0
- package/dist/heart/daemon/inner-status.js +13 -0
- package/dist/heart/sync-classification.js +176 -0
- package/dist/heart/sync.js +117 -0
- package/dist/heart/timeouts.js +101 -0
- package/dist/nerves/coverage/file-completeness.js +19 -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.518",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Layer 2 of the harness-hardening sequence (1→4→2→3 from `docs/planning/2026-04-28-1900-planning-harness-hardening-and-repairguide.md`). Wires a pre-flight `git pull` over every sync-enabled bundle into `ouro up`, before per-agent provider live-checks, so the post-pull `agent.json` is what live-check reads. First PR in the sequence that mutates working trees; does NOT write to `state/` (verified by a meta-test).",
|
|
8
|
+
"New sync failure taxonomy in `src/heart/sync-classification.ts`: `auth-failed`, `not-found-404`, `network-down`, `dirty-working-tree`, `non-fast-forward`, `merge-conflict`, `timeout-soft`, `timeout-hard`, `unknown` — extends `PendingSyncRecord.classification` additively (legacy `push_rejected`/`pull_rebase_conflict` still work). Pure pattern-matcher: priority order is abort → 404 → auth → network → dirty → conflict → non-fast-forward → unknown.",
|
|
9
|
+
"End-to-end `AbortSignal` plumbing. New `runWithTimeouts<T>` wrapper in `src/heart/timeouts.ts` (soft 8s warns, hard 15s aborts via `AbortController`); new async sibling `preTurnPullAsync` in `src/heart/sync.ts` that uses `child_process.execFile(..., { signal })` so the kernel kills the git child when the hard timeout fires. Original sync `preTurnPull` preserved for the per-turn pipeline. Two env knobs for the boot-sync probe: `OURO_BOOT_TIMEOUT_GIT_SOFT` (8000ms) and `OURO_BOOT_TIMEOUT_GIT_HARD` (15000ms).",
|
|
10
|
+
"New `runBootSyncProbe` orchestrator in `src/heart/daemon/boot-sync-probe.ts` aggregates per-bundle findings (each tagged `advisory: true|false`). Wired into `daemon.up` as a new \"sync probe\" boot phase between manual-clone-detection and provider checks. Failures during the probe itself are caught and surfaced as a warning event without blocking the boot. Tests inject `runBootSyncProbeImpl` to keep CI off the developer's home bundles.",
|
|
11
|
+
"9903 tests pass (518 files; +19 new). Coverage gate clean (cli-exec.ts 99.33% → 100%). Slow-remote integration test proves boot doesn't hang on a hung remote (probe aborts within `hardMs`). Unit 7 meta-test enforces no-state-writes invariant on the three new files."
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.1.0-alpha.517",
|
|
16
|
+
"changes": [
|
|
17
|
+
"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.",
|
|
18
|
+
"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).",
|
|
19
|
+
"`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.",
|
|
20
|
+
"`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.",
|
|
21
|
+
"`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.",
|
|
22
|
+
"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 `[]`).",
|
|
23
|
+
"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."
|
|
24
|
+
]
|
|
25
|
+
},
|
|
4
26
|
{
|
|
5
27
|
"version": "0.1.0-alpha.516",
|
|
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
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Boot sync probe — Layer 2 orchestrator for `ouro up`.
|
|
4
|
+
*
|
|
5
|
+
* For each sync-enabled bundle, runs `preTurnPullAsync` wrapped in
|
|
6
|
+
* `runWithTimeouts`, classifies failures via `classifySyncFailure`, and
|
|
7
|
+
* returns aggregated findings. Findings surface in the boot stdout
|
|
8
|
+
* summary written by `cli-exec.ts` after the probe phase.
|
|
9
|
+
*
|
|
10
|
+
* Layer 2 is intentionally a **boot-time preflight surface only** — it
|
|
11
|
+
* does NOT feed into the running daemon's rollup state. `computeDaemonRollup`
|
|
12
|
+
* is unaware of these findings; nothing is persisted to daemon health for
|
|
13
|
+
* the running daemon to consume. Layer 3 RepairGuide is the planned consumer
|
|
14
|
+
* (it reads the in-memory `BootSyncProbeFinding[]` at boot time and
|
|
15
|
+
* dispatches the right diagnostic skill).
|
|
16
|
+
*
|
|
17
|
+
* The probe NEVER:
|
|
18
|
+
* - Writes to `state/` (verified by Unit 7's grep gate).
|
|
19
|
+
* - Throws — every failure becomes a finding.
|
|
20
|
+
* - Hangs — `AbortSignal` from `runWithTimeouts` cuts hung remotes
|
|
21
|
+
* within `hardMs`.
|
|
22
|
+
*
|
|
23
|
+
* Advisory vs blocking classifications (consumed by layer 3, not by the
|
|
24
|
+
* layer 1 rollup — see comment above):
|
|
25
|
+
*
|
|
26
|
+
* | classification | advisory? | rationale |
|
|
27
|
+
* | ---------------------- | --------- | ------------------------------------------- |
|
|
28
|
+
* | auth-failed | no | agent can't sync; needs human intervention |
|
|
29
|
+
* | not-found-404 | no | remote is gone; needs human intervention |
|
|
30
|
+
* | network-down | no | agent can't reach the remote at all |
|
|
31
|
+
* | timeout-hard | no | abort cut the op; remote is hung |
|
|
32
|
+
* | dirty-working-tree | yes | local edits prevent merge; agent still runs |
|
|
33
|
+
* | non-fast-forward | yes | local commits ahead; agent still runs |
|
|
34
|
+
* | merge-conflict | yes | rebase failed; needs cleanup |
|
|
35
|
+
* | timeout-soft | yes | warning surfaced, op completed |
|
|
36
|
+
* | unknown | yes | unrecognised; surface for diagnosis |
|
|
37
|
+
*
|
|
38
|
+
* `advisory: true` is a hint for layer 3 ("warn-and-continue, agent likely
|
|
39
|
+
* still works") vs `advisory: false` ("blocking, agent can't sync until
|
|
40
|
+
* fixed"). Layer 3 will route the right diagnostic skill on this signal.
|
|
41
|
+
*/
|
|
42
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
43
|
+
if (k2 === undefined) k2 = k;
|
|
44
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
45
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
46
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
47
|
+
}
|
|
48
|
+
Object.defineProperty(o, k2, desc);
|
|
49
|
+
}) : (function(o, m, k, k2) {
|
|
50
|
+
if (k2 === undefined) k2 = k;
|
|
51
|
+
o[k2] = m[k];
|
|
52
|
+
}));
|
|
53
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
54
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
55
|
+
}) : function(o, v) {
|
|
56
|
+
o["default"] = v;
|
|
57
|
+
});
|
|
58
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
59
|
+
var ownKeys = function(o) {
|
|
60
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
61
|
+
var ar = [];
|
|
62
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
63
|
+
return ar;
|
|
64
|
+
};
|
|
65
|
+
return ownKeys(o);
|
|
66
|
+
};
|
|
67
|
+
return function (mod) {
|
|
68
|
+
if (mod && mod.__esModule) return mod;
|
|
69
|
+
var result = {};
|
|
70
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
71
|
+
__setModuleDefault(result, mod);
|
|
72
|
+
return result;
|
|
73
|
+
};
|
|
74
|
+
})();
|
|
75
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
76
|
+
exports.runBootSyncProbe = runBootSyncProbe;
|
|
77
|
+
const path = __importStar(require("path"));
|
|
78
|
+
const sync_1 = require("../sync");
|
|
79
|
+
const sync_classification_1 = require("../sync-classification");
|
|
80
|
+
const timeouts_1 = require("../timeouts");
|
|
81
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
82
|
+
/** Default soft / hard timeout windows for the boot git op. Matches Layer 2 O1 lock. */
|
|
83
|
+
const DEFAULT_SOFT_MS = 8000;
|
|
84
|
+
const DEFAULT_HARD_MS = 15000;
|
|
85
|
+
const BLOCKING_CLASSIFICATIONS = new Set([
|
|
86
|
+
"auth-failed",
|
|
87
|
+
"not-found-404",
|
|
88
|
+
"network-down",
|
|
89
|
+
"timeout-hard",
|
|
90
|
+
]);
|
|
91
|
+
function isAdvisory(classification) {
|
|
92
|
+
return !BLOCKING_CLASSIFICATIONS.has(classification);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Probe sync state for every enabled, git-initialised bundle. Returns
|
|
96
|
+
* findings for every non-clean result. Probes run sequentially (not in
|
|
97
|
+
* parallel) so the boot path's progress reporter has a stable per-agent
|
|
98
|
+
* narrative; the per-probe hard cap means worst-case total wait is
|
|
99
|
+
* `bundles.length * hardMs`, which is acceptable for typical 1-3 agent
|
|
100
|
+
* deployments.
|
|
101
|
+
*/
|
|
102
|
+
async function runBootSyncProbe(bundles, options) {
|
|
103
|
+
const startedAt = Date.now();
|
|
104
|
+
const softMs = options.softMs ?? DEFAULT_SOFT_MS;
|
|
105
|
+
const hardMs = options.hardMs ?? DEFAULT_HARD_MS;
|
|
106
|
+
(0, runtime_1.emitNervesEvent)({
|
|
107
|
+
component: "daemon",
|
|
108
|
+
event: "daemon.boot_sync_probe_start",
|
|
109
|
+
message: "boot sync probe starting",
|
|
110
|
+
meta: { bundleCount: bundles.length, softMs, hardMs },
|
|
111
|
+
});
|
|
112
|
+
const findings = [];
|
|
113
|
+
for (const bundle of bundles) {
|
|
114
|
+
if (!bundle.enabled)
|
|
115
|
+
continue;
|
|
116
|
+
const agentRoot = path.join(options.bundlesRoot, `${bundle.agent}.ouro`);
|
|
117
|
+
// gitInitialized=false: bundle is enabled for sync but never had `git init`.
|
|
118
|
+
// Surface as advisory finding without invoking git.
|
|
119
|
+
if (bundle.gitInitialized === false) {
|
|
120
|
+
findings.push({
|
|
121
|
+
agent: bundle.agent,
|
|
122
|
+
classification: "unknown",
|
|
123
|
+
error: `bundle is not a git repo; run \`git init\` inside ${agentRoot} to enable sync (or disable sync in agent.json)`,
|
|
124
|
+
conflictFiles: [],
|
|
125
|
+
warnings: [],
|
|
126
|
+
advisory: true,
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const syncConfig = { enabled: bundle.enabled, remote: bundle.remote };
|
|
131
|
+
const outcome = await (0, timeouts_1.runWithTimeouts)((signal) => (0, sync_1.preTurnPullAsync)(agentRoot, syncConfig, { signal }), { softMs, hardMs, label: `boot-sync-probe ${bundle.agent}`, envKey: "GIT" });
|
|
132
|
+
// Hard timeout — the probe was aborted. Synthesise a finding from the
|
|
133
|
+
// outcome's classification (the underlying `preTurnPullAsync` may also
|
|
134
|
+
// have rejected with an AbortError, but the wrapper already swallowed
|
|
135
|
+
// it).
|
|
136
|
+
if (outcome.classification === "timeout-hard") {
|
|
137
|
+
findings.push({
|
|
138
|
+
agent: bundle.agent,
|
|
139
|
+
classification: "timeout-hard",
|
|
140
|
+
error: `boot sync probe for ${bundle.agent} aborted after ${hardMs}ms hard timeout`,
|
|
141
|
+
conflictFiles: [],
|
|
142
|
+
warnings: outcome.warnings,
|
|
143
|
+
advisory: isAdvisory("timeout-hard"),
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const result = outcome.result;
|
|
148
|
+
/* v8 ignore start -- defensive: exclusive state from runWithTimeouts contract — either result or classification, never both undefined @preserve */
|
|
149
|
+
if (!result) {
|
|
150
|
+
findings.push({
|
|
151
|
+
agent: bundle.agent,
|
|
152
|
+
classification: "unknown",
|
|
153
|
+
error: "boot sync probe returned without result or classification",
|
|
154
|
+
conflictFiles: [],
|
|
155
|
+
warnings: outcome.warnings,
|
|
156
|
+
advisory: true,
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
/* v8 ignore stop */
|
|
161
|
+
if (!result.ok) {
|
|
162
|
+
const classification = (0, sync_classification_1.classifySyncFailure)(new Error(result.error ?? "unknown sync error"), { agentRoot });
|
|
163
|
+
findings.push({
|
|
164
|
+
agent: bundle.agent,
|
|
165
|
+
classification: classification.classification,
|
|
166
|
+
error: classification.error,
|
|
167
|
+
conflictFiles: classification.conflictFiles,
|
|
168
|
+
warnings: outcome.warnings,
|
|
169
|
+
advisory: isAdvisory(classification.classification),
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// Probe succeeded. If the soft warning fired, surface it as an advisory
|
|
174
|
+
// finding so the operator sees the slow-pull warning even on success.
|
|
175
|
+
if (outcome.warnings.length > 0) {
|
|
176
|
+
findings.push({
|
|
177
|
+
agent: bundle.agent,
|
|
178
|
+
classification: "timeout-soft",
|
|
179
|
+
error: outcome.warnings.join("; "),
|
|
180
|
+
conflictFiles: [],
|
|
181
|
+
warnings: outcome.warnings,
|
|
182
|
+
advisory: true,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Stable order — sort by agent name so renderers and tests see the same
|
|
187
|
+
// sequence.
|
|
188
|
+
findings.sort((a, b) => a.agent.localeCompare(b.agent));
|
|
189
|
+
const durationMs = Date.now() - startedAt;
|
|
190
|
+
(0, runtime_1.emitNervesEvent)({
|
|
191
|
+
component: "daemon",
|
|
192
|
+
event: "daemon.boot_sync_probe_end",
|
|
193
|
+
message: "boot sync probe complete",
|
|
194
|
+
meta: { findingCount: findings.length, durationMs },
|
|
195
|
+
});
|
|
196
|
+
return { findings, durationMs };
|
|
197
|
+
}
|
|
@@ -40,6 +40,10 @@ 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;
|
|
45
|
+
exports.writeSyncProbeSummary = writeSyncProbeSummary;
|
|
46
|
+
exports.summarizeSyncProbeFindings = summarizeSyncProbeFindings;
|
|
43
47
|
exports.mergeStartupStability = mergeStartupStability;
|
|
44
48
|
exports.ensureDaemonRunning = ensureDaemonRunning;
|
|
45
49
|
exports.listGithubCopilotModels = listGithubCopilotModels;
|
|
@@ -94,6 +98,7 @@ const cli_help_1 = require("./cli-help");
|
|
|
94
98
|
const cli_render_1 = require("./cli-render");
|
|
95
99
|
const cli_defaults_1 = require("./cli-defaults");
|
|
96
100
|
const agent_config_check_1 = require("./agent-config-check");
|
|
101
|
+
const drift_detection_1 = require("./drift-detection");
|
|
97
102
|
const doctor_1 = require("./doctor");
|
|
98
103
|
const cli_render_doctor_1 = require("./cli-render-doctor");
|
|
99
104
|
const interactive_repair_1 = require("./interactive-repair");
|
|
@@ -107,6 +112,7 @@ const up_progress_1 = require("./up-progress");
|
|
|
107
112
|
const provider_ping_progress_1 = require("./provider-ping-progress");
|
|
108
113
|
const provider_ping_1 = require("../provider-ping");
|
|
109
114
|
const agent_discovery_1 = require("./agent-discovery");
|
|
115
|
+
const boot_sync_probe_1 = require("./boot-sync-probe");
|
|
110
116
|
const connect_bay_1 = require("./connect-bay");
|
|
111
117
|
const runtime_capability_check_1 = require("../runtime-capability-check");
|
|
112
118
|
const vault_items_1 = require("./vault-items");
|
|
@@ -408,15 +414,104 @@ function writeProviderRepairSummary(deps, title, degraded) {
|
|
|
408
414
|
const blocks = degraded.map((entry) => (0, readiness_repair_1.renderReadinessIssueNextSteps)(readinessIssueFromDegraded(entry)).join("\n"));
|
|
409
415
|
deps.writeStdout([title, ...blocks].join("\n\n"));
|
|
410
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Layer 4: render a per-agent drift advisory block to stdout. Called from
|
|
419
|
+
* the `--no-repair` summary path when one or more enabled agents have a
|
|
420
|
+
* mismatch between `agent.json` (intent) and `state/providers.json`
|
|
421
|
+
* (observation). The block surfaces the lane, intent vs observed
|
|
422
|
+
* binding, and the copy-pasteable `ouro use` repair command per finding.
|
|
423
|
+
*
|
|
424
|
+
* Drift is advisory: this helper only PRINTS the advisory; running the
|
|
425
|
+
* repair command is the operator's call (and is Layer 3 RepairGuide's
|
|
426
|
+
* domain when the daemon does it automatically).
|
|
427
|
+
*
|
|
428
|
+
* Exported for direct unit-testing; the production callers are
|
|
429
|
+
* inside this module.
|
|
430
|
+
*/
|
|
431
|
+
function writeDriftAdvisorySummary(deps, advisories) {
|
|
432
|
+
if (advisories.length === 0)
|
|
433
|
+
return;
|
|
434
|
+
const lines = ["Drift advisory: agent intent does not match this machine's observed binding"];
|
|
435
|
+
for (const advisory of advisories) {
|
|
436
|
+
lines.push("");
|
|
437
|
+
lines.push(` ${advisory.agent} (${advisory.lane}):`);
|
|
438
|
+
lines.push(` intent: ${advisory.intentProvider}/${advisory.intentModel}`);
|
|
439
|
+
lines.push(` observed: ${advisory.observedProvider}/${advisory.observedModel}`);
|
|
440
|
+
lines.push(` repair: ${advisory.repairCommand}`);
|
|
441
|
+
}
|
|
442
|
+
deps.writeStdout(lines.join("\n"));
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Collect drift findings across every enabled agent in the bundles
|
|
446
|
+
* directory. Used by the `--no-repair` summary path so that drift
|
|
447
|
+
* advisories ride along with (or stand alone in place of) the existing
|
|
448
|
+
* provider-repair summary. Errors during a single agent's load (e.g.
|
|
449
|
+
* malformed `agent.json`) are swallowed: drift detection is advisory
|
|
450
|
+
* and must not block the rest of the boot path.
|
|
451
|
+
*/
|
|
452
|
+
async function collectAgentDriftAdvisories(deps) {
|
|
453
|
+
const agents = await listCliAgents(deps);
|
|
454
|
+
const bundlesRoot = deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)();
|
|
455
|
+
const findings = [];
|
|
456
|
+
for (const agent of [...new Set(agents)]) {
|
|
457
|
+
try {
|
|
458
|
+
const inputs = (0, drift_detection_1.loadDriftInputsForAgent)(bundlesRoot, agent);
|
|
459
|
+
const agentFindings = (0, drift_detection_1.detectProviderBindingDrift)({
|
|
460
|
+
agentName: agent,
|
|
461
|
+
agentJson: inputs.agentJson,
|
|
462
|
+
providerState: inputs.providerState,
|
|
463
|
+
});
|
|
464
|
+
findings.push(...agentFindings);
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
// Best-effort: a per-agent read failure is not blocking.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return findings;
|
|
471
|
+
}
|
|
411
472
|
function providerRepairCountSummary(count) {
|
|
412
473
|
if (count === 0)
|
|
413
474
|
return "selected providers answered live checks";
|
|
414
475
|
return `${count} ${count === 1 ? "needs" : "need"} attention`;
|
|
415
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Layer 2: human-readable summary of boot-sync-probe findings, written to
|
|
479
|
+
* stdout when any non-clean finding surfaces. The order reads "blockers
|
|
480
|
+
* first, then advisories" so the operator's eye lands on the actionable
|
|
481
|
+
* items before the warnings.
|
|
482
|
+
*/
|
|
483
|
+
function writeSyncProbeSummary(deps, findings) {
|
|
484
|
+
const lines = ["sync probe findings:"];
|
|
485
|
+
const sortKey = (f) => (f.advisory ? 1 : 0);
|
|
486
|
+
const sorted = [...findings].sort((a, b) => sortKey(a) - sortKey(b) || a.agent.localeCompare(b.agent));
|
|
487
|
+
for (const finding of sorted) {
|
|
488
|
+
const severity = finding.advisory ? "warn" : "block";
|
|
489
|
+
lines.push(` [${severity}] ${finding.agent}: ${finding.classification} — ${finding.error.split("\n")[0]}`);
|
|
490
|
+
}
|
|
491
|
+
deps.writeStdout(lines.join("\n"));
|
|
492
|
+
}
|
|
416
493
|
function bootPhasePlan(daemonAlive) {
|
|
417
494
|
return daemonAlive
|
|
418
|
-
? ["update check", "system setup", "starting daemon", "provider checks", "final daemon check"]
|
|
419
|
-
: ["update check", "system setup", "provider checks", "starting daemon", "final daemon check"];
|
|
495
|
+
? ["update check", "system setup", "sync probe", "starting daemon", "provider checks", "final daemon check"]
|
|
496
|
+
: ["update check", "system setup", "sync probe", "provider checks", "starting daemon", "final daemon check"];
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Layer 2: brief, scannable summary of a boot-sync-probe finding for the
|
|
500
|
+
* progress reporter. Renderer-style hints (full repair guidance) live in
|
|
501
|
+
* `inner-status.ts` / `startup-tui.ts` consumers; this helper is just for
|
|
502
|
+
* the progress phase blurb.
|
|
503
|
+
*/
|
|
504
|
+
function summarizeSyncProbeFindings(findings) {
|
|
505
|
+
if (findings.length === 0)
|
|
506
|
+
return "all sync-enabled bundles healthy";
|
|
507
|
+
const blocking = findings.filter((f) => !f.advisory).length;
|
|
508
|
+
const advisory = findings.filter((f) => f.advisory).length;
|
|
509
|
+
const parts = [];
|
|
510
|
+
if (blocking > 0)
|
|
511
|
+
parts.push(`${blocking} blocking`);
|
|
512
|
+
if (advisory > 0)
|
|
513
|
+
parts.push(`${advisory} advisory`);
|
|
514
|
+
return `${findings.length} finding${findings.length === 1 ? "" : "s"} (${parts.join(", ")})`;
|
|
420
515
|
}
|
|
421
516
|
function createHumanCommandProgress(deps, commandName) {
|
|
422
517
|
return new up_progress_1.CommandProgress({
|
|
@@ -5649,6 +5744,38 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
5649
5744
|
bundlesRoot: deps.bundlesRoot ?? bundlesRoot,
|
|
5650
5745
|
promptInput: deps.promptInput,
|
|
5651
5746
|
});
|
|
5747
|
+
// ── Layer 2 sync probe: pre-flight `git pull` for every sync-enabled bundle ──
|
|
5748
|
+
// Runs BEFORE provider checks so that any agent.json changes pulled from
|
|
5749
|
+
// the remote are visible to the live-check that follows. The probe has a
|
|
5750
|
+
// hard 15s per-agent timeout via `runWithTimeouts`, so a hung remote
|
|
5751
|
+
// can't stall the boot. `--no-repair` doesn't change probe behavior — it
|
|
5752
|
+
// gates layer 3's RepairGuide invocation, which lands in a separate PR.
|
|
5753
|
+
//
|
|
5754
|
+
// Errors in the probe path itself are caught and logged — they must not
|
|
5755
|
+
// break the boot, since the probe is best-effort visibility, not a gate.
|
|
5756
|
+
progress.startPhase("sync probe");
|
|
5757
|
+
try {
|
|
5758
|
+
const syncProbeImpl = deps.runBootSyncProbeImpl ?? boot_sync_probe_1.runBootSyncProbe;
|
|
5759
|
+
const syncRows = (0, agent_discovery_1.listBundleSyncRows)({ bundlesRoot: deps.bundlesRoot ?? bundlesRoot });
|
|
5760
|
+
const syncProbeResult = await syncProbeImpl(syncRows, {
|
|
5761
|
+
bundlesRoot: deps.bundlesRoot ?? bundlesRoot,
|
|
5762
|
+
});
|
|
5763
|
+
progress.completePhase("sync probe", summarizeSyncProbeFindings(syncProbeResult.findings));
|
|
5764
|
+
if (syncProbeResult.findings.length > 0) {
|
|
5765
|
+
writeSyncProbeSummary(deps, syncProbeResult.findings);
|
|
5766
|
+
}
|
|
5767
|
+
}
|
|
5768
|
+
catch (probeError) {
|
|
5769
|
+
const message = probeError instanceof Error ? probeError.message : String(probeError);
|
|
5770
|
+
(0, runtime_1.emitNervesEvent)({
|
|
5771
|
+
level: "warn",
|
|
5772
|
+
component: "daemon",
|
|
5773
|
+
event: "daemon.boot_sync_probe_failed",
|
|
5774
|
+
message: "boot sync probe failed; continuing boot",
|
|
5775
|
+
meta: { error: message },
|
|
5776
|
+
});
|
|
5777
|
+
progress.completePhase("sync probe", "skipped (probe error)");
|
|
5778
|
+
}
|
|
5652
5779
|
const daemonAliveBeforeStart = await deps.checkSocketAlive(deps.socketPath);
|
|
5653
5780
|
progress.setPhasePlan?.(bootPhasePlan(daemonAliveBeforeStart));
|
|
5654
5781
|
let providerChecksAlreadyRun = false;
|
|
@@ -5661,6 +5788,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
5661
5788
|
progress.end();
|
|
5662
5789
|
if (command.noRepair) {
|
|
5663
5790
|
writeProviderRepairSummary(deps, "Provider checks need attention", preflightProviderDegraded);
|
|
5791
|
+
// Layer 4: drift advisories ride along with the provider-repair
|
|
5792
|
+
// summary under --no-repair. Non-blocking; failure to collect
|
|
5793
|
+
// findings (e.g. malformed agent.json on one bundle) is swallowed
|
|
5794
|
+
// by `collectAgentDriftAdvisories` so the rest of the boot path
|
|
5795
|
+
// is unaffected.
|
|
5796
|
+
const driftAdvisories = await collectAgentDriftAdvisories(deps);
|
|
5797
|
+
writeDriftAdvisorySummary(deps, driftAdvisories);
|
|
5664
5798
|
const message = "daemon not started: provider checks need repair. Run `ouro repair` or rerun `ouro up` to choose a repair path.";
|
|
5665
5799
|
return returnCliFailure(deps, message);
|
|
5666
5800
|
}
|
|
@@ -5716,12 +5850,19 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
5716
5850
|
if (command.noRepair) {
|
|
5717
5851
|
// --no-repair: write degraded summary and skip interactive repair
|
|
5718
5852
|
writeProviderRepairSummary(deps, "Provider checks need attention", daemonResult.stability.degraded);
|
|
5853
|
+
// Layer 4: drift advisories ride along with the post-startup
|
|
5854
|
+
// degraded summary too — same rationale as the preflight path.
|
|
5855
|
+
const driftAdvisories = await collectAgentDriftAdvisories(deps);
|
|
5856
|
+
writeDriftAdvisorySummary(deps, driftAdvisories);
|
|
5719
5857
|
(0, runtime_1.emitNervesEvent)({
|
|
5720
5858
|
level: "warn",
|
|
5721
5859
|
component: "daemon",
|
|
5722
5860
|
event: "daemon.no_repair_degraded_summary",
|
|
5723
5861
|
message: "degraded agents detected with --no-repair, skipping interactive repair",
|
|
5724
|
-
meta: {
|
|
5862
|
+
meta: {
|
|
5863
|
+
degradedCount: daemonResult.stability.degraded.length,
|
|
5864
|
+
driftCount: driftAdvisories.length,
|
|
5865
|
+
},
|
|
5725
5866
|
});
|
|
5726
5867
|
}
|
|
5727
5868
|
else {
|
|
@@ -5796,6 +5937,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
5796
5937
|
}
|
|
5797
5938
|
}
|
|
5798
5939
|
}
|
|
5940
|
+
else if (command.noRepair) {
|
|
5941
|
+
// Layer 4: no degraded agents to summarize, but --no-repair still
|
|
5942
|
+
// surfaces drift advisories so the operator sees them without
|
|
5943
|
+
// having to run `ouro inner status` per agent.
|
|
5944
|
+
const driftAdvisories = await collectAgentDriftAdvisories(deps);
|
|
5945
|
+
writeDriftAdvisorySummary(deps, driftAdvisories);
|
|
5946
|
+
}
|
|
5799
5947
|
// Persist boot startup AFTER daemon is running — bootstrap is safe now
|
|
5800
5948
|
// because the daemon socket exists, so launchd's KeepAlive registers
|
|
5801
5949
|
// for crash recovery without starting a competing process.
|
|
@@ -6903,6 +7051,22 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
6903
7051
|
catch { /* no habits — heartbeat unknown */ }
|
|
6904
7052
|
// Attention count
|
|
6905
7053
|
const activeObligations = listActiveReturnObligations(command.agent);
|
|
7054
|
+
// Layer 4 drift findings for this agent. Best-effort: if agent.json
|
|
7055
|
+
// is unreadable mid-call we just suppress the advisory rather than
|
|
7056
|
+
// failing the inner-status render.
|
|
7057
|
+
let driftFindings = [];
|
|
7058
|
+
try {
|
|
7059
|
+
const bundlesRoot = deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)();
|
|
7060
|
+
const inputs = (0, drift_detection_1.loadDriftInputsForAgent)(bundlesRoot, command.agent);
|
|
7061
|
+
driftFindings = (0, drift_detection_1.detectProviderBindingDrift)({
|
|
7062
|
+
agentName: command.agent,
|
|
7063
|
+
agentJson: inputs.agentJson,
|
|
7064
|
+
providerState: inputs.providerState,
|
|
7065
|
+
});
|
|
7066
|
+
}
|
|
7067
|
+
catch {
|
|
7068
|
+
driftFindings = [];
|
|
7069
|
+
}
|
|
6906
7070
|
const message = buildInnerStatusOutput({
|
|
6907
7071
|
agentName: command.agent,
|
|
6908
7072
|
runtimeState,
|
|
@@ -6910,6 +7074,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
6910
7074
|
heartbeat,
|
|
6911
7075
|
attentionCount: activeObligations.length,
|
|
6912
7076
|
now: Date.now(),
|
|
7077
|
+
driftFindings,
|
|
6913
7078
|
});
|
|
6914
7079
|
deps.writeStdout(message);
|
|
6915
7080
|
return message;
|
|
@@ -496,8 +496,32 @@ function renderRollupStatusLine(health) {
|
|
|
496
496
|
switch (status) {
|
|
497
497
|
case "healthy":
|
|
498
498
|
return `Last known status: healthy ${tail}`;
|
|
499
|
-
case "partial":
|
|
500
|
-
|
|
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
|
+
}
|
|
501
525
|
case "degraded": {
|
|
502
526
|
// Three-way copy split based on the cached agents map:
|
|
503
527
|
// - empty map → fresh install / no agents configured.
|
|
@@ -56,6 +56,7 @@ const os_cron_deps_1 = require("./os-cron-deps");
|
|
|
56
56
|
const os_cron_1 = require("./os-cron");
|
|
57
57
|
const daemon_tombstone_1 = require("./daemon-tombstone");
|
|
58
58
|
const agent_config_check_1 = require("./agent-config-check");
|
|
59
|
+
const drift_detection_1 = require("./drift-detection");
|
|
59
60
|
const pulse_1 = require("./pulse");
|
|
60
61
|
const socket_client_1 = require("./socket-client");
|
|
61
62
|
const bundle_manifest_1 = require("../../mind/bundle-manifest");
|
|
@@ -183,6 +184,33 @@ function buildDaemonHealthState() {
|
|
|
183
184
|
...degradedComponents.map((entry) => ({ ...entry })),
|
|
184
185
|
...agentDegradedComponents,
|
|
185
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;
|
|
186
214
|
// Layer 1 rollup: project per-agent snapshots into the minimal
|
|
187
215
|
// AgentRollupInput shape and let computeDaemonRollup decide. The
|
|
188
216
|
// input is "every enabled agent" — managedAgents was filtered via
|
|
@@ -205,6 +233,7 @@ function buildDaemonHealthState() {
|
|
|
205
233
|
})),
|
|
206
234
|
bootstrapDegraded: degradedComponents,
|
|
207
235
|
safeMode: false,
|
|
236
|
+
driftDetected,
|
|
208
237
|
});
|
|
209
238
|
return {
|
|
210
239
|
status: rollupStatus,
|
|
@@ -214,6 +243,7 @@ function buildDaemonHealthState() {
|
|
|
214
243
|
uptimeSeconds: Math.floor(process.uptime()),
|
|
215
244
|
safeMode: null,
|
|
216
245
|
degraded,
|
|
246
|
+
drift,
|
|
217
247
|
agents: Object.fromEntries(snapshots.map((snapshot) => [
|
|
218
248
|
snapshot.name,
|
|
219
249
|
{
|
|
@@ -157,6 +157,12 @@ function readHealth(healthPath) {
|
|
|
157
157
|
parsed.habits === null) {
|
|
158
158
|
return null;
|
|
159
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
|
+
: [];
|
|
160
166
|
return {
|
|
161
167
|
status: parsed.status,
|
|
162
168
|
mode: parsed.mode,
|
|
@@ -165,6 +171,7 @@ function readHealth(healthPath) {
|
|
|
165
171
|
uptimeSeconds: parsed.uptimeSeconds,
|
|
166
172
|
safeMode: parsed.safeMode,
|
|
167
173
|
degraded: parsed.degraded,
|
|
174
|
+
drift,
|
|
168
175
|
agents: parsed.agents,
|
|
169
176
|
habits: parsed.habits,
|
|
170
177
|
};
|
|
@@ -50,7 +50,8 @@ function computeDaemonRollup(input) {
|
|
|
50
50
|
// healthy vs partial.
|
|
51
51
|
const hasUnhealthyAgent = notServing > 0;
|
|
52
52
|
const hasBootstrapDegraded = input.bootstrapDegraded.length > 0;
|
|
53
|
-
|
|
53
|
+
const hasDrift = input.driftDetected === true;
|
|
54
|
+
if (hasUnhealthyAgent || hasBootstrapDegraded || hasDrift) {
|
|
54
55
|
return "partial";
|
|
55
56
|
}
|
|
56
57
|
return "healthy";
|
|
@@ -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");
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Sync failure taxonomy classifier — Layer 2 of the harness-hardening sequence.
|
|
4
|
+
*
|
|
5
|
+
* Pure pattern-matcher over (error, context) that turns common git failure
|
|
6
|
+
* shapes into the locked taxonomy variants. Used by `runBootSyncProbe` (the
|
|
7
|
+
* `ouro up` pre-flight pull orchestrator) and by `postTurnPush`'s legacy
|
|
8
|
+
* push-rejected/conflict path so both producers share one vocabulary.
|
|
9
|
+
*
|
|
10
|
+
* Pattern priority — most actionable / specific wins:
|
|
11
|
+
* 1. Abort signal (timeout-soft / timeout-hard) — caller explicitly aborted.
|
|
12
|
+
* 2. not-found-404 — 404 / "Repository not found": remote endpoint gone.
|
|
13
|
+
* 3. auth-failed — 401 / 403 / "Authentication failed".
|
|
14
|
+
* 4. network-down — ENOTFOUND / ECONNREFUSED / "Could not resolve host".
|
|
15
|
+
* 5. dirty-working-tree — "would be overwritten" / "stash them".
|
|
16
|
+
* 6. merge-conflict — CONFLICT marker in stderr (also collects file list).
|
|
17
|
+
* 7. non-fast-forward — "non-fast-forward" / "fetch first".
|
|
18
|
+
* 8. unknown — fallthrough.
|
|
19
|
+
*
|
|
20
|
+
* The classifier never throws and never writes to disk. It calls
|
|
21
|
+
* `git status --porcelain=v1` only when it has already classified the error
|
|
22
|
+
* as a merge conflict, to enumerate unmerged paths for the consumer.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.classifySyncFailure = classifySyncFailure;
|
|
26
|
+
const child_process_1 = require("child_process");
|
|
27
|
+
/**
|
|
28
|
+
* Enumerate unmerged paths via `git status --porcelain=v1`. Pulled out as a
|
|
29
|
+
* pure helper so the classifier can be tested without a live git repo (the
|
|
30
|
+
* caller mocks `child_process.execFileSync`).
|
|
31
|
+
*
|
|
32
|
+
* Mirrors `sync.ts:collectRebaseConflictFiles` — kept as a separate copy here
|
|
33
|
+
* so this module has zero internal dep on `sync.ts`. The caller of
|
|
34
|
+
* `classifySyncFailure` doesn't see the duplication; the runtime cost is one
|
|
35
|
+
* git invocation per merge-conflict classification, same as before.
|
|
36
|
+
*/
|
|
37
|
+
function collectConflictFiles(agentRoot) {
|
|
38
|
+
try {
|
|
39
|
+
const output = (0, child_process_1.execFileSync)("git", ["status", "--porcelain=v1"], {
|
|
40
|
+
cwd: agentRoot,
|
|
41
|
+
stdio: "pipe",
|
|
42
|
+
timeout: 5000,
|
|
43
|
+
}).toString();
|
|
44
|
+
const files = [];
|
|
45
|
+
for (const line of output.split("\n")) {
|
|
46
|
+
// Unmerged paths in porcelain v1 are prefixed with UU/AA/DD/AU/UA/DU/UD.
|
|
47
|
+
if (/^(UU|AA|DD|AU|UA|DU|UD) /.test(line)) {
|
|
48
|
+
files.push(line.slice(3).trim());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* v8 ignore next -- defensive: git status failure inside a git repo would require a corrupt repo @preserve */
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function isAbortError(error) {
|
|
59
|
+
if (typeof error !== "object" || error === null)
|
|
60
|
+
return false;
|
|
61
|
+
const candidate = error;
|
|
62
|
+
if (candidate.name === "AbortError")
|
|
63
|
+
return true;
|
|
64
|
+
if (candidate.code === "ABORT_ERR")
|
|
65
|
+
return true;
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
function readMessage(error) {
|
|
69
|
+
if (error instanceof Error)
|
|
70
|
+
return error.message;
|
|
71
|
+
if (typeof error === "string")
|
|
72
|
+
return error;
|
|
73
|
+
if (error === null || error === undefined)
|
|
74
|
+
return String(error);
|
|
75
|
+
try {
|
|
76
|
+
return JSON.stringify(error);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* v8 ignore next -- defensive: JSON.stringify only fails on circular/BigInt; real-world git errors don't trigger it @preserve */
|
|
80
|
+
return String(error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function readErrorCode(error) {
|
|
84
|
+
if (typeof error !== "object" || error === null)
|
|
85
|
+
return undefined;
|
|
86
|
+
const code = error.code;
|
|
87
|
+
return typeof code === "string" ? code : undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Classify a sync failure into one of the locked taxonomy variants.
|
|
91
|
+
*
|
|
92
|
+
* Pure: never throws, never writes. Calls `git status` only when the error
|
|
93
|
+
* was already classified as a merge conflict, to enumerate unmerged files.
|
|
94
|
+
*/
|
|
95
|
+
function classifySyncFailure(error, context) {
|
|
96
|
+
const message = readMessage(error);
|
|
97
|
+
const errorCode = readErrorCode(error);
|
|
98
|
+
// 1. Abort signal — highest priority. Caller's signal trumps content match.
|
|
99
|
+
if (isAbortError(error)) {
|
|
100
|
+
const reason = context.abortReason ?? "hard";
|
|
101
|
+
return {
|
|
102
|
+
classification: reason === "soft" ? "timeout-soft" : "timeout-hard",
|
|
103
|
+
error: message,
|
|
104
|
+
conflictFiles: [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// Lowercased copy for case-insensitive substring matching.
|
|
108
|
+
const lower = message.toLowerCase();
|
|
109
|
+
// 2. Not-found-404 — most actionable diagnosis when both 404 and other
|
|
110
|
+
// signals are present. "404" and "Repository not found" are the canonical
|
|
111
|
+
// shapes.
|
|
112
|
+
if (lower.includes("404") || lower.includes("repository not found")) {
|
|
113
|
+
return {
|
|
114
|
+
classification: "not-found-404",
|
|
115
|
+
error: message,
|
|
116
|
+
conflictFiles: [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// 3. Auth failed — 401 / 403 / "Authentication failed" / "Permission denied".
|
|
120
|
+
if (lower.includes("401")
|
|
121
|
+
|| lower.includes("403")
|
|
122
|
+
|| lower.includes("authentication failed")
|
|
123
|
+
|| lower.includes("permission denied")) {
|
|
124
|
+
return {
|
|
125
|
+
classification: "auth-failed",
|
|
126
|
+
error: message,
|
|
127
|
+
conflictFiles: [],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// 4. Network down — DNS / connection errors.
|
|
131
|
+
if (errorCode === "ENOTFOUND"
|
|
132
|
+
|| errorCode === "ECONNREFUSED"
|
|
133
|
+
|| lower.includes("enotfound")
|
|
134
|
+
|| lower.includes("econnrefused")
|
|
135
|
+
|| lower.includes("could not resolve host")
|
|
136
|
+
|| lower.includes("connection refused")) {
|
|
137
|
+
return {
|
|
138
|
+
classification: "network-down",
|
|
139
|
+
error: message,
|
|
140
|
+
conflictFiles: [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// 5. Dirty working tree — pull / merge would clobber uncommitted changes.
|
|
144
|
+
if (lower.includes("would be overwritten")
|
|
145
|
+
|| lower.includes("commit your changes or stash them")) {
|
|
146
|
+
return {
|
|
147
|
+
classification: "dirty-working-tree",
|
|
148
|
+
error: message,
|
|
149
|
+
conflictFiles: [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// 6. Merge conflict — CONFLICT marker in stderr. Collect unmerged files.
|
|
153
|
+
if (lower.includes("conflict")) {
|
|
154
|
+
return {
|
|
155
|
+
classification: "merge-conflict",
|
|
156
|
+
error: message,
|
|
157
|
+
conflictFiles: collectConflictFiles(context.agentRoot),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// 7. Non-fast-forward — push rejected because remote moved.
|
|
161
|
+
if (lower.includes("non-fast-forward")
|
|
162
|
+
|| lower.includes("fetch first")
|
|
163
|
+
|| lower.includes("rejected")) {
|
|
164
|
+
return {
|
|
165
|
+
classification: "non-fast-forward",
|
|
166
|
+
error: message,
|
|
167
|
+
conflictFiles: [],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// 8. Fallthrough.
|
|
171
|
+
return {
|
|
172
|
+
classification: "unknown",
|
|
173
|
+
error: message,
|
|
174
|
+
conflictFiles: [],
|
|
175
|
+
};
|
|
176
|
+
}
|
package/dist/heart/sync.js
CHANGED
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.preTurnPull = preTurnPull;
|
|
37
37
|
exports.postTurnPush = postTurnPush;
|
|
38
|
+
exports.preTurnPullAsync = preTurnPullAsync;
|
|
38
39
|
const child_process_1 = require("child_process");
|
|
39
40
|
const fs = __importStar(require("fs"));
|
|
40
41
|
const path = __importStar(require("path"));
|
|
@@ -330,3 +331,119 @@ function postTurnPush(agentRoot, config) {
|
|
|
330
331
|
return { ok: false, error };
|
|
331
332
|
}
|
|
332
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Layer 2 — async, signal-aware sibling of `preTurnPull`.
|
|
336
|
+
*
|
|
337
|
+
* Used by `runBootSyncProbe` (the `ouro up` boot orchestrator) to perform
|
|
338
|
+
* the pre-flight pull with end-to-end `AbortSignal` propagation. The
|
|
339
|
+
* underlying `child_process.execFile` accepts the signal and kills the git
|
|
340
|
+
* child process when it aborts, so a hung remote (DNS hole, slow server)
|
|
341
|
+
* can be cut by the boot timeout wrapper rather than hanging the whole
|
|
342
|
+
* boot.
|
|
343
|
+
*
|
|
344
|
+
* The legacy sync `preTurnPull` is preserved unchanged for the per-turn
|
|
345
|
+
* pipeline at `src/senses/pipeline.ts:522`. The two functions share the
|
|
346
|
+
* same `.git` and remote-availability gates — the only difference is the
|
|
347
|
+
* pull itself: `execFileSync` (no signal) vs `execFile` + `{ signal }`.
|
|
348
|
+
*
|
|
349
|
+
* Honour-the-signal contract:
|
|
350
|
+
* - If `options.signal` is already aborted at call time, the pull is
|
|
351
|
+
* skipped and the result is `{ ok: false, error: "aborted" }`.
|
|
352
|
+
* - If `options.signal` aborts mid-fetch, the child receives `SIGTERM`
|
|
353
|
+
* via Node's built-in AbortSignal handling, and the result is
|
|
354
|
+
* `{ ok: false, error: <abort message> }`.
|
|
355
|
+
* - With no signal supplied, behaviour matches the sync version (subject
|
|
356
|
+
* to the small differences listed above — same git-repo / no-remote
|
|
357
|
+
* gates and same nerves events).
|
|
358
|
+
*/
|
|
359
|
+
function preTurnPullAsync(agentRoot, config, options = {}) {
|
|
360
|
+
(0, runtime_1.emitNervesEvent)({
|
|
361
|
+
component: "heart",
|
|
362
|
+
event: "heart.sync_pull_start",
|
|
363
|
+
message: "pre-turn pull starting (async)",
|
|
364
|
+
meta: { agentRoot, remote: config.remote },
|
|
365
|
+
});
|
|
366
|
+
// Bail early when the caller has already aborted — saves a git invocation
|
|
367
|
+
// and signals failure consistently.
|
|
368
|
+
if (options.signal?.aborted) {
|
|
369
|
+
(0, runtime_1.emitNervesEvent)({
|
|
370
|
+
level: "warn",
|
|
371
|
+
component: "heart",
|
|
372
|
+
event: "heart.sync_pull_aborted",
|
|
373
|
+
message: "pre-turn pull skipped: signal already aborted",
|
|
374
|
+
meta: { agentRoot },
|
|
375
|
+
});
|
|
376
|
+
return Promise.resolve({ ok: false, error: "aborted before pull started" });
|
|
377
|
+
}
|
|
378
|
+
// Same .git presence check as the sync version.
|
|
379
|
+
const repoCheck = ensureGitRepo(agentRoot);
|
|
380
|
+
if (!repoCheck.ok) {
|
|
381
|
+
(0, runtime_1.emitNervesEvent)({
|
|
382
|
+
level: "warn",
|
|
383
|
+
component: "heart",
|
|
384
|
+
event: "heart.sync_not_a_repo",
|
|
385
|
+
message: "pre-turn pull failed: bundle is not a git repo (async)",
|
|
386
|
+
meta: { agentRoot },
|
|
387
|
+
});
|
|
388
|
+
return Promise.resolve(repoCheck);
|
|
389
|
+
}
|
|
390
|
+
// Remote-presence check stays sync — it's a fast local op and doesn't
|
|
391
|
+
// need cancellation. The hangable op is the actual pull.
|
|
392
|
+
try {
|
|
393
|
+
const remoteOutput = (0, child_process_1.execFileSync)("git", ["remote"], {
|
|
394
|
+
cwd: agentRoot,
|
|
395
|
+
stdio: "pipe",
|
|
396
|
+
timeout: 5000,
|
|
397
|
+
}).toString().trim();
|
|
398
|
+
if (remoteOutput.length === 0) {
|
|
399
|
+
(0, runtime_1.emitNervesEvent)({
|
|
400
|
+
component: "heart",
|
|
401
|
+
event: "heart.sync_pull_end",
|
|
402
|
+
message: "pre-turn pull skipped: no remote configured (async)",
|
|
403
|
+
meta: { agentRoot },
|
|
404
|
+
});
|
|
405
|
+
return Promise.resolve({ ok: true });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
410
|
+
(0, runtime_1.emitNervesEvent)({
|
|
411
|
+
component: "heart",
|
|
412
|
+
event: "heart.sync_pull_error",
|
|
413
|
+
message: "pre-turn pull failed: git remote check failed (async)",
|
|
414
|
+
meta: { agentRoot, error },
|
|
415
|
+
});
|
|
416
|
+
return Promise.resolve({ ok: false, error });
|
|
417
|
+
}
|
|
418
|
+
// The hangable op. `execFile` accepts `{ signal }` and kills the child
|
|
419
|
+
// when the signal aborts — that's the whole point of the async path.
|
|
420
|
+
const execOptions = {
|
|
421
|
+
cwd: agentRoot,
|
|
422
|
+
timeout: 30000,
|
|
423
|
+
};
|
|
424
|
+
if (options.signal) {
|
|
425
|
+
execOptions.signal = options.signal;
|
|
426
|
+
}
|
|
427
|
+
return new Promise((resolve) => {
|
|
428
|
+
(0, child_process_1.execFile)("git", ["pull", config.remote], execOptions, (err) => {
|
|
429
|
+
if (err) {
|
|
430
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
431
|
+
(0, runtime_1.emitNervesEvent)({
|
|
432
|
+
component: "heart",
|
|
433
|
+
event: "heart.sync_pull_error",
|
|
434
|
+
message: "pre-turn pull failed (async)",
|
|
435
|
+
meta: { agentRoot, error },
|
|
436
|
+
});
|
|
437
|
+
resolve({ ok: false, error });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
(0, runtime_1.emitNervesEvent)({
|
|
441
|
+
component: "heart",
|
|
442
|
+
event: "heart.sync_pull_end",
|
|
443
|
+
message: "pre-turn pull complete (async)",
|
|
444
|
+
meta: { agentRoot },
|
|
445
|
+
});
|
|
446
|
+
resolve({ ok: true });
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Soft/hard timeout pattern for boot-path operations — Layer 2.
|
|
4
|
+
*
|
|
5
|
+
* Soft timeout = "log a warning, keep going". The op continues; the consumer
|
|
6
|
+
* records the warning and moves on.
|
|
7
|
+
* Hard timeout = "abort the op via AbortSignal". The underlying op is
|
|
8
|
+
* expected to honour the signal (Node child_process accepts `{ signal }`,
|
|
9
|
+
* fetch accepts `{ signal }`, etc.). When the signal aborts, the op should
|
|
10
|
+
* reject with an AbortError, and the wrapper returns
|
|
11
|
+
* `{ classification: "timeout-hard" }` rather than re-throwing.
|
|
12
|
+
*
|
|
13
|
+
Two optional env overrides (currently only `GIT` is consumed):
|
|
14
|
+
* - `OURO_BOOT_TIMEOUT_GIT_SOFT` / `OURO_BOOT_TIMEOUT_GIT_HARD` — boot
|
|
15
|
+
* git operations (fetch / pull). Used when `envKey === "GIT"`.
|
|
16
|
+
*
|
|
17
|
+
* Env values are parsed as integer milliseconds. Non-numeric or non-positive
|
|
18
|
+
* values are ignored (the explicit `softMs` / `hardMs` defaults from the
|
|
19
|
+
* caller win in that case).
|
|
20
|
+
*
|
|
21
|
+
* Pattern guarantee: timers cleared on resolve / reject so the function
|
|
22
|
+
* holds no refs after settlement. Important because `ouro up` chains
|
|
23
|
+
* many of these and a leaking timer would block process exit.
|
|
24
|
+
*/
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.runWithTimeouts = runWithTimeouts;
|
|
27
|
+
function readEnvMs(name) {
|
|
28
|
+
const raw = process.env[name];
|
|
29
|
+
if (raw === undefined || raw === null)
|
|
30
|
+
return null;
|
|
31
|
+
const parsed = Number.parseInt(raw, 10);
|
|
32
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
33
|
+
return null;
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
function resolveTimeouts(options) {
|
|
37
|
+
let softMs = options.softMs;
|
|
38
|
+
let hardMs = options.hardMs;
|
|
39
|
+
if (options.envKey === "GIT") {
|
|
40
|
+
const envSoft = readEnvMs("OURO_BOOT_TIMEOUT_GIT_SOFT");
|
|
41
|
+
if (envSoft !== null)
|
|
42
|
+
softMs = envSoft;
|
|
43
|
+
const envHard = readEnvMs("OURO_BOOT_TIMEOUT_GIT_HARD");
|
|
44
|
+
if (envHard !== null)
|
|
45
|
+
hardMs = envHard;
|
|
46
|
+
}
|
|
47
|
+
return { softMs, hardMs };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Run `fn` with soft and hard timeouts.
|
|
51
|
+
*
|
|
52
|
+
* - Returns `{ result }` on success.
|
|
53
|
+
* - Returns `{ classification: "timeout-hard", warnings }` when aborted.
|
|
54
|
+
* - Returns `{ result, warnings: [...] }` when soft tripped but op completed.
|
|
55
|
+
* - Rejects when `fn` throws a non-abort error (callers can wrap with
|
|
56
|
+
* classifier).
|
|
57
|
+
*/
|
|
58
|
+
async function runWithTimeouts(fn, options) {
|
|
59
|
+
const { softMs, hardMs } = resolveTimeouts(options);
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const warnings = [];
|
|
62
|
+
let softTimer = setTimeout(() => {
|
|
63
|
+
softTimer = null;
|
|
64
|
+
warnings.push(`${options.label}: soft timeout exceeded (${softMs}ms) — warning, continuing until hard cut`);
|
|
65
|
+
}, softMs);
|
|
66
|
+
let hardTimer = setTimeout(() => {
|
|
67
|
+
hardTimer = null;
|
|
68
|
+
controller.abort();
|
|
69
|
+
}, hardMs);
|
|
70
|
+
const cleanup = () => {
|
|
71
|
+
if (softTimer !== null) {
|
|
72
|
+
clearTimeout(softTimer);
|
|
73
|
+
softTimer = null;
|
|
74
|
+
}
|
|
75
|
+
if (hardTimer !== null) {
|
|
76
|
+
clearTimeout(hardTimer);
|
|
77
|
+
hardTimer = null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
const result = await fn(controller.signal);
|
|
82
|
+
cleanup();
|
|
83
|
+
// If the abort fired and the op resolved gracefully (e.g., the inner
|
|
84
|
+
// function caught the AbortError and returned a structured result), we
|
|
85
|
+
// still classify the outcome as timeout-hard — the op was aborted from
|
|
86
|
+
// the caller's perspective even if no exception propagated. The caller
|
|
87
|
+
// can ignore the classification and use `result` if both are present.
|
|
88
|
+
if (controller.signal.aborted) {
|
|
89
|
+
return { classification: "timeout-hard", warnings };
|
|
90
|
+
}
|
|
91
|
+
return { result, warnings };
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
cleanup();
|
|
95
|
+
if (controller.signal.aborted) {
|
|
96
|
+
// Hard timeout fired — abort wins over whatever error the op threw.
|
|
97
|
+
return { classification: "timeout-hard", warnings };
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -93,6 +93,16 @@ const DISPATCH_EXEMPT_PATTERNS = [
|
|
|
93
93
|
// buildDaemonHealthState → DaemonHealthWriter) owns observability via
|
|
94
94
|
// daemon.health_written when the rolled-up state is persisted.
|
|
95
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",
|
|
96
106
|
// Attachment helper modules: generic file-path/extension utilities and the
|
|
97
107
|
// source registry are pure support seams. The orchestrator/adapters that
|
|
98
108
|
// call them own the observability.
|
|
@@ -139,6 +149,15 @@ const DISPATCH_EXEMPT_PATTERNS = [
|
|
|
139
149
|
// Diagnostics-only utility; output is human-readable summary.
|
|
140
150
|
"heart/session-stats-cli-main",
|
|
141
151
|
"heart/session-stats",
|
|
152
|
+
// Layer 2 sync classifier: pure pattern-matcher mapping (error, context)
|
|
153
|
+
// to a SyncClassification. The orchestrator (boot-sync-probe.ts) owns
|
|
154
|
+
// observability via daemon.boot_sync_probe_start/end events; the
|
|
155
|
+
// post-turn push path (sync.ts) emits its own classification events.
|
|
156
|
+
"heart/sync-classification",
|
|
157
|
+
// Layer 2 timeout wrapper: pure soft/hard timeout abstraction over
|
|
158
|
+
// AbortController + setTimeout. Callers (boot-sync-probe.ts and any
|
|
159
|
+
// future consumer) own observability; the wrapper itself is mechanical.
|
|
160
|
+
"heart/timeouts",
|
|
142
161
|
];
|
|
143
162
|
function isDispatchExempt(filePath) {
|
|
144
163
|
return DISPATCH_EXEMPT_PATTERNS.some((pattern) => filePath.includes(pattern));
|