omp-conductor 0.17.0 → 0.18.0

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 +12 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +40 -1
  4. package/src/admission.ts +263 -44
  5. package/src/ask.ts +39 -3
  6. package/src/availability.ts +27 -1
  7. package/src/backups.ts +2 -2
  8. package/src/briefs/orchestrator.md +1 -0
  9. package/src/briefs/worker.md +38 -19
  10. package/src/command-help.ts +8 -1
  11. package/src/command-manifest.ts +5 -2
  12. package/src/commands/arm.ts +6 -3
  13. package/src/commands/message.ts +32 -4
  14. package/src/commands/watch.ts +62 -3
  15. package/src/config-schema.ts +53 -0
  16. package/src/config.ts +97 -1
  17. package/src/daemon.ts +1479 -1483
  18. package/src/decisions.ts +51 -6
  19. package/src/depends-on.ts +261 -1
  20. package/src/diff-flags.ts +350 -0
  21. package/src/digest-schedule.ts +37 -0
  22. package/src/doctor.ts +310 -22
  23. package/src/escalate.ts +560 -57
  24. package/src/failure-class.ts +71 -15
  25. package/src/fleet.ts +189 -34
  26. package/src/gitops.ts +103 -24
  27. package/src/graph-health.ts +20 -7
  28. package/src/graph.ts +313 -68
  29. package/src/lifecycle.ts +43 -7
  30. package/src/omp.ts +42 -0
  31. package/src/orchestrator-tick.ts +430 -162
  32. package/src/release-policy.ts +177 -5
  33. package/src/routing.ts +11 -3
  34. package/src/session-host.ts +16 -0
  35. package/src/settlement.ts +1728 -0
  36. package/src/setup-host.ts +193 -4
  37. package/src/setup-install.ts +91 -30
  38. package/src/setup-wizard.ts +1257 -78
  39. package/src/setup.ts +153 -6
  40. package/src/status-render.ts +36 -4
  41. package/src/store.ts +411 -17
  42. package/src/tracker/github.ts +607 -12
  43. package/src/types.ts +331 -5
  44. package/src/upgrade.ts +50 -19
  45. package/src/verbs/actions.ts +66 -18
  46. package/src/verbs/protocol.ts +45 -0
  47. package/src/verbs/server.ts +270 -13
  48. package/src/worker.ts +239 -6
  49. package/src/worktree.ts +115 -8
  50. package/systemd/omp-conductor-recover.sh +73 -0
  51. package/systemd/recover-unit-test.sh +61 -0
@@ -280,7 +280,7 @@ function wholePackageBunTest(segment: string): boolean {
280
280
  * the host were all plain invocations, and refusing the sanctioned parse check
281
281
  * would teach workers to skip it rather than stop the load.
282
282
  */
283
- const SHARED_HOST_SCRIPTS = [
283
+ export const SHARED_HOST_SCRIPTS: readonly string[] = [
284
284
  "herdr/test/recover-test.sh",
285
285
  "test/setup-test.sh",
286
286
  "setup.sh",
@@ -298,6 +298,147 @@ function sharedHostScriptMatch(segment: string): string | undefined {
298
298
  return undefined;
299
299
  }
300
300
 
301
+ /** The `git [-C <dir>] tag` prefix; the tail after it decides read vs write. */
302
+ const GIT_TAG_HEAD = /^git(?:\s+-[Cc]\s+\S+)*\s+tag(?=\s|$)/;
303
+
304
+ /**
305
+ * Whether a segment that opens with `git [-C <dir>] tag` is a write — tag
306
+ * creation or deletion — rather than a read. The tripwire used to match the
307
+ * `git tag` prefix alone (#696), so a worker orienting itself with
308
+ * `git tag | head -5` was refused as an attempted release and the refusal
309
+ * reached the digest as release-policy drift. Git writes tags in exactly two
310
+ * shapes, and every other form is a read:
311
+ *
312
+ * - creation needs a tagname operand: `git tag <name>` and the
313
+ * `-a`/`-s`/`-u`/`-t`/`-m`/`-F`/`-f` forms;
314
+ * - deletion needs `-d`/`--delete`;
315
+ * - bare `git tag`, `-l`/`--list` (with an optional pattern), `-n`,
316
+ * `--contains`, `--points-at`, `--sort`, `--merged`/`--no-merged`,
317
+ * `--format`, `--column` and `-v`/`--verify` all only read.
318
+ *
319
+ * A creation flag with no name is still gated: git rejects the command, but a
320
+ * create attempt is not a read, and the tripwire errs toward refusing a
321
+ * release act.
322
+ */
323
+ function gitTagIsWrite(segment: string): boolean {
324
+ const head = GIT_TAG_HEAD.exec(segment);
325
+ if (head === null) return false;
326
+ const tokens = segment.slice(head[0].length).trim().split(/\s+/).filter((token) => token.length > 0);
327
+
328
+ let deletion = false; // -d / --delete
329
+ let listRead = false; // a read-only list flag
330
+ let verifyRead = false; // -v / --verify
331
+ let createSignal = false; // -a / -s / -f / -m / -u / -t / -F
332
+ let hasOperand = false; // a positional tagname, pattern or commit
333
+
334
+ for (let i = 0; i < tokens.length; i++) {
335
+ const token = tokens[i]!;
336
+ // A `--` ends option parsing: whatever follows is a plain operand, the
337
+ // created or deleted tag's name.
338
+ if (token === "--") {
339
+ hasOperand = true;
340
+ break;
341
+ }
342
+ if (token.startsWith("--")) {
343
+ const flag = token.includes("=") ? token.slice(0, token.indexOf("=")) : token;
344
+ switch (flag) {
345
+ case "--delete":
346
+ deletion = true;
347
+ break;
348
+ case "--list":
349
+ case "--contains":
350
+ case "--no-contains":
351
+ case "--points-at":
352
+ case "--merged":
353
+ case "--no-merged":
354
+ case "--sort":
355
+ case "--format":
356
+ case "--column":
357
+ case "--no-column":
358
+ case "--color":
359
+ case "--no-color":
360
+ case "--ignore-case":
361
+ case "--no-ignore-case":
362
+ listRead = true;
363
+ break;
364
+ case "--verify":
365
+ verifyRead = true;
366
+ break;
367
+ case "--annotate":
368
+ case "--sign":
369
+ case "--force":
370
+ createSignal = true;
371
+ break;
372
+ case "--local-user":
373
+ case "--object":
374
+ case "--message":
375
+ case "--file":
376
+ // The flag's operand is part of the create parameters, not a
377
+ // tagname; `--flag value` must not be read as `--flag` plus a name.
378
+ createSignal = true;
379
+ if (!token.includes("=")) i++;
380
+ break;
381
+ default:
382
+ break; // an unknown flag: no conclusion either way
383
+ }
384
+ continue;
385
+ }
386
+ if (token.startsWith("-") && token.length > 1) {
387
+ // Combined short options (`-an5` is `-a -n 5`). An option with a
388
+ // required operand takes the rest of its token or the next token.
389
+ let consumeNext = false;
390
+ for (let j = 1; j < token.length; j++) {
391
+ const flag = token[j];
392
+ switch (flag) {
393
+ case "d":
394
+ deletion = true;
395
+ break;
396
+ case "a":
397
+ case "s":
398
+ case "f":
399
+ createSignal = true;
400
+ break;
401
+ case "l":
402
+ listRead = true;
403
+ break;
404
+ case "v":
405
+ verifyRead = true;
406
+ break;
407
+ case "n":
408
+ // `-n[<num>]` — the digit run belongs to the flag, so `-n5`
409
+ // is one token and never an operand.
410
+ listRead = true;
411
+ while (j + 1 < token.length && token[j + 1]! >= "0" && token[j + 1]! <= "9") j++;
412
+ break;
413
+ case "m":
414
+ case "u":
415
+ case "t":
416
+ case "F":
417
+ createSignal = true;
418
+ if (j + 1 < token.length) {
419
+ j = token.length;
420
+ } else {
421
+ consumeNext = true;
422
+ }
423
+ break;
424
+ default:
425
+ break; // an unknown short flag: no conclusion either way
426
+ }
427
+ }
428
+ if (consumeNext) i++;
429
+ continue;
430
+ }
431
+ // A plain operand: with a list flag it is a pattern or commit for the
432
+ // read; without one it is the tagname a creation needs.
433
+ hasOperand = true;
434
+ }
435
+
436
+ if (deletion) return true;
437
+ if (listRead || verifyRead) return false;
438
+ if (hasOperand) return true;
439
+ return createSignal;
440
+ }
441
+
301
442
  /**
302
443
  * Classify a shell command and the segment that fired. The segment is what a
303
444
  * triager reads: `bun test src/foo.test.ts && npm publish` matches on its
@@ -323,7 +464,7 @@ function releaseCommandMatch(command: string): { shape: GateShape; matched: stri
323
464
  // any future spelling under the upgrade family all need the install shape,
324
465
  // and a session without that grant is refused before the shell runs.
325
466
  if (/^omp-conductor\s+upgrade(?:\b|$)/.test(segment)) return { shape: "install", matched: segment };
326
- if (/^git(?:\s+-[Cc]\s+\S+)*\s+tag(?:\s|$)/.test(segment)) return { shape: "git-tag", matched: segment };
467
+ if (gitTagIsWrite(segment)) return { shape: "git-tag", matched: segment };
327
468
  if (
328
469
  /^git(?:\s+-[Cc]\s+\S+)*\s+push\b/.test(segment) &&
329
470
  GIT_PUSH_TAG_SHAPE.test(segment)
@@ -513,8 +654,10 @@ export function releaseShapeFromTool(
513
654
  /**
514
655
  * The refusal wording for the shared-host gate (#428). The whole-package
515
656
  * `bun test` form names the focused alternative (a guard that only denies
516
- * teaches nothing and gets worked around); the shell suites get their own
517
- * because there is no focused form of a suite script.
657
+ * teaches nothing and gets worked around); a refused shell suite names the
658
+ * script that fired and the sanctioned `bash -n` parse check (#687), because
659
+ * there is no focused form of a suite script — the alternative is to drop the
660
+ * execution segment and keep the allowed prefix, not to run something else.
518
661
  */
519
662
  function sharedHostRefusalReason(matched: string | undefined): string {
520
663
  if (matched !== undefined && /^bun\s+test\b/.test(matched)) {
@@ -524,10 +667,39 @@ function sharedHostRefusalReason(matched: string | undefined): string {
524
667
  "Run a focused `bun test <file>.test.ts` instead."
525
668
  );
526
669
  }
670
+ if (matched !== undefined) {
671
+ return (
672
+ `Blocked by sharedHostPolicy: \`${matched}\` is a shared-host shell suite and not a worker's proof ` +
673
+ "path on this shared host (it overloads the 4-core VPS that also runs Langfuse and the fleet). " +
674
+ `\`bash -n ${matched}\` is the sanctioned local check — parsing never executes the script — and CI's ` +
675
+ "`herdr plugin + installer shell suites` job owns executing it. Drop the segment that fired and keep " +
676
+ "the `bash -n` parse gate."
677
+ );
678
+ }
527
679
  return (
528
680
  "Blocked by sharedHostPolicy: this shell suite is not a worker's proof path on this shared host " +
529
681
  "(it overloads the 4-core VPS that also runs Langfuse and the fleet). " +
530
- "Run focused `bun test <file>.test.ts` unit tests instead."
682
+ "`bash -n <path>` is the sanctioned local check; CI's `herdr plugin + installer shell suites` job owns " +
683
+ "executing it."
684
+ );
685
+ }
686
+
687
+ /**
688
+ * The worker-brief notice for the shared-host gate (#687): the guarded suite
689
+ * paths and the sanctioned local check, derived from
690
+ * {@link SHARED_HOST_SCRIPTS} so the rendered brief and the refusal can never
691
+ * disagree about what is guarded. One line and non-empty whenever the list is:
692
+ * the brief's rendered line count must equal its template's, which the
693
+ * buildBrief tests pin, so the notice must never carry an internal newline.
694
+ */
695
+ export function sharedHostBriefNotice(): string {
696
+ if (SHARED_HOST_SCRIPTS.length === 0) return "";
697
+ const paths = SHARED_HOST_SCRIPTS.map((script) => `\`${script}\``).join(", ");
698
+ return (
699
+ `**Shared-host guard:** ${paths} are never executed on this shared host — \`bash -n\` on them is the ` +
700
+ "sanctioned local check (parsing never executes), and CI's `herdr plugin + installer shell suites` job " +
701
+ "owns executing them. A command that executes one is refused, and the refusal names the segment; drop " +
702
+ "that segment and keep the `bash -n` parse gate."
531
703
  );
532
704
  }
533
705
 
package/src/routing.ts CHANGED
@@ -39,7 +39,10 @@ export type Unroutable = {
39
39
  const MAX_BRANCH_LEN = 60;
40
40
 
41
41
  /**
42
- * True only when a human has queued the issue and no run already owns it.
42
+ * True only when a human has queued the issue, no run already owns it, and
43
+ * the operator has not parked it. The park label beats the queue label: an
44
+ * issue carrying both is not eligible, because the queue listing was fetched
45
+ * before the park landed and must not turn into a claim (#734).
43
46
  *
44
47
  * The state labels are the interlock against double-dispatch across daemon
45
48
  * restarts: the tracker, not the local store, is the source of truth for
@@ -51,8 +54,13 @@ export function isEligible(issue: ReadyIssue, p: ProjectConfig): boolean {
51
54
  // sides here, which is the only place labels are matched.
52
55
  const labels = new Set(issue.labels);
53
56
  if (!labels.has(p.queueLabel)) return false;
54
- const { inProgress, blocked, failed } = p.stateLabels;
55
- return !labels.has(inProgress) && !labels.has(blocked) && !labels.has(failed);
57
+ const { inProgress, blocked, failed, backlog } = p.stateLabels;
58
+ return (
59
+ !labels.has(inProgress) &&
60
+ !labels.has(blocked) &&
61
+ !labels.has(failed) &&
62
+ !labels.has(backlog)
63
+ );
56
64
  }
57
65
 
58
66
  /**
@@ -55,6 +55,19 @@ export interface SessionHostSpec {
55
55
  * Carried as a plain scalar like the other fields; absent, nothing is staged
56
56
  * on the far side either. */
57
57
  ompSettingsFile?: string;
58
+ /**
59
+ * The structured-settlement contract (#540): a JSON Schema the session's
60
+ * `yield` tool validates its `data` payload against. Carried as plain JSON
61
+ * through the spec, like everything else; absent, the far side passes nothing
62
+ * to the harness.
63
+ */
64
+ outputSchema?: unknown;
65
+ /** Enforcement policy for {@link outputSchema} — the worker contract is
66
+ * `"permissive"`: a schema violation settles with whatever the worker
67
+ * produced, never a lost report. */
68
+ outputSchemaMode?: "permissive" | "strict";
69
+ /** Force the hidden `yield` tool into this session's toolset (#540). */
70
+ requireYieldTool?: boolean;
58
71
  }
59
72
 
60
73
  /** Parent → child. */
@@ -192,6 +205,9 @@ export async function runSessionHost(
192
205
  ...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
193
206
  ...(spec.readOnly === undefined ? {} : { readOnly: spec.readOnly }),
194
207
  ...(spec.ompSettingsFile === undefined ? {} : { ompSettingsFile: spec.ompSettingsFile }),
208
+ ...(spec.outputSchema === undefined ? {} : { outputSchema: spec.outputSchema }),
209
+ ...(spec.outputSchemaMode === undefined ? {} : { outputSchemaMode: spec.outputSchemaMode }),
210
+ ...(spec.requireYieldTool === undefined ? {} : { requireYieldTool: spec.requireYieldTool }),
195
211
  // The release audit lives in the daemon's state directory, which this
196
212
  // process may not be able to write and must not be trusted to. It
197
213
  // becomes a message; the parent performs the durable write.