omp-conductor 0.14.0 → 0.15.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.
package/src/graph.ts CHANGED
@@ -214,7 +214,7 @@ export function reindexScript(p: ProjectConfig): string {
214
214
  "#!/usr/bin/env bash",
215
215
  `# Refresh and reindex the code graphs for omp-conductor project "${p.name}".`,
216
216
  "#",
217
- "# Generated by \`omp-conductor graph-setup\`. Regenerate it rather than editing:",
217
+ "# Generated by \`omp-conductor setup graph\`. Regenerate it rather than editing:",
218
218
  "# the repo list, branches and paths all come from that project's config.json.",
219
219
  "#",
220
220
  "# Every clone below is conductor's own, index-only and never edited by a human,",
@@ -437,7 +437,7 @@ export function formatGraphSetup(
437
437
  const script = reindexScriptPath();
438
438
  const { service, timer } = unitPaths(stateDir());
439
439
  lines.push(
440
- ` \`graph-setup --write\` writes these three files for you, all under`,
440
+ ` \`omp-conductor setup graph\` writes these three files for you, all under`,
441
441
  ` ${stateDir()}. Run it as the account the fleet runs as — never under`,
442
442
  " sudo, which would resolve the config, the state directory and the unit's",
443
443
  " own User= as root and quietly build indexes no worker can read.",
@@ -453,7 +453,7 @@ export function formatGraphSetup(
453
453
  return lines.join("\n");
454
454
  }
455
455
 
456
- /** What `graph-setup --write` did, and the root-only steps it deliberately left. */
456
+ /** What staging wrote, and the root-only steps it deliberately left. */
457
457
  export interface GraphSetupWrite {
458
458
  written: string[];
459
459
  next: string;
package/src/host.ts CHANGED
@@ -62,6 +62,22 @@ export function recommendedMaxWorkers(ramBytes: number | undefined): number {
62
62
  return ramBytes < SMALL_HOST_RAM_BYTES ? 1 : DEFAULT_CAPS.maxConcurrentWorkers;
63
63
  }
64
64
 
65
+ /**
66
+ * Warning shown when all configured projects can outgrow this host together.
67
+ *
68
+ * Omitting `ram` probes the host. Passing `undefined` explicitly means "RAM is
69
+ * unknown" and must NOT fall through to a host probe — a default parameter would
70
+ * treat that `undefined` as "use the default", which is how CI on a small box
71
+ * made `workerOvercommit(2, undefined)` warn about capacity 1.
72
+ */
73
+ export function workerOvercommit(total: number, ram?: number | undefined): string | undefined {
74
+ const effective = arguments.length < 2 ? hostRamBytes() : ram;
75
+ const recommended = recommendedMaxWorkers(effective);
76
+ return total > recommended
77
+ ? `Configured worker capacity ${total} exceeds this host's recommended maximum of ${recommended}.`
78
+ : undefined;
79
+ }
80
+
65
81
  /** Compact binary units for status lines (`3.2 GB`, `430 MB`). */
66
82
  export function formatRss(bytes: number): string {
67
83
  if (!Number.isFinite(bytes) || bytes < 0) return "?";
package/src/omp.ts CHANGED
@@ -18,7 +18,7 @@ import { createServer, type Server, type Socket } from "node:net";
18
18
  import { tmpdir } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
20
 
21
- import { worktreeConfinement } from "./confinement.ts";
21
+ import { readOnlySession, worktreeConfinement } from "./confinement.ts";
22
22
  import { releasePolicyTripwire, type ReleaseBlockContext } from "./release-policy.ts";
23
23
  import type {
24
24
  HostToParent,
@@ -168,6 +168,15 @@ export async function createLocalSession(opts: {
168
168
  * leave the model to improvise `git push` instead.
169
169
  */
170
170
  verbSocketPath?: string;
171
+ /**
172
+ * Deny every tool but reading and searching (#307).
173
+ *
174
+ * For sessions asked to *read* a repository and answer a question — the setup
175
+ * probes. Distinct from {@link worktreeConfinement}, which scopes where writes
176
+ * may land: this removes the shell, the editors and the verbs entirely, so the
177
+ * session has no route to a mutation to scope in the first place.
178
+ */
179
+ readOnly?: boolean;
171
180
  }): Promise<AgentSessionLike> {
172
181
  let loaded: unknown;
173
182
  try {
@@ -205,6 +214,9 @@ export async function createLocalSession(opts: {
205
214
  // transcript is the only record of what the worker actually did.
206
215
  const sessionManager = await openSessionManager(mod, opts);
207
216
  const extensions = [
217
+ // Read-only sessions get the deny-by-default gate *first*, so an unknown tool
218
+ // is refused before any later extension can form an opinion about it.
219
+ ...(opts.readOnly === true ? [readOnlySession()] : []),
208
220
  // Workers only. The orchestrator runs unconfined by operator ruling
209
221
  // (#143): the gate could only ever be installed in sessions this daemon
210
222
  // spawns, so an external orchestrator — the supported shape — never had it,
@@ -430,6 +442,13 @@ export interface CreateSessionOptions {
430
442
  * without a live harness or a model bill.
431
443
  */
432
444
  hostModule?: string;
445
+ /**
446
+ * Deny every tool but reading and searching (#307).
447
+ *
448
+ * Forwarded to the child, because that is where the session — and therefore the
449
+ * gate — actually lives.
450
+ */
451
+ readOnly?: boolean;
433
452
  }
434
453
 
435
454
  /**
@@ -499,6 +518,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
499
518
  ...(opts.resume === undefined ? {} : { resume: opts.resume }),
500
519
  ...(opts.releaseGrants === undefined ? {} : { releaseGrants: opts.releaseGrants }),
501
520
  ...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
521
+ ...(opts.readOnly === undefined ? {} : { readOnly: opts.readOnly }),
502
522
  };
503
523
 
504
524
  const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * Beyond those three gates, every tick carries the project's `reporting.scope`
27
27
  * as one explicit constraint line, re-read from the conductor config on each
28
- * tick so a `/conductor setup` change binds the next heartbeat rather than
28
+ * tick so an `omp-conductor setup` change binds the next heartbeat rather than
29
29
  * waiting for a session restart — and one delivery rule
30
30
  * ({@link TICK_DELIVERY_RULE}), because a tick is injected locally and a report
31
31
  * written as end-of-turn text on such a turn reaches nobody. An operator's own
@@ -49,7 +49,7 @@ import { spawnSync } from "node:child_process";
49
49
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
50
50
  import { dirname, isAbsolute, join, resolve } from "node:path";
51
51
  import { availabilityPrompt, interruptDisposition } from "./availability.ts";
52
- import { findProject, loadConfig, resolveReleaseGrants } from "./config.ts";
52
+ import { findProject, loadConfig, resolveReleaseGrants, stateDir } from "./config.ts";
53
53
  import {
54
54
  bridgeTokenBound,
55
55
  hasBotToken,
@@ -153,13 +153,18 @@ const PENDING_MESSAGE_GRACE_MS = 60_000;
153
153
  const DEFAULT_GROOM_BELOW = 4;
154
154
 
155
155
  /**
156
- * Written by herdr-conductor `recover.sh` *before* `agent start`, so a resumed
157
- * fleet can reconcile orphans without waiting a full `intervalSeconds`. Cleared
158
- * only after a tick is actually sent — a disarmed or channel-down fleet keeps
159
- * the request until gates pass (or a human removes the file).
156
+ * Written by herdr-conductor `recover.sh` *before* `agent start`, and by the
157
+ * dispatch daemon when a watched decision condition transitions false→true
158
+ * (#329), so the orchestrator can reconcile without waiting a full
159
+ * `intervalSeconds`. Cleared only after a tick is actually sent — a disarmed or
160
+ * channel-down fleet keeps the request until gates pass (or a human removes the
161
+ * file). Mid-interval the heartbeat polls for this file on a short cadence.
160
162
  */
161
163
  export const TICK_REQUESTED_FILE = ".conductor-tick-requested";
162
164
 
165
+ /** How often a live heartbeat looks for {@link TICK_REQUESTED_FILE} between ticks. */
166
+ const TICK_REQUEST_POLL_MS = 10_000;
167
+
163
168
  /** Runtime heartbeat schedule consumed by `omp-conductor status`. */
164
169
  export const TICK_STATUS_FILE = ".conductor-tick-status.json";
165
170
 
@@ -213,9 +218,9 @@ const PENDING_REASON = "tick already pending";
213
218
  * The slice of the omp extension API this entry touches, mirroring
214
219
  * `ExtensionAPI` / `ExtensionContext` from `@oh-my-pi/pi-coding-agent`.
215
220
  *
216
- * Declared here rather than imported for the reason ./plugin.ts declares its
217
- * own: the harness is a peer dependency and the package has to type-check
218
- * without it installed. Structural typing means the real objects satisfy these
221
+ * Declared here rather than imported because the harness is a peer dependency and
222
+ * the package has to type-check without it installed — the same reason ./omp.ts
223
+ * loads the SDK dynamically. Structural typing means the real objects satisfy these
219
224
  * on the way in.
220
225
  */
221
226
  interface TickLogger {
@@ -312,6 +317,19 @@ interface TickApi {
312
317
  * they get here. */
313
318
  export interface TickConfig {
314
319
  intervalSeconds: number;
320
+ /**
321
+ * The conductor project this fleet session ticks for, stamped by `setup host`
322
+ * (one tick config per fleet cwd). It is what lets a host with several
323
+ * configured projects resolve *this* fleet's brief, reporting policy, digest
324
+ * ledger and release grants, instead of asking `findProject` a question it
325
+ * refuses to guess through.
326
+ *
327
+ * Omitted is the pre-multi-project spelling. Resolution then falls back to the
328
+ * un-named `findProject`: exact for a single-project fleet — which must keep
329
+ * behaving exactly as it did — and ambiguous, therefore degrading, for more
330
+ * than one.
331
+ */
332
+ project?: string;
315
333
  armedFile?: string;
316
334
  accessFile?: string;
317
335
  message?: string;
@@ -682,7 +700,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
682
700
  * config could not answer — why.
683
701
  *
684
702
  * Read on every tick rather than cached at session start, for the reason the
685
- * channel gate is: the operator re-runs `/conductor setup` while this session
703
+ * channel gate is: the operator re-runs `omp-conductor setup` while this session
686
704
  * lives, and a heartbeat holding a startup snapshot would keep injecting the
687
705
  * old contract until somebody restarted it.
688
706
  *
@@ -698,8 +716,15 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
698
716
  * none named — the same ambiguity `findProject` refuses to guess through for
699
717
  * `status`. Stopping the heartbeat over either preference would be the worse
700
718
  * trade.
719
+ *
720
+ * `projectName` is {@link TickConfig.project}, stamped per fleet cwd, and it is
721
+ * the whole difference between a two-project host resolving each fleet's own
722
+ * contract and both of them degrading. Omitted (a pre-multi-project tick config)
723
+ * keeps the un-named lookup, which only a host with more than one project can
724
+ * fail — and that host gets {@link LEGACY_TICK_PROJECT_HINT} naming the one
725
+ * command that fixes it.
701
726
  */
702
- export function resolveTickScope(): {
727
+ export function resolveTickScope(projectName?: string): {
703
728
  scope: ReportScopeChoice;
704
729
  policy?: ReportingPolicy;
705
730
  briefPath?: string;
@@ -707,8 +732,14 @@ export function resolveTickScope(): {
707
732
  projectName?: string;
708
733
  fallback?: string;
709
734
  } {
735
+ // Counted before `findProject` can throw, because "several projects and this
736
+ // tick config names none" is the one fault with a one-command remedy and the
737
+ // only way to tell it apart from "no config at all" is the count.
738
+ let projects = 0;
710
739
  try {
711
- const project = findProject(loadConfig());
740
+ const config = loadConfig();
741
+ projects = config.projects.length;
742
+ const project = findProject(config, projectName);
712
743
  return {
713
744
  scope: reportScopeFromPolicy(project.reporting),
714
745
  policy: project.reporting,
@@ -717,8 +748,109 @@ export function resolveTickScope(): {
717
748
  projectName: project.name,
718
749
  };
719
750
  } catch (err) {
720
- return { scope: DEFAULT_REPORT_SCOPE, fallback: err instanceof Error ? err.message : String(err) };
751
+ const problem = err instanceof Error ? err.message : String(err);
752
+ const unstamped = projectName === undefined && projects > 1;
753
+ return { scope: DEFAULT_REPORT_SCOPE, fallback: unstamped ? `${problem} ${LEGACY_TICK_PROJECT_HINT}` : problem };
754
+ }
755
+ }
756
+
757
+ /**
758
+ * The fix for the one degradation an operator can clear in a single command: a
759
+ * tick config written before {@link TickConfig.project} existed, on a host that
760
+ * has since gained a second project.
761
+ */
762
+ export const LEGACY_TICK_PROJECT_HINT =
763
+ "Re-run `omp-conductor setup host` to stamp the project into .conductor-tick.json.";
764
+
765
+ /**
766
+ * Whether a tick config found on disk belongs to the project being asked about.
767
+ *
768
+ * Search roots overlap — `stateDir()` and the shared parent of two fleet cwds
769
+ * are read for every project — so without this a stray config in a shared root
770
+ * answers for whichever project asked first, and `setup host` would restamp one
771
+ * project's file while planning another. An unstamped config matches anything,
772
+ * which is what keeps a single-project fleet on its existing file.
773
+ */
774
+ export function tickConfigMatchesProject(config: TickConfig, projectName?: string): boolean {
775
+ if (projectName === undefined || config.project === undefined) return true;
776
+ return config.project === projectName;
777
+ }
778
+
779
+ /**
780
+ * Directory that owns this project's {@link TICK_CONFIG_FILE}, if one is
781
+ * present and stamped for the project (or unstamped on a single-project host).
782
+ * Used by out-of-process writers of {@link TICK_REQUESTED_FILE} — the dispatch
783
+ * daemon must not import `fleet.ts` (fleet already imports daemon).
784
+ */
785
+ export function resolveTickConfigCwd(projectName?: string): string | undefined {
786
+ const roots: string[] = [stateDir()];
787
+ try {
788
+ const p = findProject(loadConfig(), projectName);
789
+ const parent = dirname(p.workspaceRoot);
790
+ if (parent !== roots[0]) roots.push(parent);
791
+ if (p.workspaceRoot !== roots[0] && p.workspaceRoot !== parent) roots.push(p.workspaceRoot);
792
+ } catch {
793
+ /* no config */
794
+ }
795
+ for (const cwd of roots) {
796
+ const r = readTickConfig(cwd);
797
+ if (r.kind === "ok") {
798
+ if (!tickConfigMatchesProject(r.config, projectName)) continue;
799
+ return cwd;
800
+ }
801
+ if (r.kind === "invalid") return undefined;
721
802
  }
803
+ return undefined;
804
+ }
805
+
806
+ /** The shared, pre-per-project arm marker: `<stateDir>/armed`. */
807
+ export function legacyArmedMarkerPath(): string {
808
+ return join(stateDir(), "armed");
809
+ }
810
+
811
+ /** What `status` says about a bare `armed` marker that no tick will honour. */
812
+ export const LEGACY_ARM_MARKER_DETAIL =
813
+ "legacy global arm marker — re-run setup host, then arm per project";
814
+
815
+ /**
816
+ * Whether the arm gate is open, and whether the shared pre-per-project marker is
817
+ * what decided it.
818
+ *
819
+ * Two migrations meet here. A single-project fleet whose `armedFile` has just
820
+ * been restamped from `armed` to `armed-<name>` must not be silently disarmed by
821
+ * the upgrade, so its bare `armed` marker still counts (`legacy: "honoured"`). A
822
+ * host with more than one project cannot let one marker arm every fleet — that is
823
+ * the collision this issue removes — so there the bare marker arms nothing and
824
+ * says so instead (`legacy: "stranded"`), including for a tick config that still
825
+ * names it directly.
826
+ *
827
+ * The fallback reaches exactly two `armedFile` spellings: the shared path itself,
828
+ * and the per-project path the restamp replaces it with. An operator's own gate
829
+ * — any other value, which `setup host` deliberately preserves — is the gate; its
830
+ * absence means disarmed however many stale markers lie around it.
831
+ *
832
+ * The config is read only when a legacy marker is actually in play, so the
833
+ * healthy path costs one string compare. An unreadable or absent config is
834
+ * treated as single-project: a host with no conductor config cannot be a
835
+ * multi-project host, and 0.14 behaviour is the honest default there.
836
+ */
837
+ export type ArmState = { armed: boolean; legacy?: "honoured" | "stranded" };
838
+
839
+ export function resolveArmState(armedFile: string, projectName?: string): ArmState {
840
+ const legacy = legacyArmedMarkerPath();
841
+ const shared = armedFile === legacy;
842
+ if (!shared && existsSync(armedFile)) return { armed: true };
843
+ const migratable =
844
+ shared || (projectName !== undefined && armedFile === join(stateDir(), `armed-${projectName}`));
845
+ if (!migratable || !existsSync(legacy)) return { armed: false };
846
+ let multiProject = false;
847
+ try {
848
+ multiProject = loadConfig().projects.length > 1;
849
+ } catch {
850
+ // No readable config: not a multi-project host, so keep 0.14 behaviour.
851
+ }
852
+ if (multiProject) return { armed: false, legacy: "stranded" };
853
+ return shared ? { armed: true } : { armed: true, legacy: "honoured" };
722
854
  }
723
855
 
724
856
  /**
@@ -730,9 +862,9 @@ export function resolveTickScope(): {
730
862
  * the default prompt path. Failures (no config, no `POLICY.md`, unreadable
731
863
  * overlay) are silent: the tick still goes out.
732
864
  */
733
- export function refreshComposedBriefBestEffort(): boolean {
865
+ export function refreshComposedBriefBestEffort(projectName?: string): boolean {
734
866
  try {
735
- return refreshComposedBriefForProject(findProject(loadConfig()));
867
+ return refreshComposedBriefForProject(findProject(loadConfig(), projectName));
736
868
  } catch {
737
869
  return false;
738
870
  }
@@ -822,6 +954,16 @@ export function readTickConfig(cwd: string): TickConfigResult {
822
954
  }
823
955
  }
824
956
 
957
+ const projectRaw = raw["project"];
958
+ let project: string | undefined;
959
+ if (projectRaw !== undefined) {
960
+ if (typeof projectRaw !== "string" || projectRaw.trim().length === 0) {
961
+ problems.push("project must be a non-empty string when present");
962
+ } else {
963
+ project = projectRaw.trim();
964
+ }
965
+ }
966
+
825
967
  if (problems.length > 0) return { kind: "invalid", path, problem: problems.join("; ") };
826
968
 
827
969
  return {
@@ -829,6 +971,7 @@ export function readTickConfig(cwd: string): TickConfigResult {
829
971
  path,
830
972
  config: {
831
973
  intervalSeconds,
974
+ ...(project === undefined ? {} : { project }),
832
975
  ...(budgetSeconds === undefined ? {} : { budgetSeconds }),
833
976
  ...(armedFile === undefined ? {} : { armedFile }),
834
977
  ...(accessFile === undefined ? {} : { accessFile }),
@@ -991,9 +1134,17 @@ export function paneOwnership(input: { paneId: string; agentName: string; agents
991
1134
  if (mine?.name !== undefined) {
992
1135
  // A registered agent, just not this fleet's. herdr can name several omp
993
1136
  // agents in one directory, and requiring merely *a* name would arm each one.
1137
+ //
1138
+ // The rename hint is conditional because this branch has two very different
1139
+ // causes: a scratch pane that is correctly named something else, and the
1140
+ // fleet's own pane still carrying the name it had before its tick config was
1141
+ // restamped for a project. Only the operator knows which.
994
1142
  return {
995
1143
  kind: "declined",
996
- reason: `this pane is agent "${mine.name}", not the fleet agent "${input.agentName}" — this session will not tick`,
1144
+ reason:
1145
+ `this pane is agent "${mine.name}", not the fleet agent "${input.agentName}" — ` +
1146
+ `if this is the fleet pane under an old name, \`herdr agent rename ${input.paneId} ${input.agentName}\`; ` +
1147
+ `this session will not tick`,
997
1148
  };
998
1149
  }
999
1150
 
@@ -1008,12 +1159,19 @@ export function paneOwnership(input: { paneId: string; agentName: string; agents
1008
1159
  // No name here and nobody else holding it: an ad-hoc pane in the fleet's
1009
1160
  // directory, which is exactly the session that must stay inert. Fail-closed,
1010
1161
  // and the fix is named — an orchestrator that lost its registration is one
1011
- // `herdr agent start` from ticking again.
1162
+ // `herdr agent rename` from ticking again.
1163
+ //
1164
+ // Deliberately NOT `herdr agent start`: that submits omp *into* the pane's
1165
+ // existing shell and requires a pane at a shell prompt hosting no agent
1166
+ // (herdr/README.md). This pane is running the omp session reading this line, so
1167
+ // `agent start` would either be refused or start a second orchestrator in it.
1168
+ // `rename` names the agent herdr already detects, and touches no process.
1012
1169
  return {
1013
1170
  kind: "declined",
1014
1171
  reason:
1015
1172
  `this pane is not a registered herdr agent, and no pane is running the fleet agent ` +
1016
- `"${input.agentName}" — register it with \`herdr agent start ${input.agentName} --kind omp --pane ${input.paneId}\`; ` +
1173
+ `"${input.agentName}" — name its running agent with ` +
1174
+ `\`herdr agent rename ${input.paneId} ${input.agentName}\`; ` +
1017
1175
  `this session will not tick`,
1018
1176
  };
1019
1177
  }
@@ -1340,18 +1498,58 @@ function clearStallMarker(pi: TickApi, cwd: string): void {
1340
1498
  }
1341
1499
  }
1342
1500
 
1501
+ /**
1502
+ * Best-effort poke that asks a live orchestrator heartbeat to fire soon.
1503
+ *
1504
+ * Same file shape as herdr `recover.sh`: one line `${iso} ${reason}`. Writers
1505
+ * (recover, the dispatch daemon on a false→true condition) never clear it —
1506
+ * only a successful tick send does, so a disarmed or channel-down fleet keeps
1507
+ * the request until gates pass.
1508
+ */
1509
+ export function requestImmediateTick(cwd: string, reason: string): boolean {
1510
+ const path = join(cwd, TICK_REQUESTED_FILE);
1511
+ const label = reason.trim().length > 0 ? reason.trim() : "wake";
1512
+ try {
1513
+ writeFileSync(path, `${new Date().toISOString()} ${label}\n`);
1514
+ return true;
1515
+ } catch {
1516
+ return false;
1517
+ }
1518
+ }
1519
+
1520
+ /** First non-empty token after the optional ISO stamp on a poke line. */
1521
+ export function readTickRequestReason(cwd: string): string | undefined {
1522
+ const path = join(cwd, TICK_REQUESTED_FILE);
1523
+ if (!existsSync(path)) return undefined;
1524
+ let raw: string;
1525
+ try {
1526
+ raw = readFileSync(path, "utf8");
1527
+ } catch {
1528
+ return undefined;
1529
+ }
1530
+ const line = raw.split(/\r?\n/, 1)[0]?.trim() ?? "";
1531
+ if (line.length === 0) return undefined;
1532
+ const parts = line.split(/\s+/);
1533
+ if (parts.length === 0) return undefined;
1534
+ // recover.sh writes `${iso} recover`; a bare reason is also accepted.
1535
+ if (parts.length >= 2 && Number.isFinite(Date.parse(parts[0]!))) {
1536
+ return parts.slice(1).join(" ");
1537
+ }
1538
+ return parts.join(" ");
1539
+ }
1540
+
1343
1541
  /**
1344
1542
  * Best-effort, same posture as {@link clearStallMarker}. Leaving the file on a
1345
1543
  * failed unlink means the next successful send retries the clear; that is
1346
- * preferable to treating a recover poke as fire-and-forget when the tick did
1347
- * land.
1544
+ * preferable to treating a recover/condition poke as fire-and-forget when the
1545
+ * tick did land.
1348
1546
  */
1349
1547
  function clearTickRequest(pi: TickApi, cwd: string): void {
1350
1548
  const path = join(cwd, TICK_REQUESTED_FILE);
1351
1549
  if (!existsSync(path)) return;
1352
1550
  try {
1353
1551
  rmSync(path, { force: true });
1354
- pi.logger.info("[omp-conductor] recover tick request cleared: a tick was sent");
1552
+ pi.logger.info("[omp-conductor] tick request cleared: a tick was sent");
1355
1553
  } catch (err) {
1356
1554
  pi.logger.error(`[omp-conductor] could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
1357
1555
  }
@@ -1609,6 +1807,13 @@ interface TickSession {
1609
1807
  pendingSkips: number;
1610
1808
  /** Ticks sent as follow-ups but not yet observed by the agent loop. */
1611
1809
  pendingLocalTicks: PendingLocalTick[];
1810
+ /**
1811
+ * Whether the stranded shared arm marker has been named. Latched for
1812
+ * {@link TickSession.scopeFallbackLogged}'s reason: a host that gained a
1813
+ * second project without re-running setup would otherwise repeat the same
1814
+ * line every interval, forever.
1815
+ */
1816
+ legacyArmLogged: boolean;
1612
1817
  /** The local tick whose agent loop is currently running, if any. */
1613
1818
  activeLocalTick?: ActiveLocalTick;
1614
1819
  }
@@ -1619,8 +1824,15 @@ interface TickSession {
1619
1824
  * otherwise emit a notification every interval, forever.
1620
1825
  */
1621
1826
  function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
1827
+ const arm = config.armedFile === undefined ? undefined : resolveArmState(config.armedFile, config.project);
1828
+ if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
1829
+ session.legacyArmLogged = true;
1830
+ pi.logger.error(
1831
+ `[omp-conductor] ${legacyArmedMarkerPath()}: ${LEGACY_ARM_MARKER_DETAIL} — this heartbeat stays disarmed`,
1832
+ );
1833
+ }
1622
1834
  const decision = tickDecision({
1623
- armed: config.armedFile === undefined || existsSync(config.armedFile),
1835
+ armed: arm === undefined || arm.armed,
1624
1836
  channelOk: config.accessFile === undefined || channelIsUp(config.accessFile),
1625
1837
  hasPending: ctx.hasPendingMessages(),
1626
1838
  });
@@ -1647,9 +1859,9 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1647
1859
 
1648
1860
  // Floor refresh is independent of which prompt we send: a custom message still
1649
1861
  // expects ORCHESTRATOR.md / AGENTS.md to track the installed package.
1650
- refreshComposedBriefBestEffort();
1862
+ refreshComposedBriefBestEffort(config.project);
1651
1863
 
1652
- const scope = resolveTickScope();
1864
+ const scope = resolveTickScope(config.project);
1653
1865
  // The transport contract, read once from the same file at the same moment so
1654
1866
  // the approval line, the delivery rule and the narration line cannot disagree
1655
1867
  // (#169, #179). No access file means no fleet channel to judge; a session
@@ -1789,7 +2001,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1789
2001
  // tracker (#181). A failed config read skips the line rather than
1790
2002
  // wedging the tick.
1791
2003
  try {
1792
- const project = findProject(loadConfig());
2004
+ // Named: this block is already inside `scope.projectName !== undefined`,
2005
+ // so the resolved name is in hand and an un-named lookup would refuse
2006
+ // to guess on a host with a second project.
2007
+ const project = findProject(loadConfig(), scope.projectName);
1793
2008
  const queue = queueDigestLine(
1794
2009
  frictionStore.latestDispatch(scope.projectName),
1795
2010
  project.queueLabel,
@@ -1873,6 +2088,11 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1873
2088
  // No `accessFile` means no fleet bridge to judge, exactly as above.
1874
2089
  if (profile?.kind === "interactive") content = `${content}\n${TICK_NARRATION_RULE}`;
1875
2090
 
2091
+ // Recover / condition-met pokes are auditable: the same reason the writer put
2092
+ // on the sentinel line lands in the prompt and the "tick sent" log.
2093
+ const wakeReason = readTickRequestReason(ctx.cwd);
2094
+ if (wakeReason !== undefined) content = `${content}\nWake reason: ${wakeReason}`;
2095
+
1876
2096
  const pendingLocalTick: PendingLocalTick = {
1877
2097
  id: randomUUID(),
1878
2098
  ...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
@@ -1906,13 +2126,19 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1906
2126
  } finally {
1907
2127
  frictionStore?.close();
1908
2128
  }
1909
- pi.logger.info(`[omp-conductor] tick sent: ${decision.reason}`, { reason: decision.reason });
2129
+ pi.logger.info(
2130
+ wakeReason === undefined
2131
+ ? `[omp-conductor] tick sent: ${decision.reason}`
2132
+ : `[omp-conductor] tick sent: ${decision.reason} (wake: ${wakeReason})`,
2133
+ { reason: decision.reason, ...(wakeReason === undefined ? {} : { wakeReason }) },
2134
+ );
1910
2135
  // An empty queue at send time is the proof the previous tick was consumed, so
1911
2136
  // this is the only place either the counter or the marker is cleared.
1912
2137
  session.pendingSkips = 0;
1913
2138
  clearStallMarker(pi, ctx.cwd);
1914
- // Recover poke is consumed only on a real send — gates still apply above.
2139
+ // Immediate-tick poke is consumed only on a real send — gates still apply above.
1915
2140
  clearTickRequest(pi, ctx.cwd);
2141
+
1916
2142
  }
1917
2143
 
1918
2144
  function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: number): void {
@@ -1933,22 +2159,40 @@ function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: numbe
1933
2159
  }
1934
2160
 
1935
2161
  /**
1936
- * Arm the interval heartbeat, then honour a recover poke if one is waiting.
2162
+ * Arm the interval heartbeat, honour a poke already waiting at arm time, and
2163
+ * poll mid-interval for later pokes (recover resume, condition-met #329).
1937
2164
  * Extracted so the ownership-retry path and the immediate-accept path cannot
1938
- * drift: both must fire the same "do not wait a full interval after resume"
1939
- * behaviour.
2165
+ * drift: both must fire the same "do not wait a full interval after a poke"
2166
+ * behaviour. Every path still runs {@link tick} → {@link tickDecision}, so a
2167
+ * live turn coalesces rather than stacking concurrent ticks.
1940
2168
  */
1941
2169
  function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
1942
2170
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
1943
- ctx.setInterval(() => {
2171
+ const runScheduledTick = (): void => {
1944
2172
  try {
1945
2173
  tick(pi, ctx, config, session);
1946
2174
  } finally {
1947
2175
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
1948
2176
  }
1949
- }, config.intervalSeconds * 1000);
2177
+ };
2178
+ ctx.setInterval(runScheduledTick, config.intervalSeconds * 1000);
2179
+ // Short mid-interval poll so a condition that flips between heartbeats does
2180
+ // not wait a full cycle. Cap at the heartbeat itself so a misconfigured short
2181
+ // interval cannot arm a second timer faster than the primary.
2182
+ const pollMs = Math.min(TICK_REQUEST_POLL_MS, config.intervalSeconds * 1000);
2183
+ if (pollMs < config.intervalSeconds * 1000) {
2184
+ ctx.setInterval(() => {
2185
+ if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
2186
+ const reason = readTickRequestReason(ctx.cwd) ?? "wake";
2187
+ pi.logger.info(
2188
+ `[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`,
2189
+ );
2190
+ runScheduledTick();
2191
+ }, pollMs);
2192
+ }
1950
2193
  if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
1951
- pi.logger.info("[omp-conductor] tick requested by recover — firing without waiting for the interval");
2194
+ const reason = readTickRequestReason(ctx.cwd) ?? "wake";
2195
+ pi.logger.info(`[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`);
1952
2196
  tick(pi, ctx, config, session);
1953
2197
  }
1954
2198
 
@@ -2005,6 +2249,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2005
2249
  bridgeTokenAtStart: true,
2006
2250
  pendingSkips: 0,
2007
2251
  pendingLocalTicks: [],
2252
+ legacyArmLogged: false,
2008
2253
  };
2009
2254
  let releaseGateArmed = false;
2010
2255
  let availabilityGateArmed = false;
@@ -2013,7 +2258,14 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2013
2258
  // which pane owns it. The gate therefore starts closed and only honours a
2014
2259
  // configured grant after ownership is accepted.
2015
2260
  let releaseAuthorityAccepted = false;
2016
- const armReleaseGate = (): void => {
2261
+ /**
2262
+ * `configuredProject` is {@link TickConfig.project}, and it is what stops a
2263
+ * second configured project from collapsing every release grant this fleet
2264
+ * really holds to {@link DENIED_RELEASE_GRANTS}. Undefined for a tick config
2265
+ * that names none — including an invalid one, which arms this gate before the
2266
+ * config can be trusted at all.
2267
+ */
2268
+ const armReleaseGate = (configuredProject?: string): void => {
2017
2269
  if (releaseGateArmed) return;
2018
2270
  releaseGateArmed = true;
2019
2271
  (pi as TickApi & {
@@ -2033,7 +2285,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2033
2285
  let grants: ResolvedGrants = DENIED_RELEASE_GRANTS;
2034
2286
  let external = true;
2035
2287
  try {
2036
- const project = findProject(loadConfig());
2288
+ const project = findProject(loadConfig(), configuredProject);
2037
2289
  projectName = project.name;
2038
2290
  grants = resolveReleaseGrants(project);
2039
2291
  external = project.escalation.orchestrator === "external";
@@ -2077,7 +2329,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2077
2329
  });
2078
2330
  };
2079
2331
 
2080
- const armAvailabilityGate = (): void => {
2332
+ /** `configuredProject` carries {@link TickConfig.project} for the same reason
2333
+ * {@link armReleaseGate} takes it: a recovered tick must reconstruct *this*
2334
+ * fleet's availability policy, not refuse to guess between two projects. */
2335
+ const armAvailabilityGate = (configuredProject?: string): void => {
2081
2336
  if (availabilityGateArmed) return;
2082
2337
  availabilityGateArmed = true;
2083
2338
 
@@ -2095,7 +2350,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2095
2350
  // A queued custom tick can survive a host restart after the in-memory
2096
2351
  // enqueue record does not. Reconstruct its scope so recovery cannot
2097
2352
  // silently turn an autonomous run into an interactive one.
2098
- const scope = resolveTickScope();
2353
+ const scope = resolveTickScope(configuredProject);
2099
2354
  const id =
2100
2355
  typeof message.timestamp === "number"
2101
2356
  ? createHash("sha256").update(`recovered-tick\0${message.timestamp}`).digest("hex")
@@ -2158,8 +2413,12 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2158
2413
 
2159
2414
  // Present but invalid still identifies a fleet directory. Install the
2160
2415
  // fail-closed handler before validation or ownership can return early.
2161
- armReleaseGate();
2162
- armAvailabilityGate();
2416
+ // The project this fleet cwd ticks for, so both gates resolve its own policy
2417
+ // rather than collapsing fail-closed on a host with a second project. An
2418
+ // invalid config names none — it cannot be trusted to.
2419
+ const configuredProject = result.kind === "ok" ? result.config.project : undefined;
2420
+ armReleaseGate(configuredProject);
2421
+ armAvailabilityGate(configuredProject);
2163
2422
 
2164
2423
  if (result.kind === "invalid") {
2165
2424
  const detail = `${result.path}: ${result.problem}`;