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/README.md +336 -169
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +9 -5
- package/src/briefs/policy.md +4 -4
- package/src/briefs/probes/gates.md +51 -0
- package/src/briefs/probes/project-context.md +59 -0
- package/src/briefs/probes/release-procedure.md +81 -0
- package/src/cli.ts +235 -199
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1046 -796
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +442 -374
- package/src/escalate.ts +43 -3
- package/src/fleet.ts +317 -43
- package/src/generate-schema.ts +21 -0
- package/src/graph.ts +3 -3
- package/src/host.ts +16 -0
- package/src/omp.ts +21 -1
- package/src/orchestrator-tick.ts +298 -39
- package/src/privileged.ts +264 -0
- package/src/reports.ts +1 -1
- package/src/session-host.ts +3 -0
- package/src/setup-host.ts +209 -24
- package/src/setup-install.ts +320 -0
- package/src/setup-probe.ts +412 -0
- package/src/{plugin.ts → setup-wizard.ts} +790 -465
- package/src/setup.ts +264 -20
- package/src/types.ts +2 -2
- package/src/upgrade.ts +44 -10
- package/src/verbs/server.ts +32 -9
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +6 -1
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
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
|
|
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
|
-
` \`
|
|
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
|
|
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`));
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -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
|
|
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`,
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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}" —
|
|
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
|
|
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]
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2162
|
-
|
|
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}`;
|