omp-conductor 0.3.18 → 0.3.19
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 +344 -179
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +43 -50
- package/skills/conductor-update/SKILL.md +15 -165
- package/src/board.ts +734 -0
- package/src/brief-upgrade.ts +102 -19
- package/src/briefs/orchestrator.md +32 -16
- package/src/briefs/worker.md +7 -2
- package/src/cli.ts +136 -53
- package/src/config.ts +22 -6
- package/src/daemon.ts +673 -126
- package/src/fleet.ts +64 -6
- package/src/graph-health.ts +296 -0
- package/src/graph.ts +6 -6
- package/src/omp.ts +15 -4
- package/src/orchestrator-tick.ts +157 -10
- package/src/orchestrator.ts +8 -1
- package/src/plugin.ts +173 -45
- package/src/release-policy.ts +202 -0
- package/src/setup-host.ts +285 -0
- package/src/setup.ts +24 -20
- package/src/store.ts +373 -13
- package/src/tracker/github.ts +171 -3
- package/src/transcript.ts +45 -0
- package/src/types.ts +145 -2
- package/src/unblock.ts +26 -14
- package/src/upgrade.ts +327 -0
- package/src/worker.ts +67 -30
- package/src/worktree.ts +66 -0
- package/systemd/omp-conductor.service.example +5 -3
package/src/orchestrator-tick.ts
CHANGED
|
@@ -43,13 +43,20 @@
|
|
|
43
43
|
import { spawnSync } from "node:child_process";
|
|
44
44
|
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
45
45
|
import { isAbsolute, join, resolve } from "node:path";
|
|
46
|
-
import { findProject, loadConfig } from "./config.ts";
|
|
46
|
+
import { findProject, loadConfig, resolveReleasePolicy } from "./config.ts";
|
|
47
47
|
import {
|
|
48
48
|
briefPathForProject,
|
|
49
49
|
policyPathForProject,
|
|
50
50
|
refreshComposedBriefForProject,
|
|
51
51
|
} from "./setup.ts";
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
recordReleaseBlock,
|
|
54
|
+
releaseDecision,
|
|
55
|
+
releaseDriftDigestLine,
|
|
56
|
+
type ReleaseDecision,
|
|
57
|
+
} from "./release-policy.ts";
|
|
58
|
+
import { DEFAULT_RELEASE_POLICY, DEFAULT_REPORT_SCOPE, type FrictionSignal, type ReportScope, type Store } from "./types.ts";
|
|
59
|
+
import { dbPath, openStore } from "./store.ts";
|
|
53
60
|
|
|
54
61
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
55
62
|
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
@@ -63,6 +70,11 @@ export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
|
|
|
63
70
|
* misconfiguration worth refusing rather than obeying.
|
|
64
71
|
*/
|
|
65
72
|
export const MIN_INTERVAL_SECONDS = 60;
|
|
73
|
+
/** Repetition threshold and windows for Learning-loop friction signals. */
|
|
74
|
+
export const FRICTION_MIN_OBSERVATIONS = 3;
|
|
75
|
+
export const FRICTION_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
76
|
+
export const FRICTION_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
77
|
+
const FRICTION_DIGEST_LIMIT = 3;
|
|
66
78
|
|
|
67
79
|
/**
|
|
68
80
|
* How often to re-ask who owns the fleet tick after herdr failed to answer.
|
|
@@ -324,6 +336,35 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
|
324
336
|
export const TICK_DELIVERY_RULE =
|
|
325
337
|
"This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Deliver anything reportable this turn by calling the telegram_send tool and confirming success; never claim a report was sent otherwise.";
|
|
326
338
|
|
|
339
|
+
function frictionLabel(kind: FrictionSignal["kind"]): string {
|
|
340
|
+
if (kind.startsWith("admission:")) return `admission hold ${kind.slice("admission:".length)}`;
|
|
341
|
+
if (kind === "feedback:escalation-should-digest") return "escalations classified as digest material";
|
|
342
|
+
if (kind === "feedback:report-noise") return "tick reports classified as noise";
|
|
343
|
+
return "tick reports classified as surprising";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Bounded evidence for the existing approval protocol — never an automatic edit. */
|
|
347
|
+
export function formatFrictionDigest(signals: readonly FrictionSignal[]): string {
|
|
348
|
+
const shown = signals.slice(0, FRICTION_DIGEST_LIMIT);
|
|
349
|
+
const lines = [
|
|
350
|
+
"Repeated friction observed over the last 7 days (evidence only; not permission to edit policy):",
|
|
351
|
+
...shown.map((signal) => {
|
|
352
|
+
const issues =
|
|
353
|
+
signal.issues.length === 0 ? "" : `; issues ${signal.issues.map((issue) => `#${issue}`).join(", ")}`;
|
|
354
|
+
const samples = signal.samples.length === 0 ? "" : `; examples: ${signal.samples.join(" | ")}`;
|
|
355
|
+
return (
|
|
356
|
+
`- ${frictionLabel(signal.kind)} — ${signal.observations} observations, ` +
|
|
357
|
+
`${signal.occurrences} affected${issues}${samples}`
|
|
358
|
+
);
|
|
359
|
+
}),
|
|
360
|
+
];
|
|
361
|
+
if (signals.length > shown.length) lines.push(`- ${signals.length - shown.length} more signal(s) deferred`);
|
|
362
|
+
lines.push(
|
|
363
|
+
"After the tick duties, investigate at most one signal. Use the existing Learning loop only if the recurring cause has a safe POLICY.md remedy; otherwise leave policy unchanged and report or file the underlying product/infra issue through the existing rules.",
|
|
364
|
+
);
|
|
365
|
+
return lines.join("\n");
|
|
366
|
+
}
|
|
367
|
+
|
|
327
368
|
/**
|
|
328
369
|
* The scope this tick carries, where the brief actually lives, and — when the
|
|
329
370
|
* config could not answer — why.
|
|
@@ -982,23 +1023,66 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
982
1023
|
// expects ORCHESTRATOR.md / AGENTS.md to track the installed package.
|
|
983
1024
|
refreshComposedBriefBestEffort();
|
|
984
1025
|
|
|
985
|
-
|
|
986
|
-
//
|
|
987
|
-
//
|
|
1026
|
+
const scope = resolveTickScope();
|
|
1027
|
+
// A configured message owns the ordinary reporting and delivery clauses.
|
|
1028
|
+
// Mechanical evidence is different: release-policy drift and repeated
|
|
1029
|
+
// operational friction must not disappear because an operator customized the
|
|
1030
|
+
// ordinary heartbeat wording.
|
|
988
1031
|
let content = currentMessage(ctx.cwd, config);
|
|
989
1032
|
if (content === undefined) {
|
|
990
|
-
const scope = resolveTickScope();
|
|
991
1033
|
if (scope.fallback !== undefined && !session.scopeFallbackLogged) {
|
|
992
1034
|
session.scopeFallbackLogged = true;
|
|
993
1035
|
pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
|
|
994
1036
|
}
|
|
995
1037
|
content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}\n${TICK_DELIVERY_RULE}`;
|
|
996
1038
|
}
|
|
1039
|
+
let frictionStore: Store | undefined;
|
|
1040
|
+
let frictionSignals: FrictionSignal[] = [];
|
|
1041
|
+
const now = Date.now();
|
|
1042
|
+
if (scope.projectName !== undefined) {
|
|
1043
|
+
const drift = releaseDriftDigestLine(scope.projectName);
|
|
1044
|
+
if (drift !== undefined) content = `${content}\n${drift}`;
|
|
1045
|
+
if (existsSync(dbPath())) {
|
|
1046
|
+
try {
|
|
1047
|
+
frictionStore = openStore(dbPath());
|
|
1048
|
+
frictionSignals = frictionStore.pendingFriction(
|
|
1049
|
+
scope.projectName,
|
|
1050
|
+
now - FRICTION_LOOKBACK_MS,
|
|
1051
|
+
FRICTION_MIN_OBSERVATIONS,
|
|
1052
|
+
now - FRICTION_COOLDOWN_MS,
|
|
1053
|
+
);
|
|
1054
|
+
if (frictionSignals.length > 0) content = `${content}\n${formatFrictionDigest(frictionSignals)}`;
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
frictionStore?.close();
|
|
1057
|
+
frictionStore = undefined;
|
|
1058
|
+
pi.logger.error(
|
|
1059
|
+
`[omp-conductor] friction digest unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
997
1064
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1065
|
+
try {
|
|
1066
|
+
pi.sendMessage(
|
|
1067
|
+
{ customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
|
|
1068
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
1069
|
+
);
|
|
1070
|
+
if (scope.projectName !== undefined && frictionSignals.length > 0) {
|
|
1071
|
+
try {
|
|
1072
|
+
frictionStore?.markFrictionSurfaced(
|
|
1073
|
+
scope.projectName,
|
|
1074
|
+
frictionSignals.map((signal) => signal.kind),
|
|
1075
|
+
now,
|
|
1076
|
+
);
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
pi.logger.error(
|
|
1079
|
+
`[omp-conductor] friction digest cooldown could not be recorded: ${err instanceof Error ? err.message : String(err)}`,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
} finally {
|
|
1084
|
+
frictionStore?.close();
|
|
1085
|
+
}
|
|
1002
1086
|
pi.logger.info(`[omp-conductor] tick sent: ${decision.reason}`, { reason: decision.reason });
|
|
1003
1087
|
// An empty queue at send time is the proof the previous tick was consumed, so
|
|
1004
1088
|
// this is the only place either the counter or the marker is cleared.
|
|
@@ -1056,6 +1140,63 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1056
1140
|
// counter is about this session's own queue. A second session in the same
|
|
1057
1141
|
// process starts with both at zero.
|
|
1058
1142
|
const session: TickSession = { scopeFallbackLogged: false, pendingSkips: 0 };
|
|
1143
|
+
let releaseGateArmed = false;
|
|
1144
|
+
// An activation file makes this a fleet directory before Herdr can prove
|
|
1145
|
+
// which pane owns it. The gate therefore starts closed and only honours an
|
|
1146
|
+
// operator-brief policy after ownership is accepted.
|
|
1147
|
+
let releaseAuthorityAccepted = false;
|
|
1148
|
+
const armReleaseGate = (): void => {
|
|
1149
|
+
if (releaseGateArmed) return;
|
|
1150
|
+
releaseGateArmed = true;
|
|
1151
|
+
(pi as TickApi & {
|
|
1152
|
+
on(
|
|
1153
|
+
event: "tool_call",
|
|
1154
|
+
handler: (
|
|
1155
|
+
event: { toolName: string; input: Record<string, unknown> },
|
|
1156
|
+
ctx: unknown,
|
|
1157
|
+
) => ReleaseDecision | undefined,
|
|
1158
|
+
): void;
|
|
1159
|
+
}).on("tool_call", (event) => {
|
|
1160
|
+
// Most tool calls are ordinary tracker/file work. Detect shape first so
|
|
1161
|
+
// they do not re-read config or emit policy diagnostics.
|
|
1162
|
+
const candidate = releaseDecision(DEFAULT_RELEASE_POLICY, event.toolName, event.input);
|
|
1163
|
+
if (candidate === undefined) return undefined;
|
|
1164
|
+
let projectName: string | undefined;
|
|
1165
|
+
let policy = DEFAULT_RELEASE_POLICY;
|
|
1166
|
+
let external = true;
|
|
1167
|
+
try {
|
|
1168
|
+
const project = findProject(loadConfig());
|
|
1169
|
+
projectName = project.name;
|
|
1170
|
+
policy = resolveReleasePolicy(project);
|
|
1171
|
+
external = project.escalation.orchestrator === "external";
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
// A missing/unreadable config cannot open a release gate. Log only when
|
|
1174
|
+
// a release-shaped call actually reaches this handler.
|
|
1175
|
+
pi.logger.error(
|
|
1176
|
+
`[omp-conductor] release policy unreadable; enforcing ${DEFAULT_RELEASE_POLICY}: ${
|
|
1177
|
+
err instanceof Error ? err.message : String(err)
|
|
1178
|
+
}`,
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
if (releaseAuthorityAccepted && policy === "operator-brief") return undefined;
|
|
1182
|
+
// Embedded orchestrators carry the same tripwire inline through
|
|
1183
|
+
// `createSession`; suppress this second copy only after this session has
|
|
1184
|
+
// proved it owns the external heartbeat.
|
|
1185
|
+
if (releaseAuthorityAccepted && !external) return undefined;
|
|
1186
|
+
if (projectName !== undefined) {
|
|
1187
|
+
try {
|
|
1188
|
+
recordReleaseBlock(projectName, "orchestrator", candidate.shape);
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
pi.logger.error(
|
|
1191
|
+
`[omp-conductor] could not record release-policy block: ${
|
|
1192
|
+
err instanceof Error ? err.message : String(err)
|
|
1193
|
+
}`,
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return candidate.decision;
|
|
1198
|
+
});
|
|
1199
|
+
};
|
|
1059
1200
|
|
|
1060
1201
|
pi.on("session_start", (_event, ctx) => {
|
|
1061
1202
|
if (decided) return;
|
|
@@ -1081,6 +1222,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1081
1222
|
return;
|
|
1082
1223
|
}
|
|
1083
1224
|
|
|
1225
|
+
// Present but invalid still identifies a fleet directory. Install the
|
|
1226
|
+
// fail-closed handler before validation or ownership can return early.
|
|
1227
|
+
armReleaseGate();
|
|
1228
|
+
|
|
1084
1229
|
if (result.kind === "invalid") {
|
|
1085
1230
|
const detail = `${result.path}: ${result.problem}`;
|
|
1086
1231
|
pi.logger.error(`[omp-conductor] orchestrator tick disabled — ${detail}`);
|
|
@@ -1138,6 +1283,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1138
1283
|
return;
|
|
1139
1284
|
}
|
|
1140
1285
|
if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
|
|
1286
|
+
releaseAuthorityAccepted = true;
|
|
1141
1287
|
armTickHeartbeat(pi, ctx, config, session);
|
|
1142
1288
|
pi.logger.info(`[omp-conductor] orchestrator tick active: ownership resolved on retry`, { agentName });
|
|
1143
1289
|
}, retryMs);
|
|
@@ -1151,6 +1297,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1151
1297
|
return;
|
|
1152
1298
|
}
|
|
1153
1299
|
if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
|
|
1300
|
+
releaseAuthorityAccepted = true;
|
|
1154
1301
|
armTickHeartbeat(pi, ctx, config, session);
|
|
1155
1302
|
decided = true;
|
|
1156
1303
|
// Both gates are named at startup: "why is it not ticking?" is answered by
|
package/src/orchestrator.ts
CHANGED
|
@@ -33,7 +33,8 @@ import { stateDir } from "./config.ts";
|
|
|
33
33
|
import { formatEscalation } from "./escalate.ts";
|
|
34
34
|
import { createSession, disposeSession } from "./omp.ts";
|
|
35
35
|
import type { AgentSessionLike } from "./omp.ts";
|
|
36
|
-
import type {
|
|
36
|
+
import type { ReleaseShape } from "./release-policy.ts";
|
|
37
|
+
import type { Escalation, ReleasePolicy } from "./types.ts";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* The session factory {@link startOrchestrator} uses. Named so the test seam
|
|
@@ -44,6 +45,8 @@ export type CreateSessionFn = (opts: {
|
|
|
44
45
|
sessionDir?: string;
|
|
45
46
|
model?: string;
|
|
46
47
|
resume?: boolean;
|
|
48
|
+
releasePolicy?: ReleasePolicy;
|
|
49
|
+
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
47
50
|
}) => Promise<AgentSessionLike>;
|
|
48
51
|
|
|
49
52
|
/**
|
|
@@ -76,6 +79,8 @@ export interface OrchestratorOpts {
|
|
|
76
79
|
cwd: string;
|
|
77
80
|
sessionDir?: string;
|
|
78
81
|
model?: string;
|
|
82
|
+
releasePolicy?: ReleasePolicy;
|
|
83
|
+
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
79
84
|
/**
|
|
80
85
|
* Standing orders — which repo, which labels, what the fleet is. Prepended to
|
|
81
86
|
* the *first* injection rather than sent as its own prompt on startup: a
|
|
@@ -159,6 +164,8 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
|
|
|
159
164
|
cwd: o.cwd,
|
|
160
165
|
sessionDir,
|
|
161
166
|
...(o.model === undefined ? {} : { model: o.model }),
|
|
167
|
+
...(o.releasePolicy === undefined ? {} : { releasePolicy: o.releasePolicy }),
|
|
168
|
+
...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
|
|
162
169
|
// The whole point of a persistent orchestrator: a daemon restart must not
|
|
163
170
|
// reset what it knows it has already escalated, or the first tick after a
|
|
164
171
|
// deploy re-litigates every parked issue from scratch.
|
package/src/plugin.ts
CHANGED
|
@@ -21,12 +21,12 @@ import {
|
|
|
21
21
|
repairPolicyBannerCrumbs,
|
|
22
22
|
writeMergedBrief,
|
|
23
23
|
} from "./brief-upgrade.ts";
|
|
24
|
-
import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
|
|
24
|
+
import { configPath, expandHome, findProject, loadConfig, resolveCaps, saveConfig } from "./config.ts";
|
|
25
25
|
import { hostRamBytes, recommendedMaxWorkers } from "./host.ts";
|
|
26
26
|
import {
|
|
27
|
-
armConductor,
|
|
28
27
|
isPaused,
|
|
29
|
-
|
|
28
|
+
prepareConductor,
|
|
29
|
+
previewProject,
|
|
30
30
|
setPaused,
|
|
31
31
|
type QueuePreview,
|
|
32
32
|
} from "./daemon.ts";
|
|
@@ -40,8 +40,15 @@ import {
|
|
|
40
40
|
releaseHold,
|
|
41
41
|
renderStatus,
|
|
42
42
|
} from "./fleet.ts";
|
|
43
|
+
import { restartDaemon } from "./lifecycle.ts";
|
|
43
44
|
|
|
44
45
|
import { defaultGraphRoot } from "./graph.ts";
|
|
46
|
+
import {
|
|
47
|
+
formatHostRuntimePlan,
|
|
48
|
+
planHostRuntime,
|
|
49
|
+
runSetupSmoke,
|
|
50
|
+
writeHostRuntime,
|
|
51
|
+
} from "./setup-host.ts";
|
|
45
52
|
import {
|
|
46
53
|
AMEND_AREAS,
|
|
47
54
|
ORCHESTRATOR_BRIEF_NAME,
|
|
@@ -74,6 +81,7 @@ import {
|
|
|
74
81
|
type ConductorConfig,
|
|
75
82
|
type OrchestratorMode,
|
|
76
83
|
type ProjectConfig,
|
|
84
|
+
type ReleasePolicy,
|
|
77
85
|
type ReportScope,
|
|
78
86
|
} from "./types.ts";
|
|
79
87
|
|
|
@@ -341,6 +349,20 @@ async function askAuthority(
|
|
|
341
349
|
return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human" };
|
|
342
350
|
}
|
|
343
351
|
|
|
352
|
+
async function askReleasePolicy(
|
|
353
|
+
ctx: CommandContext,
|
|
354
|
+
prior: ReleasePolicy,
|
|
355
|
+
): Promise<ReleasePolicy> {
|
|
356
|
+
const open = await ctx.ui.confirm(
|
|
357
|
+
"Release tool gate",
|
|
358
|
+
"Allow worker and orchestrator sessions to invoke release/deploy-shaped tools? Only enable this " +
|
|
359
|
+
"when the operator brief contains the release procedure they must follow. Default: no, block " +
|
|
360
|
+
"git tags, tag pushes, package publishing, GitHub release creation and deploy commands" +
|
|
361
|
+
`${prior === "operator-brief" ? " — currently enabled, answer no to close it" : ""}.`,
|
|
362
|
+
);
|
|
363
|
+
return open ? "operator-brief" : "none";
|
|
364
|
+
}
|
|
365
|
+
|
|
344
366
|
/**
|
|
345
367
|
* Where the session that triages escalations lives. Phrased as a fact about the
|
|
346
368
|
* host rather than a preference, because that is what it is: answering yes when
|
|
@@ -571,7 +593,8 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
571
593
|
`Defaults: ${workersDefault} workers, ` +
|
|
572
594
|
`${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} turns and ` +
|
|
573
595
|
`${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
|
|
574
|
-
`${DEFAULT_CAPS.maxAttemptsPerIssue} attempts
|
|
596
|
+
`${DEFAULT_CAPS.maxAttemptsPerIssue} failed attempts and ` +
|
|
597
|
+
`${DEFAULT_CAPS.maxContinuationsPerIssue} operational continuations per issue.${smallHostNote} Change them?`,
|
|
575
598
|
);
|
|
576
599
|
if (!tuneCaps) {
|
|
577
600
|
if (
|
|
@@ -603,9 +626,14 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
603
626
|
);
|
|
604
627
|
caps.maxAttemptsPerIssue = await askNumber(
|
|
605
628
|
ctx,
|
|
606
|
-
"
|
|
629
|
+
"Failed implementation attempts per issue before escalation",
|
|
607
630
|
caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
|
|
608
631
|
);
|
|
632
|
+
caps.maxContinuationsPerIssue = await askNumber(
|
|
633
|
+
ctx,
|
|
634
|
+
"Operational continuations per issue before escalation",
|
|
635
|
+
caps.maxContinuationsPerIssue ?? DEFAULT_CAPS.maxContinuationsPerIssue,
|
|
636
|
+
);
|
|
609
637
|
return { ...a, caps };
|
|
610
638
|
};
|
|
611
639
|
|
|
@@ -621,7 +649,11 @@ const askWorkerModel: AreaAsker = async (ctx, a) => {
|
|
|
621
649
|
|
|
622
650
|
/** Both grants, asked together because they are the two questions that decide
|
|
623
651
|
* what an unattended fleet may do without asking anybody. */
|
|
624
|
-
const askAuthorityArea: AreaAsker = async (ctx, a) => ({
|
|
652
|
+
const askAuthorityArea: AreaAsker = async (ctx, a) => ({
|
|
653
|
+
...a,
|
|
654
|
+
authority: await askAuthority(ctx, a.authority),
|
|
655
|
+
releasePolicy: await askReleasePolicy(ctx, a.releasePolicy),
|
|
656
|
+
});
|
|
625
657
|
|
|
626
658
|
/** How a stuck run reaches a human, and who triages it when it does. */
|
|
627
659
|
const askEscalation: AreaAsker = async (ctx, a) => {
|
|
@@ -795,20 +827,6 @@ function formatPreview(p: QueuePreview): string[] {
|
|
|
795
827
|
return lines;
|
|
796
828
|
}
|
|
797
829
|
|
|
798
|
-
/**
|
|
799
|
-
* The dry run needs a saved config to read, and before the confirm there may
|
|
800
|
-
* not be one — that is the normal first run, not a failure. So the reason is
|
|
801
|
-
* reported inline and the wizard carries on; the post-write preview is the one
|
|
802
|
-
* that always has something to say.
|
|
803
|
-
*/
|
|
804
|
-
async function tryPreview(project: string): Promise<string[]> {
|
|
805
|
-
try {
|
|
806
|
-
return formatPreview(await previewQueue(project));
|
|
807
|
-
} catch (err) {
|
|
808
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
809
|
-
return [` (not available yet: ${message.split("\n")[0] ?? message})`];
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
830
|
|
|
813
831
|
/** What the whole conversation produced: the answers, and which area an amend
|
|
814
832
|
* narrowed it to. `amend` absent means every question was asked. */
|
|
@@ -817,6 +835,17 @@ export interface CollectedSetup {
|
|
|
817
835
|
amend?: { area: AmendAreaId; before: ProjectConfig };
|
|
818
836
|
}
|
|
819
837
|
|
|
838
|
+
|
|
839
|
+
export async function ensureSetupArm(
|
|
840
|
+
projectName: string,
|
|
841
|
+
arm: typeof armTicks = armTicks,
|
|
842
|
+
): Promise<string> {
|
|
843
|
+
const armed = await arm(projectName);
|
|
844
|
+
return armed.alreadyArmed
|
|
845
|
+
? `existing heartbeat arm revalidated for owner ${armed.owner} at ${armed.path}`
|
|
846
|
+
: `heartbeat armed for owner ${armed.owner} at ${armed.path}`;
|
|
847
|
+
}
|
|
848
|
+
|
|
820
849
|
/**
|
|
821
850
|
* The whole conversation, from the amend question to the last prompt, and not one
|
|
822
851
|
* byte further: no `gh`, no dry run, nothing written.
|
|
@@ -845,11 +874,10 @@ export async function collectSetup(
|
|
|
845
874
|
/**
|
|
846
875
|
* The onboarding wizard, and — for a project it already knows — the amend.
|
|
847
876
|
*
|
|
848
|
-
* The invariant that makes this safe
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
* `writeOrchestratorBrief`, `armConductor` — all live after it. Keep it that way.
|
|
877
|
+
* The invariant that makes this safe against a live tracker: no mutation occurs
|
|
878
|
+
* before the consent below. Config, tracker, and host-runtime planning are
|
|
879
|
+
* read-only. The paused state, labels, config, brief, runtime files, smoke, and
|
|
880
|
+
* arm proof all follow the same consent gate.
|
|
853
881
|
*
|
|
854
882
|
* An amend changes which questions are asked and what the summary leads with,
|
|
855
883
|
* and nothing else: the same answers, the same `buildConfig`, the same single
|
|
@@ -876,8 +904,46 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
876
904
|
const { answers, amend } = collected;
|
|
877
905
|
|
|
878
906
|
const scopes = await checkTokenScopes();
|
|
907
|
+
if (!scopes.ok) {
|
|
908
|
+
ctx.ui.notify(
|
|
909
|
+
`Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
|
|
910
|
+
`Run \`gh auth refresh -s repo,project\`, then run setup again.`,
|
|
911
|
+
"error",
|
|
912
|
+
);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
879
915
|
const labels = await planLabels(answers.trackerRepo, answers);
|
|
880
916
|
const telegram = detectTelegram();
|
|
917
|
+
const nextConfig = buildConfig(answers, existing);
|
|
918
|
+
const project = findProject(nextConfig, answers.projectName);
|
|
919
|
+
if (
|
|
920
|
+
project.escalation.orchestrator === "external" &&
|
|
921
|
+
!answers.writeOrchestratorBrief &&
|
|
922
|
+
(!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
|
|
923
|
+
) {
|
|
924
|
+
ctx.ui.notify(
|
|
925
|
+
`Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` +
|
|
926
|
+
`Run setup again and approve the brief write.`,
|
|
927
|
+
"error",
|
|
928
|
+
);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const runtime = planHostRuntime(
|
|
932
|
+
project,
|
|
933
|
+
resolveCaps(project, nextConfig.defaults),
|
|
934
|
+
telegram.stateDir,
|
|
935
|
+
);
|
|
936
|
+
let queuePreview: string[];
|
|
937
|
+
try {
|
|
938
|
+
queuePreview = formatPreview(await previewProject(project));
|
|
939
|
+
} catch (err) {
|
|
940
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
941
|
+
ctx.ui.notify(
|
|
942
|
+
`Setup stopped before writing anything because the proposed queue could not be read: ${message}`,
|
|
943
|
+
"error",
|
|
944
|
+
);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
881
947
|
|
|
882
948
|
ctx.ui.notify(
|
|
883
949
|
[
|
|
@@ -886,10 +952,10 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
886
952
|
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers)]),
|
|
887
953
|
summarisePlan(answers, scopes, labels, telegram),
|
|
888
954
|
"",
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
...
|
|
955
|
+
formatHostRuntimePlan(runtime),
|
|
956
|
+
"",
|
|
957
|
+
"Dry run against the PROPOSED config:",
|
|
958
|
+
...queuePreview,
|
|
893
959
|
"",
|
|
894
960
|
"Nothing has been changed yet.",
|
|
895
961
|
].join("\n"),
|
|
@@ -903,14 +969,22 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
903
969
|
toCreate.length > 0
|
|
904
970
|
? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`
|
|
905
971
|
: "Creates no labels.",
|
|
906
|
-
`Writes ${path},
|
|
907
|
-
|
|
908
|
-
? `
|
|
909
|
-
: ""
|
|
972
|
+
`Writes ${path}, prepares a paused state database, then runs a paused daemon smoke.`,
|
|
973
|
+
runtime.service.action === "keep"
|
|
974
|
+
? `Keeps the staged systemd unit at ${runtime.service.path}.`
|
|
975
|
+
: `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
|
|
976
|
+
runtime.tick === undefined
|
|
977
|
+
? ""
|
|
978
|
+
: runtime.tick.action === "keep"
|
|
979
|
+
? `Keeps the external heartbeat config at ${runtime.tick.path}.`
|
|
980
|
+
: `${runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${runtime.tick.path}.`,
|
|
910
981
|
answers.writeOrchestratorBrief
|
|
911
982
|
? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
|
|
912
983
|
: "",
|
|
913
|
-
|
|
984
|
+
project.escalation.orchestrator === "external"
|
|
985
|
+
? "Dispatch stays paused until the existing arm marker or a new inbound Telegram proof makes the heartbeat live."
|
|
986
|
+
: "Dispatch resumes after the smoke succeeds.",
|
|
987
|
+
"Issues are only claimed after every setup gate succeeds.",
|
|
914
988
|
]
|
|
915
989
|
.filter((s) => s.length > 0)
|
|
916
990
|
.join(" "),
|
|
@@ -920,25 +994,79 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
920
994
|
return;
|
|
921
995
|
}
|
|
922
996
|
|
|
923
|
-
//
|
|
924
|
-
//
|
|
997
|
+
// Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
|
|
998
|
+
// partially applied setup unable to claim work.
|
|
999
|
+
prepareConductor();
|
|
925
1000
|
const created = await createMissingLabels(answers.trackerRepo, labels);
|
|
926
|
-
saveConfig(
|
|
1001
|
+
saveConfig(nextConfig);
|
|
927
1002
|
const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers) : undefined;
|
|
928
|
-
|
|
1003
|
+
const runtimeFiles = writeHostRuntime(runtime);
|
|
1004
|
+
const smoke = await runSetupSmoke(project.name);
|
|
1005
|
+
let smokeLine =
|
|
1006
|
+
`paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
|
|
1007
|
+
`stored status for ${smoke.status.project}`;
|
|
1008
|
+
let restartVia: "systemctl" | "cli" | undefined;
|
|
1009
|
+
if (smoke.mode === "existing") {
|
|
1010
|
+
if (smoke.status.liveWorkers > 0) {
|
|
1011
|
+
ctx.ui.notify(
|
|
1012
|
+
[
|
|
1013
|
+
`Setup files are updated, but ${smoke.status.liveWorkers} live worker(s) still use the old daemon config.`,
|
|
1014
|
+
"Dispatch remains paused. Let those workers finish.",
|
|
1015
|
+
`Then run \`omp-conductor restart --project ${project.name}\`.`,
|
|
1016
|
+
project.escalation.orchestrator === "external"
|
|
1017
|
+
? `Run \`omp-conductor arm --project ${project.name}\` if ticks are disarmed, then run \`omp-conductor resume\`.`
|
|
1018
|
+
: "Then run `omp-conductor resume`.",
|
|
1019
|
+
].join("\n"),
|
|
1020
|
+
"warning",
|
|
1021
|
+
);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const restarted = await restartDaemon({ project: project.name });
|
|
1025
|
+
restartVia = restarted.via;
|
|
1026
|
+
smokeLine =
|
|
1027
|
+
`existing /healthz and stored status; restarted through ${restarted.via}; ` +
|
|
1028
|
+
`new /healthz on :${restarted.record.port}`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
let armLine = "embedded orchestrator — no heartbeat arm marker";
|
|
1032
|
+
if (project.escalation.orchestrator === "external") {
|
|
1033
|
+
ctx.ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
|
|
1034
|
+
try {
|
|
1035
|
+
armLine = await ensureSetupArm(project.name);
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
ctx.ui.notify(
|
|
1038
|
+
[
|
|
1039
|
+
"Setup files passed the paused daemon smoke, but the fleet remains held.",
|
|
1040
|
+
err instanceof Error ? err.message : String(err),
|
|
1041
|
+
`Start the external orchestrator in ${project.workspaceRoot}, then run \`omp-conductor arm --project ${project.name}\`.`,
|
|
1042
|
+
"After the arm proof succeeds, run `omp-conductor resume`.",
|
|
1043
|
+
].join("\n"),
|
|
1044
|
+
"warning",
|
|
1045
|
+
);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
setPaused(false);
|
|
929
1050
|
|
|
930
1051
|
ctx.ui.notify(
|
|
931
1052
|
[
|
|
932
|
-
created.length > 0 ? `Created label(s): ${created.join(", ")}` : "
|
|
933
|
-
`Wrote ${path}
|
|
1053
|
+
created.length > 0 ? `Created label(s): ${created.join(", ")}` : "All required labels already existed.",
|
|
1054
|
+
`Wrote ${path}; dispatch is ready.`,
|
|
934
1055
|
briefPath === undefined
|
|
935
|
-
? "
|
|
936
|
-
: `Wrote ${briefPath} + POLICY.md
|
|
1056
|
+
? "Kept the existing orchestrator brief."
|
|
1057
|
+
: `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`,
|
|
1058
|
+
runtimeFiles.length === 0
|
|
1059
|
+
? "Host runtime files were already current."
|
|
1060
|
+
: `Wrote host runtime file(s): ${runtimeFiles.join(", ")}`,
|
|
1061
|
+
`Smoke passed: ${smokeLine}.`,
|
|
1062
|
+
`Heartbeat: ${armLine}.`,
|
|
937
1063
|
"",
|
|
938
|
-
"
|
|
939
|
-
...(
|
|
1064
|
+
"On a systemd host, install and start the supervised daemon:",
|
|
1065
|
+
...(restartVia === "cli" ? [" omp-conductor stop"] : []),
|
|
1066
|
+
...runtime.installCommands.map((command) => ` ${command}`),
|
|
940
1067
|
"",
|
|
941
|
-
"
|
|
1068
|
+
"Without systemd, run `omp-conductor start`.",
|
|
1069
|
+
"Use the documented toy-issue drill to prove one complete worker path.",
|
|
942
1070
|
].join("\n"),
|
|
943
1071
|
"info",
|
|
944
1072
|
);
|