@devrouter/cli 0.0.72 → 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.72" : "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(
@@ -8901,14 +8901,16 @@ function handleRecover(state, event) {
8901
8901
  if (state.executionPolicy === "manual" || state.desired !== "running")
8902
8902
  return unchanged(state, "blocked");
8903
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;
8904
8906
  if (state.incident !== null && state.incident.id !== event.incidentId) {
8905
8907
  return unchanged(state, "conflict");
8906
8908
  }
8907
8909
  if (state.incident !== null) {
8908
- if (state.operation && pendingCommand(state.operation)) {
8909
- 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");
8910
8912
  }
8911
- if (state.operation?.id === event.operationId && state.operation.status === "COMPLETED") {
8913
+ if (operation?.id === event.operationId && operation.status === "COMPLETED") {
8912
8914
  return unchanged(state, "joined");
8913
8915
  }
8914
8916
  if (state.incident.correctiveActionsTaken >= state.incident.actionLimit) {
@@ -8916,17 +8918,20 @@ function handleRecover(state, event) {
8916
8918
  }
8917
8919
  }
8918
8920
  if (state.incident === null) {
8919
- if (state.operation?.status === "NOT_STARTED") return unchanged(state, "blocked");
8920
- 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");
8921
8923
  state.incident = {
8922
8924
  id: event.incidentId,
8923
8925
  correctiveActionsTaken: 0,
8924
8926
  actionLimit: event.actionLimit
8925
8927
  };
8926
8928
  }
8927
- if (!state.operation || state.operation.status === "COMPLETED" || state.operation.status === "INTERRUPTED") {
8929
+ if (!operation || operation.status === "COMPLETED" || operation.status === "INTERRUPTED") {
8928
8930
  const runtimeGeneration = advance(state.runtimeGeneration);
8929
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");
8930
8935
  state.runtimeGeneration = runtimeGeneration;
8931
8936
  state.observations = [];
8932
8937
  state.operation = {
@@ -8936,6 +8941,12 @@ function handleRecover(state, event) {
8936
8941
  status: "NOT_STARTED",
8937
8942
  exitCode: null
8938
8943
  };
8944
+ state.operationHistory.push({
8945
+ ...state.operation,
8946
+ key: event.operationId,
8947
+ profile: state.profile,
8948
+ consumer: cloneConsumer(consumer)
8949
+ });
8939
8950
  }
8940
8951
  state.phase = "recovering";
8941
8952
  return transition(state, "accepted");
@@ -9035,7 +9046,7 @@ var import_node_fs25 = __toESM(require("fs"));
9035
9046
  var import_node_path30 = __toESM(require("path"));
9036
9047
  var import_node_util5 = require("util");
9037
9048
  function installedCliVersion() {
9038
- return true ? "0.0.72" : "0.0.0-dev";
9049
+ return true ? "0.0.73" : "0.0.0-dev";
9039
9050
  }
9040
9051
  function compareCliVersions(left, right) {
9041
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.72" : "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.72" : "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;
@@ -12138,14 +12232,16 @@ function handleRecover(state, event) {
12138
12232
  if (state.executionPolicy === "manual" || state.desired !== "running")
12139
12233
  return unchanged(state, "blocked");
12140
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;
12141
12237
  if (state.incident !== null && state.incident.id !== event.incidentId) {
12142
12238
  return unchanged(state, "conflict");
12143
12239
  }
12144
12240
  if (state.incident !== null) {
12145
- if (state.operation && pendingCommand(state.operation)) {
12146
- 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");
12147
12243
  }
12148
- if (state.operation?.id === event.operationId && state.operation.status === "COMPLETED") {
12244
+ if (operation?.id === event.operationId && operation.status === "COMPLETED") {
12149
12245
  return unchanged(state, "joined");
12150
12246
  }
12151
12247
  if (state.incident.correctiveActionsTaken >= state.incident.actionLimit) {
@@ -12153,17 +12249,20 @@ function handleRecover(state, event) {
12153
12249
  }
12154
12250
  }
12155
12251
  if (state.incident === null) {
12156
- if (state.operation?.status === "NOT_STARTED") return unchanged(state, "blocked");
12157
- 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");
12158
12254
  state.incident = {
12159
12255
  id: event.incidentId,
12160
12256
  correctiveActionsTaken: 0,
12161
12257
  actionLimit: event.actionLimit
12162
12258
  };
12163
12259
  }
12164
- if (!state.operation || state.operation.status === "COMPLETED" || state.operation.status === "INTERRUPTED") {
12260
+ if (!operation || operation.status === "COMPLETED" || operation.status === "INTERRUPTED") {
12165
12261
  const runtimeGeneration = advance(state.runtimeGeneration);
12166
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");
12167
12266
  state.runtimeGeneration = runtimeGeneration;
12168
12267
  state.observations = [];
12169
12268
  state.operation = {
@@ -12173,6 +12272,12 @@ function handleRecover(state, event) {
12173
12272
  status: "NOT_STARTED",
12174
12273
  exitCode: null
12175
12274
  };
12275
+ state.operationHistory.push({
12276
+ ...state.operation,
12277
+ key: event.operationId,
12278
+ profile: state.profile,
12279
+ consumer: cloneConsumer(consumer)
12280
+ });
12176
12281
  }
12177
12282
  state.phase = "recovering";
12178
12283
  return transition(state, "accepted");
@@ -12265,6 +12370,49 @@ function stepReliability(state, event, nowMs) {
12265
12370
  }
12266
12371
  return result;
12267
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
+ }
12268
12416
  var INFRASTRUCTURE_VALUES, APPLICATION_VALUES, ADMISSION_VALUES;
12269
12417
  var init_reliability_model = __esm({
12270
12418
  "src/core/reliability-model.ts"() {
@@ -14841,6 +14989,52 @@ function assertCurrentCapacityController(expected, directory = import_node_path4
14841
14989
  if (!expected || current?.store !== expected.store || current.epoch !== expected.epoch)
14842
14990
  throw new Error("Capacity controller incarnation changed.");
14843
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
+ }
14844
15038
  function settlePreparedLifecycleCapacity(input2) {
14845
15039
  const directory = input2.directory ?? import_node_path44.default.join(DEVROUTER_HOME, "controller");
14846
15040
  const store = new CapacityStore(directory);
@@ -15175,6 +15369,17 @@ async function superviseThroughController(input2) {
15175
15369
  );
15176
15370
  };
15177
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
+ };
15178
15383
  try {
15179
15384
  const bind = () => observeControllerBinding(directory, {
15180
15385
  path: input2.repoPath,
@@ -15189,7 +15394,7 @@ async function superviseThroughController(input2) {
15189
15394
  let pending = await submitControllerOperation(
15190
15395
  directory,
15191
15396
  input2.kind === "exec" ? { ...binding, requestId: input2.requestId, kind: "exec", command: input2.command ?? [] } : { ...binding, requestId: input2.requestId, kind: "ensure" },
15192
- { waitSeconds: controllerWaitSeconds(deadline) }
15397
+ { waitSeconds: controllerWaitSeconds(deadline), onOutput }
15193
15398
  );
15194
15399
  while (pending.status !== "terminal") {
15195
15400
  cancelled();
@@ -15205,7 +15410,7 @@ async function superviseThroughController(input2) {
15205
15410
  pending = await followControllerOperation(
15206
15411
  directory,
15207
15412
  { ...binding, operationId: pending.operationId },
15208
- { waitSeconds: controllerWaitSeconds(deadline) }
15413
+ { waitSeconds: controllerWaitSeconds(deadline), onOutput }
15209
15414
  );
15210
15415
  } catch {
15211
15416
  await new Promise((resolve) => setTimeout(resolve, Math.min(1e3, deadline - Date.now())));
@@ -15231,23 +15436,28 @@ function renewLifecycleCapacity(request2, policy, samples, controller, directory
15231
15436
  const binding = record5.capacity;
15232
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)
15233
15438
  throw new Error("Capacity renewal no longer owns this operation.");
15234
- binding.validUntilMs = 0;
15235
15439
  try {
15236
- 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;
15237
15442
  return false;
15443
+ }
15238
15444
  const enrolled = record5.enrollment;
15239
15445
  const runtime = enrolled && policy.domains[enrolled.runtimeDomain];
15240
15446
  const enrollment = policy.enrollments.find(
15241
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 ?? "")
15242
15448
  );
15243
- 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;
15244
15451
  return false;
15452
+ }
15245
15453
  const snapshot = new CapacityStore(directory).read();
15246
15454
  const reservation = snapshot.reservations.find(
15247
15455
  (entry) => entry.reservationId === binding.reservationId
15248
15456
  );
15249
- 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;
15250
15459
  return false;
15460
+ }
15251
15461
  const maxAge = policy.scheduling.maxSampleAgeSeconds * 1e3;
15252
15462
  const decision = evaluateCapacity(
15253
15463
  policy.domains,
@@ -15274,16 +15484,21 @@ function renewLifecycleCapacity(request2, policy, samples, controller, directory
15274
15484
  snapshot.pools
15275
15485
  ).admitted;
15276
15486
  })();
15277
- if (!tolerated) return false;
15487
+ if (!tolerated) {
15488
+ if (decision.reason !== "unknown" && decision.reason !== "stale")
15489
+ binding.validUntilMs = 0;
15490
+ return false;
15491
+ }
15278
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;
15279
15497
  binding.snapshotRevision = snapshot.revision;
15280
- binding.validUntilMs = Math.min(
15281
- ...Object.keys(reservation.totals).map((domain) => samples[domain]?.sampledAtMs ?? 0)
15282
- ) + maxAge;
15498
+ binding.validUntilMs = Math.min(...observed) + maxAge;
15283
15499
  assertCapacityEffect(record5, request2.workerId, nowMs, directory);
15284
15500
  return true;
15285
15501
  } catch {
15286
- binding.validUntilMs = 0;
15287
15502
  return false;
15288
15503
  }
15289
15504
  });
@@ -15427,6 +15642,11 @@ var init_capacity_queue = __esm({
15427
15642
  });
15428
15643
  return request2.operationId;
15429
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
+ }
15430
15650
  observe(operationId) {
15431
15651
  const entry = this.entries.get(operationId);
15432
15652
  return entry ? { operationId, phase: entry.phase, reason: entry.reason, output: entry.output.read() } : void 0;
@@ -15964,6 +16184,87 @@ function createCapacityController(options) {
15964
16184
  settlePreparations();
15965
16185
  return queue.tick();
15966
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
+ },
15967
16268
  close() {
15968
16269
  lifetime.abort();
15969
16270
  queue.close();
@@ -16094,12 +16395,13 @@ var init_controller_monitor = __esm({
16094
16395
  ControllerObservationBindingChanged = class extends Error {
16095
16396
  };
16096
16397
  ControllerMonitor = class {
16097
- constructor(sessions, collect, serialize, clock = () => Math.floor(performance.now()), fence = withReliabilityObservationFence) {
16398
+ constructor(sessions, collect, serialize, clock = () => Math.floor(performance.now()), fence = withReliabilityObservationFence, recover) {
16098
16399
  this.sessions = sessions;
16099
16400
  this.collect = collect;
16100
16401
  this.serialize = serialize;
16101
16402
  this.clock = clock;
16102
16403
  this.fence = fence;
16404
+ this.recover = recover;
16103
16405
  }
16104
16406
  active = /* @__PURE__ */ new Map();
16105
16407
  lastStarted = /* @__PURE__ */ new Map();
@@ -16194,6 +16496,18 @@ var init_controller_monitor = __esm({
16194
16496
  this.sessions.publish(bindings, projections, commitTime, Date.now());
16195
16497
  });
16196
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
+ });
16197
16511
  }).catch(async (error) => {
16198
16512
  if (!this.stopped)
16199
16513
  await this.serialize(() => {
@@ -16217,7 +16531,7 @@ var init_controller_monitor = __esm({
16217
16531
  async function observeControllerProcess(containerId, name, signal) {
16218
16532
  if (!/^[a-f0-9]{12,64}$/.test(containerId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(name))
16219
16533
  throw new Error("Process observation identity is invalid.");
16220
- const output2 = await runControllerProbe(
16534
+ const result = await runControllerProbeStatus(
16221
16535
  "docker",
16222
16536
  [
16223
16537
  "exec",
@@ -16233,12 +16547,14 @@ async function observeControllerProcess(containerId, name, signal) {
16233
16547
  ],
16234
16548
  signal
16235
16549
  );
16236
- 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();
16237
16553
  if (!/^[1-9][0-9]* [0-9]+ [a-zA-Z0-9][a-zA-Z0-9._:-]{0,4095}$/.test(identity2))
16238
16554
  throw new Error("Process observation is unavailable.");
16239
- return identity2;
16555
+ return { kind: "present", identity: identity2 };
16240
16556
  }
16241
- var PROCESS_OBSERVATION;
16557
+ var PROCESS_OBSERVATION, PROCESS_ABSENT_EXIT;
16242
16558
  var init_controller_process_observation = __esm({
16243
16559
  "src/core/controller-process-observation.ts"() {
16244
16560
  "use strict";
@@ -16248,7 +16564,9 @@ set -euo pipefail
16248
16564
  name="$1"
16249
16565
  [[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || exit 1
16250
16566
  file="${"$"}{DEVROUTER_PROCESS_STATE_DIR:-/tmp}/devrouter-process-$name.state"
16251
- [ -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
16252
16570
  [ "$(stat -c %s "$file")" -le 4096 ] || exit 1
16253
16571
  before="$(head -c 4097 "$file")"
16254
16572
  read -r pid pgid fingerprint extra <<<"$before"
@@ -16268,11 +16586,13 @@ identity() {
16268
16586
  tr '\0' '\n' <"/proc/$pid/environ" | grep -Fx "DEVROUTER_PROCESS_FINGERPRINT=$fingerprint" >/dev/null || return 1
16269
16587
  printf '%s' "${"$"}{fields[19]}"
16270
16588
  }
16271
- birth="$(identity)"
16589
+ birth="$(identity)" || exit 3
16272
16590
  [ "$birth" = "$(identity)" ] || exit 1
16273
- [ ! -L "$file" ] && [ "$before" = "$(head -c 4097 "$file")" ] || exit 1
16591
+ [ ! -L "$file" ] || exit 1
16592
+ [ "$before" = "$(head -c 4097 "$file")" ] || exit 3
16274
16593
  printf '%s %s %s\n' "$pid" "$birth" "$fingerprint"
16275
16594
  `;
16595
+ PROCESS_ABSENT_EXIT = 3;
16276
16596
  }
16277
16597
  });
16278
16598
 
@@ -16488,10 +16808,14 @@ var init_controller_observation = __esm({
16488
16808
  ];
16489
16809
  const processes = [];
16490
16810
  let processesVerified = healthy;
16811
+ let processesAbsent = false;
16491
16812
  if (healthy && requirements.some((value) => value.startsWith("app:"))) {
16492
16813
  try {
16493
- for (const name of profile.managedRuntime.processes)
16494
- 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
+ }
16495
16819
  } catch (error) {
16496
16820
  if (signal.aborted) throw error;
16497
16821
  processesVerified = false;
@@ -16505,7 +16829,7 @@ var init_controller_observation = __esm({
16505
16829
  const routeMatches = routes.some(
16506
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
16507
16831
  );
16508
- const http2 = healthy && routeMatches && processesVerified ? await probeHttpReadiness(
16832
+ const http2 = healthy && routeMatches && processesVerified && !processesAbsent ? await probeHttpReadiness(
16509
16833
  app.host,
16510
16834
  app.readiness,
16511
16835
  signal,
@@ -16514,7 +16838,7 @@ var init_controller_observation = __esm({
16514
16838
  ) : void 0;
16515
16839
  capabilities.push({
16516
16840
  capability: controllerCapability(requirement),
16517
- infrastructure: healthy && routeMatches ? "healthy" : "unknown",
16841
+ infrastructure: healthy && routeMatches ? processesAbsent ? "failed" : "healthy" : "unknown",
16518
16842
  application: http2?.ok ? "verified" : http2?.classification === "application-contract" ? "unready" : "unverified",
16519
16843
  observedAtMs: 0,
16520
16844
  validForMs: 15e3
@@ -16522,13 +16846,15 @@ var init_controller_observation = __esm({
16522
16846
  }
16523
16847
  if (processesVerified && requirements.some((value) => value.startsWith("app:"))) {
16524
16848
  try {
16525
- for (let index = 0; index < profile.managedRuntime.processes.length; index++)
16526
- if (processes[index] !== await observeControllerProcess(
16849
+ for (let index = 0; index < profile.managedRuntime.processes.length; index++) {
16850
+ const recheck = await observeControllerProcess(
16527
16851
  primary[0].id,
16528
16852
  profile.managedRuntime.processes[index],
16529
16853
  signal
16530
- ))
16854
+ );
16855
+ if (recheck.kind !== "present" || processes[index] !== recheck.identity)
16531
16856
  throw new Error("Observation process changed.");
16857
+ }
16532
16858
  } catch (error) {
16533
16859
  if (signal.aborted) throw error;
16534
16860
  for (const capability of capabilities)
@@ -16839,12 +17165,19 @@ async function runController(options) {
16839
17165
  let serial = Promise.resolve();
16840
17166
  const monotonic = () => Math.floor(performance.now());
16841
17167
  let fatal;
16842
- const monitor = options.collect ? new ControllerMonitor(sessions, options.collect, (operation) => {
16843
- const pending = serial.then(operation);
16844
- serial = pending.catch(() => {
16845
- });
16846
- return pending;
16847
- }) : 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;
16848
17181
  const server = import_node_net4.default.createServer((socket) => {
16849
17182
  if (sockets.size >= 32) {
16850
17183
  socket.destroy();
@@ -17918,8 +18251,8 @@ function buildOnboardingPrompt(options = {}) {
17918
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.",
17919
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.",
17920
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.",
17921
- "- 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.",
17922
- "- 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.",
17923
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.",
17924
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.",
17925
18258
  "- A managed adapter plus `postCreateCommand` requires `waitFor` exactly `postCreateCommand` or `postStartCommand`; managed selective config preserves lifecycle fields and changes only `runServices`.",
@@ -20398,7 +20731,7 @@ async function buildDoctorReport(options = {}) {
20398
20731
  const config = runtimeConfig.config;
20399
20732
  loadedConfig = config;
20400
20733
  loadedWorkspace = runtimeConfig.workspace;
20401
- const cliVersion = true ? "0.0.72" : "0.0.0-dev";
20734
+ const cliVersion = true ? "0.0.73" : "0.0.0-dev";
20402
20735
  const configVersion = config.devrouter?.version;
20403
20736
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
20404
20737
  addCheck(checks, {
@@ -25828,7 +26161,7 @@ var init_version = __esm({
25828
26161
 
25829
26162
  // src/cli.ts
25830
26163
  var import_commander = require("commander");
25831
- var CLI_VERSION = true ? "0.0.72" : "0.0.0-dev";
26164
+ var CLI_VERSION = true ? "0.0.73" : "0.0.0-dev";
25832
26165
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
25833
26166
  function withErrorHandling(action2) {
25834
26167
  return async (...args) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrouter/cli",
3
- "version": "0.0.72",
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,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.