@devrouter/cli 0.0.71 → 0.0.73

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.
@@ -5159,7 +5159,7 @@ function loadRepoConfig(repoPath, read2 = (file) => import_node_fs14.default.rea
5159
5159
  const config = parseConfig(parsed ?? {}, configPath);
5160
5160
  const requiredVersion = config.devrouter?.version;
5161
5161
  if (requiredVersion && !hasWarnedVersionMismatch) {
5162
- const cliVersion = true ? "0.0.71" : "0.0.0-dev";
5162
+ const cliVersion = true ? "0.0.73" : "0.0.0-dev";
5163
5163
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
5164
5164
  hasWarnedVersionMismatch = true;
5165
5165
  process.stderr.write(
@@ -8596,8 +8596,6 @@ function handleOperationRequest(state, event) {
8596
8596
  );
8597
8597
  if (state.operationHistory.some((entry) => entry.id === event.operationId))
8598
8598
  return unchanged(state, "conflict");
8599
- if (state.executionPolicy !== "manual" && state.operationHistory.length >= RELIABILITY_MAX_ITEMS)
8600
- return unchanged(state, "blocked");
8601
8599
  if (event.kind === "exec" && canExecAfterInterruptedEnsure(state) && event.recoverInterruptedEnsure !== true)
8602
8600
  return blocked(
8603
8601
  state,
@@ -8620,7 +8618,7 @@ function handleOperationRequest(state, event) {
8620
8618
  state,
8621
8619
  `exec requires a running workspace (runtimeRunning=${event.runtimeRunning}, desired='${state.desired}').`
8622
8620
  );
8623
- const rollover = state.executionPolicy === "manual" && state.operationHistory.length >= RELIABILITY_MAX_ITEMS;
8621
+ const rollover = state.operationHistory.length >= RELIABILITY_MAX_ITEMS;
8624
8622
  let retired = -1;
8625
8623
  if (rollover) {
8626
8624
  const latestEnsureId = [...state.operationHistory].reverse().find((entry) => entry.kind === "ensure")?.id;
@@ -8903,14 +8901,16 @@ function handleRecover(state, event) {
8903
8901
  if (state.executionPolicy === "manual" || state.desired !== "running")
8904
8902
  return unchanged(state, "blocked");
8905
8903
  if (possibleDispatch(state.operation)) return unchanged(state, "blocked");
8904
+ const stranded = state.operation?.drained === true && ["NOT_STARTED", "NOT_LAUNCHED"].includes(state.operation.status);
8905
+ const operation = stranded ? null : state.operation;
8906
8906
  if (state.incident !== null && state.incident.id !== event.incidentId) {
8907
8907
  return unchanged(state, "conflict");
8908
8908
  }
8909
8909
  if (state.incident !== null) {
8910
- if (state.operation && pendingCommand(state.operation)) {
8911
- return state.operation.id === event.operationId && state.phase === "recovering" ? unchanged(state, "joined") : unchanged(state, "conflict");
8910
+ if (operation && pendingCommand(operation)) {
8911
+ return operation.id === event.operationId && state.phase === "recovering" ? unchanged(state, "joined") : unchanged(state, "conflict");
8912
8912
  }
8913
- if (state.operation?.id === event.operationId && state.operation.status === "COMPLETED") {
8913
+ if (operation?.id === event.operationId && operation.status === "COMPLETED") {
8914
8914
  return unchanged(state, "joined");
8915
8915
  }
8916
8916
  if (state.incident.correctiveActionsTaken >= state.incident.actionLimit) {
@@ -8918,17 +8918,20 @@ function handleRecover(state, event) {
8918
8918
  }
8919
8919
  }
8920
8920
  if (state.incident === null) {
8921
- if (state.operation?.status === "NOT_STARTED") return unchanged(state, "blocked");
8922
- if (state.operation?.id === event.operationId) return unchanged(state, "conflict");
8921
+ if (operation?.status === "NOT_STARTED") return unchanged(state, "blocked");
8922
+ if (operation?.id === event.operationId) return unchanged(state, "conflict");
8923
8923
  state.incident = {
8924
8924
  id: event.incidentId,
8925
8925
  correctiveActionsTaken: 0,
8926
8926
  actionLimit: event.actionLimit
8927
8927
  };
8928
8928
  }
8929
- if (!state.operation || state.operation.status === "COMPLETED" || state.operation.status === "INTERRUPTED") {
8929
+ if (!operation || operation.status === "COMPLETED" || operation.status === "INTERRUPTED") {
8930
8930
  const runtimeGeneration = advance(state.runtimeGeneration);
8931
8931
  if (runtimeGeneration === null) return unchanged(state, "blocked");
8932
+ const consumer = state.consumers[0];
8933
+ if (state.profile === null || consumer === void 0) return unchanged(state, "blocked");
8934
+ if (state.operationHistory.length >= RELIABILITY_MAX_ITEMS) return unchanged(state, "blocked");
8932
8935
  state.runtimeGeneration = runtimeGeneration;
8933
8936
  state.observations = [];
8934
8937
  state.operation = {
@@ -8938,6 +8941,12 @@ function handleRecover(state, event) {
8938
8941
  status: "NOT_STARTED",
8939
8942
  exitCode: null
8940
8943
  };
8944
+ state.operationHistory.push({
8945
+ ...state.operation,
8946
+ key: event.operationId,
8947
+ profile: state.profile,
8948
+ consumer: cloneConsumer(consumer)
8949
+ });
8941
8950
  }
8942
8951
  state.phase = "recovering";
8943
8952
  return transition(state, "accepted");
@@ -9037,7 +9046,7 @@ var import_node_fs25 = __toESM(require("fs"));
9037
9046
  var import_node_path30 = __toESM(require("path"));
9038
9047
  var import_node_util5 = require("util");
9039
9048
  function installedCliVersion() {
9040
- return true ? "0.0.71" : "0.0.0-dev";
9049
+ return true ? "0.0.73" : "0.0.0-dev";
9041
9050
  }
9042
9051
  function compareCliVersions(left, right) {
9043
9052
  const parts = (value) => value.split(".").map((part) => Number.parseInt(part, 10) || 0);
package/dist/devrouter.js CHANGED
@@ -31,7 +31,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
31
31
  ));
32
32
 
33
33
  // src/core/controller-probe.ts
34
- function runControllerProbe(command, args, signal, options = {}) {
34
+ function probe(command, args, signal, options) {
35
35
  return new Promise((resolve, reject) => {
36
36
  if (signal.aborted) {
37
37
  reject(new Error("Observation cancelled."));
@@ -85,12 +85,19 @@ function runControllerProbe(command, args, signal, options = {}) {
85
85
  if (finished) return;
86
86
  finished = true;
87
87
  cleanup();
88
- if (failed || code !== 0)
89
- reject(new Error("Observation process failed or exceeded its bound."));
90
- else resolve(Buffer.concat(chunks).toString("utf8"));
88
+ if (failed) reject(new Error("Observation process failed or exceeded its bound."));
89
+ else resolve({ output: Buffer.concat(chunks).toString("utf8"), code });
91
90
  });
92
91
  });
93
92
  }
93
+ async function runControllerProbe(command, args, signal, options = {}) {
94
+ const result = await probe(command, args, signal, options);
95
+ if (result.code !== 0) throw new Error("Observation process failed or exceeded its bound.");
96
+ return result.output;
97
+ }
98
+ function runControllerProbeStatus(command, args, signal, options = {}) {
99
+ return probe(command, args, signal, options);
100
+ }
94
101
  var import_node_child_process, ControllerProbeUnavailable;
95
102
  var init_controller_probe = __esm({
96
103
  "src/core/controller-probe.ts"() {
@@ -132,12 +139,12 @@ async function readCapacityHostSnapshot(signal, dependencies = {}) {
132
139
  try {
133
140
  if ((dependencies.platform ?? process.platform) !== "darwin" || signal.aborted)
134
141
  throw new Error("Host probe unavailable.");
135
- const probe = dependencies.probe ?? runControllerProbe;
142
+ const probe2 = dependencies.probe ?? runControllerProbe;
136
143
  const cancellation = new AbortController();
137
144
  const probeSignal = AbortSignal.any([signal, cancellation.signal]);
138
145
  const results = await Promise.allSettled(
139
146
  ["hw.memsize", "kern.memorystatus_vm_pressure_level"].map(
140
- (name) => Promise.resolve().then(() => probe("/usr/sbin/sysctl", ["-n", name], probeSignal)).catch((error) => {
147
+ (name) => Promise.resolve().then(() => probe2("/usr/sbin/sysctl", ["-n", name], probeSignal)).catch((error) => {
141
148
  cancellation.abort();
142
149
  throw error;
143
150
  })
@@ -1803,6 +1810,62 @@ function parseScheduling(value) {
1803
1810
  }
1804
1811
  return result;
1805
1812
  }
1813
+ function parseRecovery(value) {
1814
+ if (value === void 0) return { ...DEFAULT_CAPACITY_RECOVERY };
1815
+ const label2 = "recovery";
1816
+ const recovery = ensureObject(value, label2);
1817
+ ensureAllowedKeys(
1818
+ recovery,
1819
+ [
1820
+ "enabled",
1821
+ "maxProcessRestarts",
1822
+ "maxServiceRestarts",
1823
+ "maxCorrectiveActions",
1824
+ "windowSeconds",
1825
+ "observationSeconds",
1826
+ "resumeDwellSeconds"
1827
+ ],
1828
+ label2
1829
+ );
1830
+ if (typeof recovery.enabled !== "boolean") {
1831
+ throw new Error(`${label2}.enabled must be a boolean.`);
1832
+ }
1833
+ const result = {
1834
+ enabled: recovery.enabled,
1835
+ maxProcessRestarts: parseSafeInteger(
1836
+ recovery.maxProcessRestarts,
1837
+ `${label2}.maxProcessRestarts`,
1838
+ 0
1839
+ ),
1840
+ maxServiceRestarts: parseSafeInteger(
1841
+ recovery.maxServiceRestarts,
1842
+ `${label2}.maxServiceRestarts`,
1843
+ 0
1844
+ ),
1845
+ maxCorrectiveActions: parsePositiveInteger(
1846
+ recovery.maxCorrectiveActions,
1847
+ `${label2}.maxCorrectiveActions`
1848
+ ),
1849
+ windowSeconds: parsePositiveInteger(recovery.windowSeconds, `${label2}.windowSeconds`),
1850
+ observationSeconds: parsePositiveInteger(
1851
+ recovery.observationSeconds,
1852
+ `${label2}.observationSeconds`
1853
+ ),
1854
+ resumeDwellSeconds: parsePositiveInteger(
1855
+ recovery.resumeDwellSeconds,
1856
+ `${label2}.resumeDwellSeconds`
1857
+ )
1858
+ };
1859
+ if (result.maxCorrectiveActions < result.maxProcessRestarts + result.maxServiceRestarts) {
1860
+ throw new Error(
1861
+ `${label2}.maxCorrectiveActions must cover maxProcessRestarts and maxServiceRestarts.`
1862
+ );
1863
+ }
1864
+ if (result.maxProcessRestarts > 4 || result.maxServiceRestarts > 2 || result.maxCorrectiveActions > 8 || result.windowSeconds > 3600 || result.observationSeconds > 300 || result.resumeDwellSeconds > 3600) {
1865
+ throw new Error(`${label2} exceeds supported controller bounds.`);
1866
+ }
1867
+ return result;
1868
+ }
1806
1869
  function validateDomainReferences(domains) {
1807
1870
  const daemonIds = /* @__PURE__ */ new Map();
1808
1871
  const endpoints = /* @__PURE__ */ new Map();
@@ -1926,7 +1989,7 @@ function parseCapacityPolicy(value) {
1926
1989
  const root = ensureObject(value, "capacity policy");
1927
1990
  ensureAllowedKeys(
1928
1991
  root,
1929
- ["version", "revision", "admissions", "scheduling", "domains", "enrollments"],
1992
+ ["version", "revision", "admissions", "scheduling", "recovery", "domains", "enrollments"],
1930
1993
  "capacity policy"
1931
1994
  );
1932
1995
  const version = parsePositiveInteger(root.version, "capacity policy.version");
@@ -1939,6 +2002,7 @@ function parseCapacityPolicy(value) {
1939
2002
  throw new Error("capacity policy.admissions must be 'enabled' or 'paused'.");
1940
2003
  }
1941
2004
  const scheduling = parseScheduling(root.scheduling);
2005
+ const recovery = parseRecovery(root.recovery);
1942
2006
  const domainsValue = ensureObject(root.domains, "capacity policy.domains");
1943
2007
  const domainEntries = Object.entries(domainsValue);
1944
2008
  if (domainEntries.length > MAX_DOMAINS) {
@@ -1985,11 +2049,12 @@ function parseCapacityPolicy(value) {
1985
2049
  revision,
1986
2050
  admissions,
1987
2051
  scheduling,
2052
+ recovery,
1988
2053
  domains,
1989
2054
  enrollments
1990
2055
  };
1991
2056
  }
1992
- var import_node_fs4, import_node_path6, MAX_DOMAINS, MAX_ENROLLMENTS, MAX_PROFILES_PER_ENROLLMENT, MAX_STRING_LENGTH, MAX_PATH_LENGTH, DOMAIN_ID_RE, PROFILE_NAME_RE, WORKSPACE_TOKEN_RE, SHA256_RE;
2057
+ var import_node_fs4, import_node_path6, MAX_DOMAINS, MAX_ENROLLMENTS, MAX_PROFILES_PER_ENROLLMENT, MAX_STRING_LENGTH, MAX_PATH_LENGTH, DOMAIN_ID_RE, PROFILE_NAME_RE, WORKSPACE_TOKEN_RE, SHA256_RE, DEFAULT_CAPACITY_RECOVERY;
1993
2058
  var init_capacity_policy = __esm({
1994
2059
  "src/core/capacity-policy.ts"() {
1995
2060
  "use strict";
@@ -2004,6 +2069,15 @@ var init_capacity_policy = __esm({
2004
2069
  PROFILE_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
2005
2070
  WORKSPACE_TOKEN_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2006
2071
  SHA256_RE = /^[0-9a-f]{64}$/;
2072
+ DEFAULT_CAPACITY_RECOVERY = {
2073
+ enabled: false,
2074
+ maxProcessRestarts: 2,
2075
+ maxServiceRestarts: 1,
2076
+ maxCorrectiveActions: 3,
2077
+ windowSeconds: 600,
2078
+ observationSeconds: 30,
2079
+ resumeDwellSeconds: 300
2080
+ };
2007
2081
  }
2008
2082
  });
2009
2083
 
@@ -4133,7 +4207,7 @@ function loadRepoConfig(repoPath, read2 = (file) => import_node_fs7.default.read
4133
4207
  const config = parseConfig(parsed ?? {}, configPath);
4134
4208
  const requiredVersion = config.devrouter?.version;
4135
4209
  if (requiredVersion && !hasWarnedVersionMismatch) {
4136
- const cliVersion = true ? "0.0.71" : "0.0.0-dev";
4210
+ const cliVersion = true ? "0.0.73" : "0.0.0-dev";
4137
4211
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
4138
4212
  hasWarnedVersionMismatch = true;
4139
4213
  process.stderr.write(
@@ -5386,7 +5460,7 @@ var init_reliability_contract = __esm({
5386
5460
 
5387
5461
  // src/core/reliability-operation-store.ts
5388
5462
  function installedCliVersion() {
5389
- return true ? "0.0.71" : "0.0.0-dev";
5463
+ return true ? "0.0.73" : "0.0.0-dev";
5390
5464
  }
5391
5465
  function compareCliVersions(left, right) {
5392
5466
  const parts = (value) => value.split(".").map((part) => Number.parseInt(part, 10) || 0);
@@ -6423,6 +6497,16 @@ async function readProviderGeneration(repoPath, providerId, signal) {
6423
6497
  throw new Error("Capacity provider generation is malformed.");
6424
6498
  return generation;
6425
6499
  }
6500
+ function ownershipPopulationEvidence(record5, state) {
6501
+ return {
6502
+ environmentId: record5.state.environmentId,
6503
+ executionPolicy: record5.state.executionPolicy,
6504
+ enrollment: record5.enrollment ?? null,
6505
+ composeProject: state?.composeProject ?? null,
6506
+ devpodId: state?.devpodId ?? null,
6507
+ stopBaseline: state?.stopBaseline ?? null
6508
+ };
6509
+ }
6426
6510
  async function resolveCapacityOwnership(policy, runtimeDomain, signal, dependencies = {
6427
6511
  resolve: resolveCapacityEnrollment,
6428
6512
  providerGeneration: readProviderGeneration,
@@ -6473,18 +6557,21 @@ async function resolveCapacityOwnership(policy, runtimeDomain, signal, dependenc
6473
6557
  check();
6474
6558
  if (!(0, import_node_util5.isDeepStrictEqual)(current, entry.binding) || !(0, import_node_util5.isDeepStrictEqual)(generation, entry.generation))
6475
6559
  throw new Error("Capacity provider ownership changed during collection.");
6476
- if (!entry.evidence || !(0, import_node_util5.isDeepStrictEqual)(entry.evidence, {
6477
- record: dependencies.journal({
6478
- repoPath: entry.enrollment.repoPath,
6479
- workspace: entry.enrollment.workspace || null,
6480
- provider: entry.enrollment.provider
6481
- }),
6482
- state: dependencies.managed(
6483
- entry.enrollment.repoPath,
6484
- entry.enrollment.workspace || void 0,
6485
- (file) => readControllerEvidence(file, 1048576)
6560
+ if (!entry.evidence || !(0, import_node_util5.isDeepStrictEqual)(
6561
+ entry.evidence,
6562
+ ownershipPopulationEvidence(
6563
+ dependencies.journal({
6564
+ repoPath: entry.enrollment.repoPath,
6565
+ workspace: entry.enrollment.workspace || null,
6566
+ provider: entry.enrollment.provider
6567
+ }),
6568
+ dependencies.managed(
6569
+ entry.enrollment.repoPath,
6570
+ entry.enrollment.workspace || void 0,
6571
+ (file) => readControllerEvidence(file, 1048576)
6572
+ )
6486
6573
  )
6487
- }))
6574
+ ))
6488
6575
  throw new Error("Capacity ownership records changed before publication.");
6489
6576
  }
6490
6577
  },
@@ -6533,7 +6620,7 @@ async function resolveCapacityOwnership(policy, runtimeDomain, signal, dependenc
6533
6620
  throw new Error("Capacity witnessed startup generation changed.");
6534
6621
  } else if (!baseline || baseline.provider !== enrollment.provider || baseline.context !== entry.generation.context || baseline.uid !== entry.generation.uid || baseline.sourceContainer !== entry.generation.sourceContainer || baseline.endpoint !== `unix://${domain.endpoint}` || baseline.daemonId !== domain.daemonId)
6535
6622
  throw new Error("Capacity population lacks an exact retained runtime baseline.");
6536
- const evidence = { record: record5, state };
6623
+ const evidence = ownershipPopulationEvidence(record5, state);
6537
6624
  if (entry.evidence && !(0, import_node_util5.isDeepStrictEqual)(entry.evidence, evidence))
6538
6625
  throw new Error("Capacity ownership generation changed during collection.");
6539
6626
  entry.evidence = structuredClone(evidence);
@@ -6569,7 +6656,13 @@ async function resolveCapacityOwnership(policy, runtimeDomain, signal, dependenc
6569
6656
  endpoint: `unix://${domain.endpoint}`
6570
6657
  });
6571
6658
  }
6572
- if (!(0, import_node_util5.isDeepStrictEqual)(state, readState()) || !(0, import_node_util5.isDeepStrictEqual)(record5, dependencies.journal(identity2)))
6659
+ if (!(0, import_node_util5.isDeepStrictEqual)(state, readState()) || !(0, import_node_util5.isDeepStrictEqual)(
6660
+ ownershipPopulationEvidence(record5, state),
6661
+ ownershipPopulationEvidence(
6662
+ dependencies.journal(identity2),
6663
+ state
6664
+ )
6665
+ ))
6573
6666
  throw new Error("Capacity ownership records changed during collection.");
6574
6667
  result.push({ environmentId: binding.environment.id, containers: containers2 });
6575
6668
  }
@@ -6623,8 +6716,8 @@ async function collectDeclaredRuntimeCapacity(domain, proveOwned, signal, depend
6623
6716
  const sampledAtMs = Date.now();
6624
6717
  const cancellation = new AbortController();
6625
6718
  const combined = AbortSignal.any([signal, cancellation.signal]);
6626
- const timer = setTimeout(() => cancellation.abort(), 3e3);
6627
- const deadline = performance.now() + 3e3;
6719
+ const timer = setTimeout(() => cancellation.abort(), RUNTIME_PROBE_BUDGET_MS);
6720
+ const deadline = performance.now() + RUNTIME_PROBE_BUDGET_MS;
6628
6721
  const check = () => {
6629
6722
  if (combined.aborted || performance.now() >= deadline)
6630
6723
  throw new Error("Runtime capacity collection expired.");
@@ -6717,13 +6810,14 @@ async function collectDeclaredRuntimeCapacity(domain, proveOwned, signal, depend
6717
6810
  clearTimeout(timer);
6718
6811
  }
6719
6812
  }
6720
- var import_node_util6;
6813
+ var import_node_util6, RUNTIME_PROBE_BUDGET_MS;
6721
6814
  var init_capacity_runtime_probe = __esm({
6722
6815
  "src/core/capacity-runtime-probe.ts"() {
6723
6816
  "use strict";
6724
6817
  import_node_util6 = require("util");
6725
6818
  init_capacity_docker_probe();
6726
6819
  init_capacity_host_probe();
6820
+ RUNTIME_PROBE_BUDGET_MS = 8e3;
6727
6821
  }
6728
6822
  });
6729
6823
 
@@ -8538,8 +8632,8 @@ function parseWorkspaceRuntime(value) {
8538
8632
  }
8539
8633
  function isRuntimeInstalled(runtime) {
8540
8634
  const probeArgs = runtime === "devsy" ? ["--version"] : ["version"];
8541
- const probe = (0, import_node_child_process10.spawnSync)(runtime, probeArgs, { encoding: "utf-8" });
8542
- return probe.status === 0 && !probe.error;
8635
+ const probe2 = (0, import_node_child_process10.spawnSync)(runtime, probeArgs, { encoding: "utf-8" });
8636
+ return probe2.status === 0 && !probe2.error;
8543
8637
  }
8544
8638
  function readWorkspaceRuntimeConfig() {
8545
8639
  let raw;
@@ -11833,8 +11927,6 @@ function handleOperationRequest(state, event) {
11833
11927
  );
11834
11928
  if (state.operationHistory.some((entry) => entry.id === event.operationId))
11835
11929
  return unchanged(state, "conflict");
11836
- if (state.executionPolicy !== "manual" && state.operationHistory.length >= RELIABILITY_MAX_ITEMS)
11837
- return unchanged(state, "blocked");
11838
11930
  if (event.kind === "exec" && canExecAfterInterruptedEnsure(state) && event.recoverInterruptedEnsure !== true)
11839
11931
  return blocked(
11840
11932
  state,
@@ -11857,7 +11949,7 @@ function handleOperationRequest(state, event) {
11857
11949
  state,
11858
11950
  `exec requires a running workspace (runtimeRunning=${event.runtimeRunning}, desired='${state.desired}').`
11859
11951
  );
11860
- const rollover = state.executionPolicy === "manual" && state.operationHistory.length >= RELIABILITY_MAX_ITEMS;
11952
+ const rollover = state.operationHistory.length >= RELIABILITY_MAX_ITEMS;
11861
11953
  let retired = -1;
11862
11954
  if (rollover) {
11863
11955
  const latestEnsureId = [...state.operationHistory].reverse().find((entry) => entry.kind === "ensure")?.id;
@@ -12140,14 +12232,16 @@ function handleRecover(state, event) {
12140
12232
  if (state.executionPolicy === "manual" || state.desired !== "running")
12141
12233
  return unchanged(state, "blocked");
12142
12234
  if (possibleDispatch(state.operation)) return unchanged(state, "blocked");
12235
+ const stranded = state.operation?.drained === true && ["NOT_STARTED", "NOT_LAUNCHED"].includes(state.operation.status);
12236
+ const operation = stranded ? null : state.operation;
12143
12237
  if (state.incident !== null && state.incident.id !== event.incidentId) {
12144
12238
  return unchanged(state, "conflict");
12145
12239
  }
12146
12240
  if (state.incident !== null) {
12147
- if (state.operation && pendingCommand(state.operation)) {
12148
- return state.operation.id === event.operationId && state.phase === "recovering" ? unchanged(state, "joined") : unchanged(state, "conflict");
12241
+ if (operation && pendingCommand(operation)) {
12242
+ return operation.id === event.operationId && state.phase === "recovering" ? unchanged(state, "joined") : unchanged(state, "conflict");
12149
12243
  }
12150
- if (state.operation?.id === event.operationId && state.operation.status === "COMPLETED") {
12244
+ if (operation?.id === event.operationId && operation.status === "COMPLETED") {
12151
12245
  return unchanged(state, "joined");
12152
12246
  }
12153
12247
  if (state.incident.correctiveActionsTaken >= state.incident.actionLimit) {
@@ -12155,17 +12249,20 @@ function handleRecover(state, event) {
12155
12249
  }
12156
12250
  }
12157
12251
  if (state.incident === null) {
12158
- if (state.operation?.status === "NOT_STARTED") return unchanged(state, "blocked");
12159
- if (state.operation?.id === event.operationId) return unchanged(state, "conflict");
12252
+ if (operation?.status === "NOT_STARTED") return unchanged(state, "blocked");
12253
+ if (operation?.id === event.operationId) return unchanged(state, "conflict");
12160
12254
  state.incident = {
12161
12255
  id: event.incidentId,
12162
12256
  correctiveActionsTaken: 0,
12163
12257
  actionLimit: event.actionLimit
12164
12258
  };
12165
12259
  }
12166
- if (!state.operation || state.operation.status === "COMPLETED" || state.operation.status === "INTERRUPTED") {
12260
+ if (!operation || operation.status === "COMPLETED" || operation.status === "INTERRUPTED") {
12167
12261
  const runtimeGeneration = advance(state.runtimeGeneration);
12168
12262
  if (runtimeGeneration === null) return unchanged(state, "blocked");
12263
+ const consumer = state.consumers[0];
12264
+ if (state.profile === null || consumer === void 0) return unchanged(state, "blocked");
12265
+ if (state.operationHistory.length >= RELIABILITY_MAX_ITEMS) return unchanged(state, "blocked");
12169
12266
  state.runtimeGeneration = runtimeGeneration;
12170
12267
  state.observations = [];
12171
12268
  state.operation = {
@@ -12175,6 +12272,12 @@ function handleRecover(state, event) {
12175
12272
  status: "NOT_STARTED",
12176
12273
  exitCode: null
12177
12274
  };
12275
+ state.operationHistory.push({
12276
+ ...state.operation,
12277
+ key: event.operationId,
12278
+ profile: state.profile,
12279
+ consumer: cloneConsumer(consumer)
12280
+ });
12178
12281
  }
12179
12282
  state.phase = "recovering";
12180
12283
  return transition(state, "accepted");
@@ -12267,6 +12370,49 @@ function stepReliability(state, event, nowMs) {
12267
12370
  }
12268
12371
  return result;
12269
12372
  }
12373
+ function requiredCapabilities(state) {
12374
+ const required = /* @__PURE__ */ new Set();
12375
+ for (const consumer of state.consumers) {
12376
+ for (const capability of consumer.requiredCapabilities) required.add(capability);
12377
+ }
12378
+ return [...required];
12379
+ }
12380
+ function failedCapability(state, required, nowMs) {
12381
+ return required.some((capability) => {
12382
+ const observation = state.observations.find((entry) => entry.capability === capability);
12383
+ return observation !== void 0 && observation.infrastructure === "failed" && observation.observedAtMs >= state.observationsAfterMs && observation.observedAtMs <= nowMs && nowMs - observation.observedAtMs < observation.validForMs;
12384
+ });
12385
+ }
12386
+ function decideRecovery(input2) {
12387
+ const { state, nowMs, actionLimit, pressureDwellSatisfied } = input2;
12388
+ assertReliabilityState(state);
12389
+ if (!isReliabilityCounter(nowMs) || nowMs < state.observationsAfterMs)
12390
+ throw new Error("Invalid reliability clock.");
12391
+ if (input2.failedCapabilities !== void 0 && (input2.failedCapabilities.length > RELIABILITY_MAX_ITEMS || input2.failedCapabilities.some((capability) => !isReliabilityId(capability))))
12392
+ throw new Error("Invalid recovery evidence.");
12393
+ if (state.executionPolicy === "manual") return { action: "none", reason: "unmanaged" };
12394
+ if (state.desired === "stopped-by-user") return { action: "none", reason: "user-stopped" };
12395
+ if (state.desired === "parked-for-capacity") {
12396
+ const resumeReady = pressureDwellSatisfied && state.stopProof.workloadsStopped && state.stopProof.routesRemoved && state.consumers.length > 0 && state.admission === "admitted" && state.profile !== null && (state.incident === null || state.incident.correctiveActionsTaken < state.incident.actionLimit) && state.operation !== null && ["NOT_STARTED", "COMPLETED", "INTERRUPTED"].includes(state.operation.status);
12397
+ return { action: resumeReady ? "resume" : "none", reason: "parked" };
12398
+ }
12399
+ if (!isReliabilityCounter(actionLimit) || actionLimit < 1)
12400
+ throw new Error("Invalid recovery action budget.");
12401
+ const failed = input2.failedCapabilities !== void 0 ? input2.failedCapabilities.length > 0 : failedCapability(state, requiredCapabilities(state), nowMs);
12402
+ if (!failed) return { action: "none", reason: "healthy" };
12403
+ if (possibleDispatch(state.operation)) return { action: "none", reason: "active-operation" };
12404
+ if (state.incident !== null) {
12405
+ if (state.incident.correctiveActionsTaken >= state.incident.actionLimit)
12406
+ return { action: "blocked", reason: "budget-exhausted" };
12407
+ return {
12408
+ action: "continue",
12409
+ reason: "recovery-eligible",
12410
+ incidentId: state.incident.id,
12411
+ actionLimit: state.incident.actionLimit
12412
+ };
12413
+ }
12414
+ return { action: "start", reason: "recovery-eligible", actionLimit };
12415
+ }
12270
12416
  var INFRASTRUCTURE_VALUES, APPLICATION_VALUES, ADMISSION_VALUES;
12271
12417
  var init_reliability_model = __esm({
12272
12418
  "src/core/reliability-model.ts"() {
@@ -14843,6 +14989,52 @@ function assertCurrentCapacityController(expected, directory = import_node_path4
14843
14989
  if (!expected || current?.store !== expected.store || current.epoch !== expected.epoch)
14844
14990
  throw new Error("Capacity controller incarnation changed.");
14845
14991
  }
14992
+ function prepareRecoveryLifecycleOperation(input2) {
14993
+ const ids2 = newLifecycleIds();
14994
+ return updateReliabilityOperation(input2.identity, (record5) => {
14995
+ assertCurrentCapacityController(input2.controller);
14996
+ if (record5.version !== 2 || record5.state.executionPolicy !== "capacity-managed" || record5.enrollment?.policyRevision !== input2.policyRevision)
14997
+ throw new Error("Managed recovery requires current durable enrollment.");
14998
+ if (record5.phaseSettlement) return void 0;
14999
+ reconcileDrained(record5);
15000
+ if (record5.worker) return void 0;
15001
+ const decision = decideRecovery({
15002
+ state: record5.state,
15003
+ nowMs: Date.now(),
15004
+ actionLimit: input2.actionLimit,
15005
+ pressureDwellSatisfied: false,
15006
+ failedCapabilities: input2.failedCapabilities
15007
+ });
15008
+ if (decision.action !== "start" && decision.action !== "continue") return void 0;
15009
+ const transition2 = stepReliability(
15010
+ record5.state,
15011
+ {
15012
+ ...reliabilityFence(record5.state),
15013
+ type: "recover",
15014
+ incidentId: decision.action === "continue" ? decision.incidentId : input2.incidentId,
15015
+ actionLimit: input2.actionLimit,
15016
+ operationId: ids2.operationId
15017
+ },
15018
+ Date.now()
15019
+ );
15020
+ if (transition2.outcome !== "accepted") return void 0;
15021
+ record5.state = transition2.state;
15022
+ record5.outcome = null;
15023
+ record5.result = null;
15024
+ record5.startupWitness = null;
15025
+ return {
15026
+ operationId: ids2.operationId,
15027
+ request: {
15028
+ ...ids2,
15029
+ kind: "ensure",
15030
+ repoPath: input2.identity.repoPath,
15031
+ identity: { ...input2.identity },
15032
+ fence: reliabilityFence(record5.state),
15033
+ options: { profile: input2.profile, quiet: true }
15034
+ }
15035
+ };
15036
+ });
15037
+ }
14846
15038
  function settlePreparedLifecycleCapacity(input2) {
14847
15039
  const directory = input2.directory ?? import_node_path44.default.join(DEVROUTER_HOME, "controller");
14848
15040
  const store = new CapacityStore(directory);
@@ -15177,6 +15369,17 @@ async function superviseThroughController(input2) {
15177
15369
  );
15178
15370
  };
15179
15371
  const deadline = Date.now() + CAPACITY_CLIENT_WAIT_MS;
15372
+ let outputGapReported = false;
15373
+ const onOutput = (page) => {
15374
+ if (page.gap && !outputGapReported) {
15375
+ process.stderr.write("devrouter: controller output buffer dropped earlier command output.\n");
15376
+ outputGapReported = true;
15377
+ }
15378
+ for (const chunk of page.chunks) {
15379
+ const stream = chunk.stream === "stderr" ? process.stderr : process.stdout;
15380
+ stream.write(Buffer.from(chunk.data, page.encoding));
15381
+ }
15382
+ };
15180
15383
  try {
15181
15384
  const bind = () => observeControllerBinding(directory, {
15182
15385
  path: input2.repoPath,
@@ -15191,7 +15394,7 @@ async function superviseThroughController(input2) {
15191
15394
  let pending = await submitControllerOperation(
15192
15395
  directory,
15193
15396
  input2.kind === "exec" ? { ...binding, requestId: input2.requestId, kind: "exec", command: input2.command ?? [] } : { ...binding, requestId: input2.requestId, kind: "ensure" },
15194
- { waitSeconds: controllerWaitSeconds(deadline) }
15397
+ { waitSeconds: controllerWaitSeconds(deadline), onOutput }
15195
15398
  );
15196
15399
  while (pending.status !== "terminal") {
15197
15400
  cancelled();
@@ -15207,7 +15410,7 @@ async function superviseThroughController(input2) {
15207
15410
  pending = await followControllerOperation(
15208
15411
  directory,
15209
15412
  { ...binding, operationId: pending.operationId },
15210
- { waitSeconds: controllerWaitSeconds(deadline) }
15413
+ { waitSeconds: controllerWaitSeconds(deadline), onOutput }
15211
15414
  );
15212
15415
  } catch {
15213
15416
  await new Promise((resolve) => setTimeout(resolve, Math.min(1e3, deadline - Date.now())));
@@ -15233,23 +15436,28 @@ function renewLifecycleCapacity(request2, policy, samples, controller, directory
15233
15436
  const binding = record5.capacity;
15234
15437
  if (!matchesFence(record5, request2.fence) || !binding || binding.operationId !== request2.operationId || binding.workerId !== request2.workerId || binding.controller?.store !== controller.store || binding.controller?.epoch !== controller.epoch)
15235
15438
  throw new Error("Capacity renewal no longer owns this operation.");
15236
- binding.validUntilMs = 0;
15237
15439
  try {
15238
- if (record5.state.executionPolicy !== "capacity-managed" || record5.state.operation?.id !== request2.operationId || record5.state.operation.drained || record5.state.desired !== "running" || policy.admissions !== "enabled" || policy.revision !== binding.policyRevision || JSON.stringify(readCapacityPolicy(directory)) !== JSON.stringify(policy))
15440
+ if (record5.state.executionPolicy !== "capacity-managed" || record5.state.operation?.id !== request2.operationId || record5.state.operation.drained || record5.state.desired !== "running" || policy.admissions !== "enabled" || policy.revision !== binding.policyRevision || JSON.stringify(readCapacityPolicy(directory)) !== JSON.stringify(policy)) {
15441
+ binding.validUntilMs = 0;
15239
15442
  return false;
15443
+ }
15240
15444
  const enrolled = record5.enrollment;
15241
15445
  const runtime = enrolled && policy.domains[enrolled.runtimeDomain];
15242
15446
  const enrollment = policy.enrollments.find(
15243
15447
  (entry) => entry.repoPath === record5.identity.repoPath && entry.workspace === record5.identity.workspace && entry.provider === record5.identity.provider && entry.providerId === enrolled?.providerId && entry.gitCommonDir === enrolled.gitCommonDir && entry.estimatesDigest === enrolled.estimatesDigest && entry.hostDomain === enrolled.hostDomain && entry.runtimeDomain === enrolled.runtimeDomain && entry.profiles.includes(record5.state.profile ?? "")
15244
15448
  );
15245
- if (!enrolled || enrolled.policyRevision !== policy.revision || !enrollment || runtime?.kind !== "runtime" || runtime.endpoint !== enrolled.endpoint || runtime.daemonId !== enrolled.daemonId)
15449
+ if (!enrolled || enrolled.policyRevision !== policy.revision || !enrollment || runtime?.kind !== "runtime" || runtime.endpoint !== enrolled.endpoint || runtime.daemonId !== enrolled.daemonId) {
15450
+ binding.validUntilMs = 0;
15246
15451
  return false;
15452
+ }
15247
15453
  const snapshot = new CapacityStore(directory).read();
15248
15454
  const reservation = snapshot.reservations.find(
15249
15455
  (entry) => entry.reservationId === binding.reservationId
15250
15456
  );
15251
- if (!reservation || reservation.environmentId !== record5.state.environmentId || reservation.operationId !== request2.operationId || reservation.policyRevision !== policy.revision)
15457
+ if (!reservation || reservation.environmentId !== record5.state.environmentId || reservation.operationId !== request2.operationId || reservation.policyRevision !== policy.revision) {
15458
+ binding.validUntilMs = 0;
15252
15459
  return false;
15460
+ }
15253
15461
  const maxAge = policy.scheduling.maxSampleAgeSeconds * 1e3;
15254
15462
  const decision = evaluateCapacity(
15255
15463
  policy.domains,
@@ -15276,16 +15484,21 @@ function renewLifecycleCapacity(request2, policy, samples, controller, directory
15276
15484
  snapshot.pools
15277
15485
  ).admitted;
15278
15486
  })();
15279
- if (!tolerated) return false;
15487
+ if (!tolerated) {
15488
+ if (decision.reason !== "unknown" && decision.reason !== "stale")
15489
+ binding.validUntilMs = 0;
15490
+ return false;
15491
+ }
15280
15492
  }
15493
+ const observed = Object.keys(reservation.totals).map(
15494
+ (domain) => samples[domain]?.sampledAtMs
15495
+ );
15496
+ if (observed.some((sampledAtMs) => typeof sampledAtMs !== "number")) return false;
15281
15497
  binding.snapshotRevision = snapshot.revision;
15282
- binding.validUntilMs = Math.min(
15283
- ...Object.keys(reservation.totals).map((domain) => samples[domain]?.sampledAtMs ?? 0)
15284
- ) + maxAge;
15498
+ binding.validUntilMs = Math.min(...observed) + maxAge;
15285
15499
  assertCapacityEffect(record5, request2.workerId, nowMs, directory);
15286
15500
  return true;
15287
15501
  } catch {
15288
- binding.validUntilMs = 0;
15289
15502
  return false;
15290
15503
  }
15291
15504
  });
@@ -15429,6 +15642,11 @@ var init_capacity_queue = __esm({
15429
15642
  });
15430
15643
  return request2.operationId;
15431
15644
  }
15645
+ /** True while the queue still owns the operation, awaiting admission or running. */
15646
+ hasOperation(operationId) {
15647
+ const entry = this.entries.get(operationId);
15648
+ return entry !== void 0 && entry.phase !== "terminal";
15649
+ }
15432
15650
  observe(operationId) {
15433
15651
  const entry = this.entries.get(operationId);
15434
15652
  return entry ? { operationId, phase: entry.phase, reason: entry.reason, output: entry.output.read() } : void 0;
@@ -15966,6 +16184,87 @@ function createCapacityController(options) {
15966
16184
  settlePreparations();
15967
16185
  return queue.tick();
15968
16186
  },
16187
+ /**
16188
+ * Open or advance one bounded automatic recovery for an environment whose
16189
+ * required capability has positively failed. It is inert unless the
16190
+ * operator policy enables recovery, and every decision, budget, and
16191
+ * admission rule is the one an operator-requested ensure obeys.
16192
+ */
16193
+ async recover(environment, failedCapabilities, signal) {
16194
+ signal = AbortSignal.any([signal, lifetime.signal]);
16195
+ if (signal.aborted || !policy.recovery.enabled) return;
16196
+ if (failedCapabilities.length === 0 || failedCapabilities.length > 64) return;
16197
+ const current = readCapacityPolicy(options.directory);
16198
+ if (current?.admissions !== "enabled" || !(0, import_node_util12.isDeepStrictEqual)(current, policy)) return;
16199
+ let resolved;
16200
+ try {
16201
+ resolved = await enrollCapacityLifecycle(
16202
+ current,
16203
+ { path: environment.repoPath, profile: environment.profile, require: [] },
16204
+ signal,
16205
+ options.directory
16206
+ );
16207
+ } catch {
16208
+ return;
16209
+ }
16210
+ if (signal.aborted || !(0, import_node_util12.isDeepStrictEqual)(resolved.environment, environment)) return;
16211
+ if (!(0, import_node_util12.isDeepStrictEqual)(readCapacityPolicy(options.directory), current)) return;
16212
+ const runtime = current.domains[resolved.enrollment.runtimeDomain];
16213
+ if (runtime?.kind !== "runtime" || runtime.hostDomain !== resolved.enrollment.hostDomain || current.domains[runtime.hostDomain]?.kind !== "host" || !current.enrollments.some((entry) => (0, import_node_util12.isDeepStrictEqual)(entry, resolved.enrollment)))
16214
+ return;
16215
+ const pool = {
16216
+ daemonId: runtime.daemonId,
16217
+ runtimeDomain: resolved.enrollment.runtimeDomain,
16218
+ hostDomain: runtime.hostDomain,
16219
+ hostChargeCeilingBytes: runtime.hostChargeCeilingBytes
16220
+ };
16221
+ const identity2 = {
16222
+ repoPath: environment.repoPath,
16223
+ workspace: environment.workspace || null,
16224
+ provider: environment.provider
16225
+ };
16226
+ const record5 = readReliabilityOperation(identity2);
16227
+ if (!record5) return;
16228
+ if (record5.state.operation && queue.hasOperation(record5.state.operation.id)) return;
16229
+ let prepared;
16230
+ try {
16231
+ prepared = prepareRecoveryLifecycleOperation({
16232
+ identity: identity2,
16233
+ controller,
16234
+ policyRevision: current.revision,
16235
+ actionLimit: policy.recovery.maxCorrectiveActions,
16236
+ failedCapabilities,
16237
+ profile: environment.profile,
16238
+ incidentId: `incident-${(0, import_node_crypto22.randomUUID)()}`
16239
+ });
16240
+ } catch {
16241
+ return;
16242
+ }
16243
+ if (!prepared) return;
16244
+ const charge = capacityRequest(resolved.estimates, resolved.enrollment, {
16245
+ environmentId: record5.state.environmentId,
16246
+ profile: environment.profile,
16247
+ ...record5.activeProfile ? { activeProfile: record5.activeProfile } : {},
16248
+ kind: "ensure"
16249
+ });
16250
+ try {
16251
+ queue.enqueue(
16252
+ prepared.request,
16253
+ {
16254
+ ...charge,
16255
+ operationId: prepared.operationId,
16256
+ reservationId: (0, import_node_crypto22.randomUUID)(),
16257
+ policyRevision: current.revision
16258
+ },
16259
+ { estimates: resolved.estimates, enrollment: resolved.enrollment, pool }
16260
+ );
16261
+ } catch {
16262
+ try {
16263
+ retireQueuedLifecycle(prepared.request);
16264
+ } catch {
16265
+ }
16266
+ }
16267
+ },
15969
16268
  close() {
15970
16269
  lifetime.abort();
15971
16270
  queue.close();
@@ -16096,12 +16395,13 @@ var init_controller_monitor = __esm({
16096
16395
  ControllerObservationBindingChanged = class extends Error {
16097
16396
  };
16098
16397
  ControllerMonitor = class {
16099
- constructor(sessions, collect, serialize, clock = () => Math.floor(performance.now()), fence = withReliabilityObservationFence) {
16398
+ constructor(sessions, collect, serialize, clock = () => Math.floor(performance.now()), fence = withReliabilityObservationFence, recover) {
16100
16399
  this.sessions = sessions;
16101
16400
  this.collect = collect;
16102
16401
  this.serialize = serialize;
16103
16402
  this.clock = clock;
16104
16403
  this.fence = fence;
16404
+ this.recover = recover;
16105
16405
  }
16106
16406
  active = /* @__PURE__ */ new Map();
16107
16407
  lastStarted = /* @__PURE__ */ new Map();
@@ -16196,6 +16496,18 @@ var init_controller_monitor = __esm({
16196
16496
  this.sessions.publish(bindings, projections, commitTime, Date.now());
16197
16497
  });
16198
16498
  });
16499
+ if (this.recover === void 0 || this.stopped) return;
16500
+ const required = new Set(requirements.map(controllerCapability));
16501
+ const failed = batch.capabilities.filter(
16502
+ (capability) => capability.infrastructure === "failed" && required.has(capability.capability)
16503
+ ).map((capability) => capability.capability);
16504
+ if (failed.length > 0)
16505
+ void this.recover(
16506
+ batch.environment,
16507
+ failed,
16508
+ this.active.get(environment.id)?.signal ?? abort.signal
16509
+ ).catch(() => {
16510
+ });
16199
16511
  }).catch(async (error) => {
16200
16512
  if (!this.stopped)
16201
16513
  await this.serialize(() => {
@@ -16219,7 +16531,7 @@ var init_controller_monitor = __esm({
16219
16531
  async function observeControllerProcess(containerId, name, signal) {
16220
16532
  if (!/^[a-f0-9]{12,64}$/.test(containerId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(name))
16221
16533
  throw new Error("Process observation identity is invalid.");
16222
- const output2 = await runControllerProbe(
16534
+ const result = await runControllerProbeStatus(
16223
16535
  "docker",
16224
16536
  [
16225
16537
  "exec",
@@ -16235,12 +16547,14 @@ async function observeControllerProcess(containerId, name, signal) {
16235
16547
  ],
16236
16548
  signal
16237
16549
  );
16238
- const identity2 = output2.trim();
16550
+ if (result.code === PROCESS_ABSENT_EXIT) return { kind: "absent" };
16551
+ if (result.code !== 0) throw new Error("Process observation is unavailable.");
16552
+ const identity2 = result.output.trim();
16239
16553
  if (!/^[1-9][0-9]* [0-9]+ [a-zA-Z0-9][a-zA-Z0-9._:-]{0,4095}$/.test(identity2))
16240
16554
  throw new Error("Process observation is unavailable.");
16241
- return identity2;
16555
+ return { kind: "present", identity: identity2 };
16242
16556
  }
16243
- var PROCESS_OBSERVATION;
16557
+ var PROCESS_OBSERVATION, PROCESS_ABSENT_EXIT;
16244
16558
  var init_controller_process_observation = __esm({
16245
16559
  "src/core/controller-process-observation.ts"() {
16246
16560
  "use strict";
@@ -16250,7 +16564,9 @@ set -euo pipefail
16250
16564
  name="$1"
16251
16565
  [[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || exit 1
16252
16566
  file="${"$"}{DEVROUTER_PROCESS_STATE_DIR:-/tmp}/devrouter-process-$name.state"
16253
- [ -f "$file" ] && [ ! -L "$file" ] || exit 1
16567
+ [ ! -L "$file" ] || exit 1
16568
+ # A missing marker is positive absence: the expected process was never recorded.
16569
+ [ -f "$file" ] || exit 3
16254
16570
  [ "$(stat -c %s "$file")" -le 4096 ] || exit 1
16255
16571
  before="$(head -c 4097 "$file")"
16256
16572
  read -r pid pgid fingerprint extra <<<"$before"
@@ -16270,11 +16586,13 @@ identity() {
16270
16586
  tr '\0' '\n' <"/proc/$pid/environ" | grep -Fx "DEVROUTER_PROCESS_FINGERPRINT=$fingerprint" >/dev/null || return 1
16271
16587
  printf '%s' "${"$"}{fields[19]}"
16272
16588
  }
16273
- birth="$(identity)"
16589
+ birth="$(identity)" || exit 3
16274
16590
  [ "$birth" = "$(identity)" ] || exit 1
16275
- [ ! -L "$file" ] && [ "$before" = "$(head -c 4097 "$file")" ] || exit 1
16591
+ [ ! -L "$file" ] || exit 1
16592
+ [ "$before" = "$(head -c 4097 "$file")" ] || exit 3
16276
16593
  printf '%s %s %s\n' "$pid" "$birth" "$fingerprint"
16277
16594
  `;
16595
+ PROCESS_ABSENT_EXIT = 3;
16278
16596
  }
16279
16597
  });
16280
16598
 
@@ -16490,10 +16808,14 @@ var init_controller_observation = __esm({
16490
16808
  ];
16491
16809
  const processes = [];
16492
16810
  let processesVerified = healthy;
16811
+ let processesAbsent = false;
16493
16812
  if (healthy && requirements.some((value) => value.startsWith("app:"))) {
16494
16813
  try {
16495
- for (const name of profile.managedRuntime.processes)
16496
- processes.push(await observeControllerProcess(primary[0].id, name, signal));
16814
+ for (const name of profile.managedRuntime.processes) {
16815
+ const observation = await observeControllerProcess(primary[0].id, name, signal);
16816
+ if (observation.kind === "absent") processesAbsent = true;
16817
+ else processes.push(observation.identity);
16818
+ }
16497
16819
  } catch (error) {
16498
16820
  if (signal.aborted) throw error;
16499
16821
  processesVerified = false;
@@ -16507,7 +16829,7 @@ var init_controller_observation = __esm({
16507
16829
  const routeMatches = routes.some(
16508
16830
  (route) => route.workspace === environment.workspace && route.name === app.name && route.host === app.host && route.mode === "proxy" && route.upstreamHost === upstream.upstreamHost && route.port === upstream.port
16509
16831
  );
16510
- const http2 = healthy && routeMatches && processesVerified ? await probeHttpReadiness(
16832
+ const http2 = healthy && routeMatches && processesVerified && !processesAbsent ? await probeHttpReadiness(
16511
16833
  app.host,
16512
16834
  app.readiness,
16513
16835
  signal,
@@ -16516,7 +16838,7 @@ var init_controller_observation = __esm({
16516
16838
  ) : void 0;
16517
16839
  capabilities.push({
16518
16840
  capability: controllerCapability(requirement),
16519
- infrastructure: healthy && routeMatches ? "healthy" : "unknown",
16841
+ infrastructure: healthy && routeMatches ? processesAbsent ? "failed" : "healthy" : "unknown",
16520
16842
  application: http2?.ok ? "verified" : http2?.classification === "application-contract" ? "unready" : "unverified",
16521
16843
  observedAtMs: 0,
16522
16844
  validForMs: 15e3
@@ -16524,13 +16846,15 @@ var init_controller_observation = __esm({
16524
16846
  }
16525
16847
  if (processesVerified && requirements.some((value) => value.startsWith("app:"))) {
16526
16848
  try {
16527
- for (let index = 0; index < profile.managedRuntime.processes.length; index++)
16528
- if (processes[index] !== await observeControllerProcess(
16849
+ for (let index = 0; index < profile.managedRuntime.processes.length; index++) {
16850
+ const recheck = await observeControllerProcess(
16529
16851
  primary[0].id,
16530
16852
  profile.managedRuntime.processes[index],
16531
16853
  signal
16532
- ))
16854
+ );
16855
+ if (recheck.kind !== "present" || processes[index] !== recheck.identity)
16533
16856
  throw new Error("Observation process changed.");
16857
+ }
16534
16858
  } catch (error) {
16535
16859
  if (signal.aborted) throw error;
16536
16860
  for (const capability of capabilities)
@@ -16841,12 +17165,19 @@ async function runController(options) {
16841
17165
  let serial = Promise.resolve();
16842
17166
  const monotonic = () => Math.floor(performance.now());
16843
17167
  let fatal;
16844
- const monitor = options.collect ? new ControllerMonitor(sessions, options.collect, (operation) => {
16845
- const pending = serial.then(operation);
16846
- serial = pending.catch(() => {
16847
- });
16848
- return pending;
16849
- }) : void 0;
17168
+ const monitor = options.collect ? new ControllerMonitor(
17169
+ sessions,
17170
+ options.collect,
17171
+ (operation) => {
17172
+ const pending = serial.then(operation);
17173
+ serial = pending.catch(() => {
17174
+ });
17175
+ return pending;
17176
+ },
17177
+ void 0,
17178
+ void 0,
17179
+ operations?.recover
17180
+ ) : void 0;
16850
17181
  const server = import_node_net4.default.createServer((socket) => {
16851
17182
  if (sockets.size >= 32) {
16852
17183
  socket.destroy();
@@ -17920,8 +18251,8 @@ function buildOnboardingPrompt(options = {}) {
17920
18251
  "- Lifecycle: after one-time setup, use `devrouter ensure .` for both primary and linked checkouts; never branch on checkout kind or use live verify as startup. Managed consumer images contain no devrouter package/helper: ensure delivers its matching helper at runtime and invokes an exact captured snapshot of the repository-owned post-start adapter. Keep `.devrouter.yml` as the only consumer-side version pin. Use `devrouter stop .` for a non-destructive pause, `devrouter stop . --delete` only for explicit exact-owner cleanup without removing the checkout, and `devrouter exec . -- <command...>` for container commands. Never substitute raw DevPod/Devsy mutations; they bypass the machine-global ownership lock, which serializes provider mutations in a fair arrival-order queue and lets contenders wait up to thirty minutes with throttled stderr progress before failing with the queue position or holder PID and true durations. `workspace up` creates linked worktrees; destructive worktree removal and GC remain ledger-scoped.",
17921
18252
  "- After a drained interrupted ensure, manual exec can use an exactly proven retained Devsy runtime with the plain local Docker command. Revalidate identity for each tooling command until later preparation reconciles startup; preserve configuration, data and interrupted history. This does not repair absent stop baselines or provide OOM protection.",
17922
18253
  "- Busy ensure and exec wait up to thirty minutes for a positively identified active lifecycle worker on the same checkout, with stderr progress. Do not stop healthy work to make room. Commands stay serial; timeout or cancellation before admission leaves prior work untouched. A lifecycle fence change cancels admission, and uncertain execution is never replayed.",
17923
- "- Ensure reconciles interrupted or never-dispatched ensure after positive worker drainage. Unknown arbitrary exec never replays; use explicit stop to reconcile that uncertainty. Preserve journals and generated configuration if evidence is unavailable. This does not prevent OOM or activate capacity-managed recovery.",
17924
- "- Capacity admission is opt-in: only an operator-enabled capacity policy with an enrolled checkout routes ensure and exec through controller admission. The CLI follows the decision with bounded reconnecting waits that survive controller restarts, worker results are journalled atomically, and stop bypasses admission. Without an enabled policy lifecycle behavior is unchanged.",
18254
+ "- Ensure reconciles interrupted or never-dispatched ensure after positive worker drainage. Unknown arbitrary exec never replays; use explicit stop to reconcile that uncertainty. Preserve journals and generated configuration if evidence is unavailable. This does not detect or prevent OOM; bounded automatic recovery runs only when the operator enables the capacity recovery policy.",
18255
+ "- Capacity admission is opt-in: only an operator-enabled capacity policy with an enrolled checkout routes ensure and exec through controller admission. The CLI follows the decision with bounded reconnecting waits that survive controller restarts, worker results are journalled atomically, and stop bypasses admission. Without an enabled policy lifecycle behavior is unchanged. An optional recovery block adds bounded automatic recovery: one journal-admitted corrective ensure per positively failed required capability, bounded by per-scope restart counts, an aggregate corrective-action cap and a rolling observation window. It is inert unless enabled.",
17925
18256
  "- A lock that cannot prove its owner fails closed with the failing inspection stage, the exact lock path, and a portable reproduction command; run the canonical command in a permitted host context. No identity fallback is attempted, and `devrouter controller` prints the underlying cause on stderr while keeping the stable `controller-unavailable` JSON on stdout.",
17926
18257
  "- Devsy agent readiness: run `devrouter setup --yes --workspace-runtime devsy` once so Devrouter acquires and verifies the pinned agent in its own machine cache. `doctor` reports `ready`, `missing`, `stale`, or `invalid` without network access, and `ensure` fails before the provider queue when readiness is not `ready`. An explicit `DEVSY_AGENT_BINARY` remains authoritative and must match a pinned official asset.",
17927
18258
  "- A managed adapter plus `postCreateCommand` requires `waitFor` exactly `postCreateCommand` or `postStartCommand`; managed selective config preserves lifecycle fields and changes only `runServices`.",
@@ -20400,7 +20731,7 @@ async function buildDoctorReport(options = {}) {
20400
20731
  const config = runtimeConfig.config;
20401
20732
  loadedConfig = config;
20402
20733
  loadedWorkspace = runtimeConfig.workspace;
20403
- const cliVersion = true ? "0.0.71" : "0.0.0-dev";
20734
+ const cliVersion = true ? "0.0.73" : "0.0.0-dev";
20404
20735
  const configVersion = config.devrouter?.version;
20405
20736
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
20406
20737
  addCheck(checks, {
@@ -25830,7 +26161,7 @@ var init_version = __esm({
25830
26161
 
25831
26162
  // src/cli.ts
25832
26163
  var import_commander = require("commander");
25833
- var CLI_VERSION = true ? "0.0.71" : "0.0.0-dev";
26164
+ var CLI_VERSION = true ? "0.0.73" : "0.0.0-dev";
25834
26165
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
25835
26166
  function withErrorHandling(action2) {
25836
26167
  return async (...args) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrouter/cli",
3
- "version": "0.0.71",
3
+ "version": "0.0.73",
4
4
  "description": "Local dev routing CLI with shared Traefik reverse proxy",
5
5
  "author": "Roland Schlaefli",
6
6
  "homepage": "https://github.com/rschlaefli/devrouter#readme",
@@ -0,0 +1,27 @@
1
+ # Upgrade to devrouter 0.0.72
2
+
3
+ This patch release lets capacity-enrolled checkouts keep working past the
4
+ 128-entry lifecycle journal cap. Previously the journal refused every later
5
+ `ensure` and `exec` once it filled, because history rollover ran only under
6
+ manual execution policy. Enrolled repositories now roll over under the same
7
+ conservative guards as manual ones. No schema, configuration, or CLI flag
8
+ changes.
9
+
10
+ ## Task
11
+
12
+ Install the published release and update `.devrouter.yml` devrouter.version to
13
+ 0.0.72. No configuration change is required.
14
+
15
+ ## Validation
16
+
17
+ On a repository with an enabled capacity policy enrollment whose journal is at
18
+ or near its cap, run `devrouter ensure <path> --json` and confirm the operation
19
+ is admitted and dispatched instead of failing with a blocked lifecycle
20
+ transition. `devrouter exec <path> -- echo ok` must complete, and
21
+ `devrouter stop <path>` must end with the runtime stopped and zero routes.
22
+
23
+ ## Report
24
+
25
+ Report the installed CLI version, the enrolled repository, the journal entry
26
+ count before and after the operation, and the final stopped status with the
27
+ route count.
@@ -0,0 +1,54 @@
1
+ # Upgrade to devrouter 0.0.73
2
+
3
+ This patch release adds opt-in bounded automatic recovery and fixes three
4
+ capacity-managed defects found while dogfooding the eLearning canary.
5
+
6
+ - Capacity-enrolled `exec` now returns the wrapped command's output. The
7
+ controller captured a supervised worker's stdout and stderr into its bounded
8
+ buffer and the client exposed it through `onOutput`, but the CLI never
9
+ forwarded it, so `devrouter exec <path> -- <command>` returned only the exit
10
+ status. The controller-supervised path now streams each bounded page to the
11
+ caller and prints one notice if the buffer dropped earlier bytes.
12
+ - A capacity-enrolled operation keeps its effect authority while the controller
13
+ cannot observe capacity. Renewal previously wrote a zero deadline before each
14
+ attempt and again on failure, so one missing, stale, or unknown sample revoked
15
+ authority mid-start and later commands failed with `Capacity effect authority
16
+ is absent or stale.` Renewal now extends only when every reserved domain has a
17
+ usable sample. Ownership evidence also compares environment identity instead
18
+ of whole journal records, so the controller rewriting its own bookkeeping no
19
+ longer reports `unknown` for a healthy runtime.
20
+ - The observation collector reports a required managed process that has
21
+ positively exited as a failed capability instead of `unknown`, so bounded
22
+ recovery can act on it. Ambiguous or unreadable process state stays `unknown`.
23
+ - Optional bounded automatic recovery. A new `recovery` block in the capacity
24
+ policy enables a supervisor that reacts to a positively failed required
25
+ capability with one journal-admitted corrective `ensure`, bounded by
26
+ per-scope restart counts, an aggregate corrective-action cap and a rolling
27
+ observation window. The block is absent by default. Parking and controlled
28
+ resume remain model-only, and there is no OOM detection: a container-local OOM
29
+ still surfaces only as a failed capability.
30
+
31
+ No `.devrouter.yml` schema, CLI flag, or default behavior changes.
32
+
33
+ ## Task
34
+
35
+ Install the published release and update `.devrouter.yml` devrouter.version to
36
+ 0.0.73. No configuration change is required. Enabling bounded recovery is
37
+ optional: add a `recovery` block to the operator capacity policy only when you
38
+ want automatic corrective ensures.
39
+
40
+ ## Validation
41
+
42
+ On a capacity-enrolled checkout, run
43
+ `devrouter exec <path> -- bash -lc 'echo checked'` and confirm the command's
44
+ output appears. Confirm a failing command still returns its non-zero exit
45
+ status, and that `devrouter ensure <path>` reports the environment ready with
46
+ its routes published. If you enabled recovery, confirm an environment whose
47
+ required process is positively absent is corrected by one admitted `ensure`
48
+ within the configured bound.
49
+
50
+ ## Report
51
+
52
+ Report the installed CLI version, the enrolled repository, the observed `exec`
53
+ output and exit code, the final `ensure` readiness with its route count, and
54
+ whether the recovery policy was enabled.