omp-conductor 0.15.11 → 0.15.13

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.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
@@ -7,70 +7,97 @@
7
7
  */
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
+ import { buildStopProvenance } from "../stop-provenance.ts";
10
11
  import { setPaused } from "../daemon.ts";
11
- import { restartDaemon, type RestartResult } from "../lifecycle.ts";
12
+ import { restartDaemon, type RestartResult, type StopDelivery } from "../lifecycle.ts";
13
+ import { openStore, dbPath } from "../store.ts";
12
14
  import { DEFAULT_DEPS, drainAndRestart, type UpgradeDeps } from "../upgrade.ts";
13
15
 
14
16
  export async function restartCommand(ctx: CommandContext): Promise<void> {
15
- if (ctx.argv.includes("--now")) {
16
- // Inherit the running daemon's port and project: a restart that quietly
17
- // moved to the default port would leave every existing health check
18
- // pointing at nothing. When the unit owns the live pid, restartDaemon
19
- // goes through systemctl so the replacement stays supervised; a failed
20
- // installed unit is reset and started through systemd the same way and
21
- // is never replaced by an unmanaged daemon. Success is only reported
22
- // after the manager is proven to own the reported pid.
23
- const { previous, record, via } = await restartDaemon({
24
- port: ctx.portFlag(),
25
- project: ctx.projectFlag,
17
+ const store = openStore(dbPath());
18
+ try {
19
+ // Provenance (#378): one record naming the request, every configured
20
+ // project with its live-run count, and via the lifecycle chokepoint — the
21
+ // delivery method used. The shared daemon serves all projects, so a restart
22
+ // is never silent even when the operator narrows nothing.
23
+ const provenance = buildStopProvenance({
24
+ controlPath: "cli restart",
25
+ reason: "operator restart",
26
+ scope: "global",
26
27
  });
27
- if (previous !== undefined) {
28
- process.stdout.write(
29
- `stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
30
- );
31
- }
32
- process.stdout.write(
33
- `started — pid ${record.pid}, /healthz on :${record.port}` +
34
- `${record.project === undefined ? "" : `, project ${record.project}`}` +
35
- `${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
36
- );
37
- return;
38
- }
28
+ const record = (signal: StopDelivery) => store.recordDaemonStop(signal.provenance);
39
29
 
40
- // Default: drain the fleet before restarting — pause new claims, wait for
41
- // live workers to reach 0/N (bounded by --timeout), restart, then restore
42
- // the prior dispatch state. A timed-out drain restarts nothing and leaves
43
- // dispatch paused, so it is safe to run while the daemon is wedged.
44
- const timeoutRaw = ctx.flag("timeout");
45
- const timeoutSeconds =
46
- timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10);
47
- const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000;
48
- let result!: RestartResult;
49
- const deps: UpgradeDeps = {
50
- ...DEFAULT_DEPS,
51
- setPaused: (v, project) =>
52
- setPaused(v, { source: "restart", reason: "restart, draining" }, project),
53
- restartDaemon: async () => {
54
- result = await restartDaemon({
30
+ if (ctx.argv.includes("--now")) {
31
+ // Inherit the running daemon's port and project: a restart that quietly
32
+ // moved to the default port would leave every existing health check
33
+ // pointing at nothing. When the unit owns the live pid, restartDaemon
34
+ // goes through systemctl so the replacement stays supervised; a failed
35
+ // installed unit is reset and started through systemd the same way and
36
+ // is never replaced by an unmanaged daemon. Success is only reported
37
+ // after the manager is proven to own the reported pid.
38
+ const { previous, record: newRecord, via } = await restartDaemon({
55
39
  port: ctx.portFlag(),
56
40
  project: ctx.projectFlag,
41
+ provenance,
42
+ record,
57
43
  });
58
- },
59
- };
60
- try {
61
- await drainAndRestart(deps, { project: ctx.projectFlag, timeoutMs });
62
- } catch (err) {
63
- process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
64
- process.exit(1);
65
- }
66
- if (result.previous !== undefined) {
44
+ if (previous !== undefined) {
45
+ process.stdout.write(
46
+ `stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
47
+ );
48
+ }
49
+ process.stdout.write(
50
+ `started — pid ${newRecord.pid}, /healthz on :${newRecord.port}` +
51
+ `${newRecord.project === undefined ? "" : `, project ${newRecord.project}`}` +
52
+ `${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${newRecord.logFile}\n`,
53
+ );
54
+ return;
55
+ }
56
+
57
+ // Default: drain the fleet before restarting — pause new claims, wait for
58
+ // live workers to reach 0/N (bounded by --timeout), restart, then restore
59
+ // the prior dispatch state. A timed-out drain restarts nothing and leaves
60
+ // dispatch paused, so it is safe to run while the daemon is wedged.
61
+ const timeoutRaw = ctx.flag("timeout");
62
+ const timeoutSeconds =
63
+ timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10);
64
+ const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000;
65
+ let result!: RestartResult;
66
+ const deps: UpgradeDeps = {
67
+ ...DEFAULT_DEPS,
68
+ setPaused: (v, project) =>
69
+ setPaused(v, { source: "restart", reason: "restart, draining" }, project),
70
+ restartDaemon: async () => {
71
+ result = await restartDaemon({
72
+ port: ctx.portFlag(),
73
+ project: ctx.projectFlag,
74
+ provenance,
75
+ record,
76
+ });
77
+ },
78
+ };
79
+ // Record the request at ENTRY, before the drain: a caller that disconnects
80
+ // or hits --timeout while workers drain never reaches the lifecycle
81
+ // chokepoint, and the durable provenance must survive that — it is exactly
82
+ // the stop-with-no-witness incident #378 exists for (#378).
83
+ store.recordDaemonStop(provenance);
84
+ try {
85
+ await drainAndRestart(deps, { project: ctx.projectFlag, timeoutMs });
86
+ } catch (err) {
87
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
88
+ process.exit(1);
89
+ }
90
+ if (result.previous !== undefined) {
91
+ process.stdout.write(
92
+ `stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
93
+ );
94
+ }
67
95
  process.stdout.write(
68
- `stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
96
+ `started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
97
+ `${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
98
+ `${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
69
99
  );
100
+ } finally {
101
+ store.close();
70
102
  }
71
- process.stdout.write(
72
- `started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
73
- `${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
74
- `${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
75
- );
76
103
  }
@@ -9,10 +9,11 @@
9
9
  import type { CommandContext } from "./context.ts";
10
10
  import { findProject, loadConfig, resolveCaps } from "../config.ts";
11
11
  import { AMEND_AREA_IDS, type AmendAreaId } from "../setup.ts";
12
- import { runGraphInstall, runHostInstall } from "../setup-install.ts";
12
+ import { runGraphInstall, runHostInstall, type InstallOutcome } from "../setup-install.ts";
13
13
  import { DEFAULT_PROBES, NO_PROBES, setup } from "../setup-wizard.ts";
14
14
  import { telegramStateDir } from "../fleet.ts";
15
15
  import { terminalUi } from "../wizard-ui.ts";
16
+ import type { ConductorConfig, ProjectConfig } from "../types.ts";
16
17
 
17
18
  /**
18
19
  * The setup verb's own usage, printed for `setup --help` / `setup -h` before
@@ -25,18 +26,43 @@ and the staged host files behind one confirm.
25
26
 
26
27
  usage:
27
28
  omp-conductor setup [area] [--no-ai] [--project NAME]
29
+ omp-conductor setup host [NAME] [--project NAME]
30
+ omp-conductor setup graph [--no-seed] [--print] [--project NAME]
28
31
 
29
32
  Bare setup runs the full interview — or, when the project already exists,
30
33
  asks which area to amend. Naming an area positionally skips that chooser and
31
- amends only that area.
34
+ amends only that area. \`setup host\` and \`setup graph\` are install
35
+ subcommands, not areas: the NAME (or --project NAME) says which project to
36
+ install for.
32
37
 
33
38
  amend areas:
34
39
  ${AMEND_AREA_IDS.join(", ")}
35
40
 
36
41
  flags:
37
- --project NAME the project to configure or amend
42
+ --project NAME the project to configure or amend (or NAME positionally
43
+ for \`setup host\`)
38
44
  --no-ai ask every question, propose nothing (no AI repo reads)`;
39
45
 
46
+ /**
47
+ * Resolve the project an install subcommand (`setup host`, `setup graph`)
48
+ * installs for — the flag, or the positional form an operator naturally
49
+ * types on a multi-project host (`setup host conductor`). The pair may not
50
+ * disagree, and an unknown name fails loudly as "Unknown project …" rather
51
+ * than being silently ignored (#514).
52
+ */
53
+ export function setupInstallProject(
54
+ cfg: ConductorConfig,
55
+ projectFlag: string | undefined,
56
+ positionalName: string | undefined,
57
+ ): ProjectConfig {
58
+ if (projectFlag !== undefined && positionalName !== undefined && projectFlag !== positionalName) {
59
+ throw new Error(
60
+ `setup names two different projects (--project ${projectFlag} and ${positionalName}) — pass one`,
61
+ );
62
+ }
63
+ return findProject(cfg, projectFlag ?? positionalName);
64
+ }
65
+
40
66
  export async function setupCommand(ctx: CommandContext): Promise<void> {
41
67
  // Help first, and only in the first trailing position: a help request
42
68
  // must never open a UI, read config, probe GitHub or pause dispatch.
@@ -58,14 +84,38 @@ try {
58
84
  // is how `setup host` came to exit 2 as an "unknown setup area".
59
85
  if (positional === "host" || positional === "graph") {
60
86
  const cfg = loadConfig();
61
- const project = findProject(cfg, ctx.projectFlag);
62
- const outcome =
63
- positional === "host"
64
- ? await runHostInstall(project, resolveCaps(project, cfg.defaults), telegramStateDir(), ui)
65
- : await runGraphInstall(project, ui, {
66
- noSeed: ctx.argv.includes("--no-seed"),
67
- print: ctx.argv.includes("--print"),
68
- });
87
+ // `setup host <name>`: the project an operator would naturally type.
88
+ // The positional only ever names a project; anything else that does not
89
+ // look like a flag is refused by findProject below rather than being
90
+ // quietly ignored.
91
+ const positionalName =
92
+ positional === "host" && ctx.argv[2] !== undefined && !ctx.argv[2].startsWith("--")
93
+ ? ctx.argv[2]
94
+ : undefined;
95
+ let outcome: InstallOutcome;
96
+ if (positional === "host") {
97
+ // No `--project` and no positional → a host-global install (#530): the
98
+ // units are host-global, so a multi-project host installs them with no
99
+ // name and skips the per-project tail (tick config, brief link) with a
100
+ // named note; a name resolves and wires the tail.
101
+ const project =
102
+ ctx.projectFlag === undefined && positionalName === undefined
103
+ ? undefined
104
+ : setupInstallProject(cfg, ctx.projectFlag, positionalName);
105
+ outcome = await runHostInstall(
106
+ project,
107
+ project === undefined ? cfg.defaults : resolveCaps(project, cfg.defaults),
108
+ telegramStateDir(),
109
+ ui,
110
+ );
111
+ } else {
112
+ // `setup graph` is inherently per-project repositories; there is no
113
+ // host-global graph to install, so a project must resolve.
114
+ outcome = await runGraphInstall(findProject(cfg, ctx.projectFlag), ui, {
115
+ noSeed: ctx.argv.includes("--no-seed"),
116
+ print: ctx.argv.includes("--print"),
117
+ });
118
+ }
69
119
  // `staged` is a success on a host that has no systemd: the files are
70
120
  // real, only the enable step is impossible.
71
121
  if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1);
@@ -7,8 +7,10 @@
7
7
  */
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
+ import { buildStopProvenance } from "../stop-provenance.ts";
10
11
  import { hold, pinPaneHalt, stopConductorPane } from "../fleet.ts";
11
12
  import { stopDaemon } from "../lifecycle.ts";
13
+ import { openStore, dbPath } from "../store.ts";
12
14
 
13
15
  export async function stopCommand(ctx: CommandContext): Promise<void> {
14
16
  const withPane = ctx.argv.includes("--pane");
@@ -24,28 +26,49 @@ const targets = ctx.targetProjects().map((project) => {
24
26
  pin: withPane ? pinPaneHalt(project.name).path : undefined,
25
27
  };
26
28
  });
27
- const stop = await stopDaemon();
28
- const stopLine =
29
- stop.kind === "not-running"
30
- ? "daemon was not running"
31
- : `daemon stopped pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
32
- for (const target of targets) {
33
- if (target.pin !== undefined) {
34
- const pane = await stopConductorPane(target.project.name);
35
- process.stdout.write(
36
- `stopped claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
37
- `${stopLine}\n` +
38
- `pane recovery pinned at ${target.pin}\n` +
39
- `pane stop: ${pane.stopped} — ${pane.detail}\n` +
40
- ` (conductor agent "${pane.agentName}" only herdr-fleet.service was NOT stopped;\n` +
41
- ` resume clears the pin when you want recovery again)\n`,
42
- );
43
- } else {
44
- process.stdout.write(
45
- `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
46
- `${stopLine}\n` +
47
- `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
48
- );
29
+ const store = openStore(dbPath());
30
+ try {
31
+ // Provenance (#378): the shared daemon serves every configured project, so
32
+ // the record names the request's own project (or the global scope) AND every
33
+ // sibling with its live-run count no silent cross-project stop.
34
+ const provenance = buildStopProvenance({
35
+ controlPath: "cli stop",
36
+ reason: "operator stop",
37
+ scope: targets.length > 1 ? "global" : "project",
38
+ project: targets.length === 1 ? targets[0]!.project.name : undefined,
39
+ });
40
+ const stop = await stopDaemon({
41
+ provenance,
42
+ // The durable row is written by the lifecycle at the exact chokepoint —
43
+ // immediately before systemctl/SIGTERM, with the delivery method that is
44
+ // about to be used — which is what makes "provenance precedes signalling"
45
+ // provable rather than claimed.
46
+ record: (signal) => store.recordDaemonStop(signal.provenance),
47
+ });
48
+ const stopLine =
49
+ stop.kind === "not-running"
50
+ ? "daemon was not running"
51
+ : `daemon stopped — pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
52
+ for (const target of targets) {
53
+ if (target.pin !== undefined) {
54
+ const pane = await stopConductorPane(target.project.name);
55
+ process.stdout.write(
56
+ `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
57
+ `${stopLine}\n` +
58
+ `pane recovery pinned at ${target.pin}\n` +
59
+ `pane stop: ${pane.stopped} — ${pane.detail}\n` +
60
+ ` (conductor agent "${pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
61
+ ` resume clears the pin when you want recovery again)\n`,
62
+ );
63
+ } else {
64
+ process.stdout.write(
65
+ `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
66
+ `${stopLine}\n` +
67
+ `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
68
+ );
69
+ }
49
70
  }
71
+ } finally {
72
+ store.close();
50
73
  }
51
74
  }
@@ -13,6 +13,15 @@ import type { CommandContext } from "./context.ts";
13
13
  import { rollbackFromJournal } from "../upgrade.ts";
14
14
 
15
15
  export async function upgradeRollbackCommand(ctx: CommandContext): Promise<void> {
16
+ // Nothing here varies by project (#514): the rollback restores every
17
+ // surface the failed install touched, from the host-wide journal. The
18
+ // flag only ever misled, so refuse it with a reason.
19
+ if (ctx.projectFlag !== undefined) {
20
+ process.stderr.write(
21
+ `omp-conductor: upgrade-rollback does not take a project — it restores the whole host from the upgrade journal\n`,
22
+ );
23
+ process.exit(2);
24
+ }
16
25
  const result = await rollbackFromJournal();
17
26
  process.stdout.write(
18
27
  `rolled back to omp-conductor@${result.restoredVersion} after the failed ` +
@@ -316,12 +316,21 @@ const projectSchema = z
316
316
  routing: routingSchema.optional(),
317
317
  caps: capsSchema.optional(),
318
318
  workerModel: z.unknown().optional(),
319
+ // The loader normalises both of these (trimmed entries / positive integer),
320
+ // so the schema admits any shape and lets the normaliser decide what is
321
+ // usable, exactly like `workerModel`.
322
+ modelFallbacks: z.unknown().optional(),
323
+ modelFallbackThreshold: z.unknown().optional(),
319
324
  escalation: escalationSchema.optional(),
320
325
  authority: authoritySchema.optional(),
321
326
  releasePolicy: releasePolicySchema.optional(),
322
327
  policy: projectPolicySchema.optional(),
323
328
  recoveryMerges: recoveryMergesSchema.optional(),
324
329
  reporting: reportingSchema.optional(),
330
+ // Hand-edited safety markers (commit SHAs or refs) a preserved continuation
331
+ // branch must contain before it may be reattached; the loader normalises
332
+ // them like `modelFallbacks`.
333
+ criticalBase: z.unknown().optional(),
325
334
  // Roots silently fall back to the default when unusable, so the schema
326
335
  // admits any shape and the normaliser picks the usable path.
327
336
  workspaceRoot: z.unknown().optional(),
package/src/config.ts CHANGED
@@ -355,8 +355,11 @@ export function findProject(c: ConductorConfig, name?: string): ProjectConfig {
355
355
  if (name === undefined) {
356
356
  const only = c.projects[0];
357
357
  if (c.projects.length !== 1 || only === undefined) {
358
+ // #514: every operator hitting this has to guess the spelling of the
359
+ // flag that names a project — say it outright. Doctor's project finding
360
+ // quotes this text, so keep the "name one explicitly" phrase.
358
361
  throw new Error(
359
- `Ambiguous project: config has ${c.projects.length} projects (${names.join(", ") || "none"}) — name one explicitly.`,
362
+ `Ambiguous project: config has ${c.projects.length} projects (${names.join(", ") || "none"}) — name one explicitly with --project NAME.`,
360
363
  );
361
364
  }
362
365
  return only;
@@ -1080,6 +1083,34 @@ function finalizeProject(
1080
1083
  const workerModel =
1081
1084
  typeof rawWorkerModel === "string" && rawWorkerModel.trim() !== "" ? rawWorkerModel : undefined;
1082
1085
 
1086
+ // The failover chain is a hint like workerModel, not a ceiling: an unusable
1087
+ // entry or a malformed threshold is dropped rather than failing the load,
1088
+ // and absent/empty `modelFallbacks` keeps today's dispatch byte for byte.
1089
+ const rawFallbacks = p["modelFallbacks"];
1090
+ const modelFallbacks = Array.isArray(rawFallbacks)
1091
+ ? rawFallbacks
1092
+ .filter((entry): entry is string => typeof entry === "string")
1093
+ .map((entry) => entry.trim())
1094
+ .filter((entry) => entry !== "")
1095
+ : [];
1096
+ const rawThreshold = p["modelFallbackThreshold"];
1097
+ const modelFallbackThreshold =
1098
+ typeof rawThreshold === "number" && Number.isInteger(rawThreshold) && rawThreshold >= 1
1099
+ ? rawThreshold
1100
+ : undefined;
1101
+
1102
+ // Marked critical-base/safety markers (commit SHAs or refs). A continuation
1103
+ // branch must contain every one before the dispatcher may reattach it;
1104
+ // unusable entries are dropped like `modelFallbacks` and an absent or empty
1105
+ // list keeps today's behaviour byte for byte.
1106
+ const rawCriticalBase = p["criticalBase"];
1107
+ const criticalBase = Array.isArray(rawCriticalBase)
1108
+ ? rawCriticalBase
1109
+ .filter((entry): entry is string => typeof entry === "string")
1110
+ .map((entry) => entry.trim())
1111
+ .filter((entry) => entry !== "")
1112
+ : [];
1113
+
1083
1114
  if (problems.length > before) return undefined;
1084
1115
 
1085
1116
  return {
@@ -1095,12 +1126,15 @@ function finalizeProject(
1095
1126
  routing: { labelPrefix, repos },
1096
1127
  caps,
1097
1128
  ...(workerModel === undefined ? {} : { workerModel }),
1129
+ ...(modelFallbacks.length === 0 ? {} : { modelFallbacks }),
1130
+ ...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
1098
1131
  escalation,
1099
1132
  authority,
1100
1133
  releasePolicy,
1101
1134
  policy,
1102
1135
  ...(recoveryMerges === undefined ? {} : { recoveryMerges }),
1103
1136
  reporting,
1137
+ ...(criticalBase.length === 0 ? {} : { criticalBase }),
1104
1138
  workspaceRoot: expandHome(pickString(p["workspaceRoot"], defaultWorkspaceRoot())),
1105
1139
  mirrorRoot: expandHome(pickString(p["mirrorRoot"], defaultMirrorRoot())),
1106
1140
  };