omp-conductor 0.7.1 → 0.8.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/decisions.ts CHANGED
@@ -17,7 +17,7 @@ import type { DecisionRecord, Store, Tracker } from "./types.ts";
17
17
  /**
18
18
  * A precondition whose truth this package can check on its own.
19
19
  *
20
- * Exactly three kinds, deliberately. Each one is a question the tracker or npm
20
+ * Exactly six kinds, deliberately. Each one is a question the tracker or npm
21
21
  * already answers, so the row moves from "parked" to "act on this" without a
22
22
  * human re-reading it. Anything richer — a label appearing, a workflow going
23
23
  * green — is a follow-on issue rather than a grammar nobody validated.
@@ -25,11 +25,22 @@ import type { DecisionRecord, Store, Tracker } from "./types.ts";
25
25
  export type DecisionCondition =
26
26
  | { kind: "pr-merged"; url: string }
27
27
  | { kind: "issue-closed"; issue: number }
28
- | { kind: "npm-version"; spec: string };
28
+ | { kind: "npm-version"; spec: string }
29
+ | { kind: "pr-checks-green"; url: string }
30
+ | { kind: "pr-mergeable"; url: string }
31
+ | { kind: "rate-limit-reset" };
29
32
 
30
33
  /** `pkg@version`, including a scoped package (`@scope/pkg@1.2.3`). */
31
34
  const NPM_SPEC = /^(@[^/@\s]+\/)?[^@\s]+@[^\s]+$/;
32
35
 
36
+ /**
37
+ * Check states that count as a green verdict for `pr-checks-green`, copied from
38
+ * the daemon's failure classifier (`failure-class.ts` `SUCCESS_CHECK_STATES`):
39
+ * both spellings are terminally successful, and anything else — a failure, a
40
+ * still-running/pending check, a cancelled or skipped runner — is not.
41
+ */
42
+ const GREEN_CHECK_STATES: Record<string, true> = { success: true, neutral: true };
43
+
33
44
  /**
34
45
  * Parse a raw condition, or `undefined` when it is not one of the three forms.
35
46
  *
@@ -46,8 +57,8 @@ export function parseCondition(raw: string): DecisionCondition | undefined {
46
57
  const rest = text.slice(at + 1).trim();
47
58
  if (rest.length === 0) return undefined;
48
59
 
49
- if (kind === "pr-merged") {
50
- return rest.startsWith("https://") ? { kind: "pr-merged", url: rest } : undefined;
60
+ if (kind === "pr-merged" || kind === "pr-checks-green" || kind === "pr-mergeable") {
61
+ return rest.startsWith("https://") ? { kind, url: rest } : undefined;
51
62
  }
52
63
  if (kind === "issue-closed") {
53
64
  if (!/^\d+$/.test(rest)) return undefined;
@@ -57,14 +68,22 @@ export function parseCondition(raw: string): DecisionCondition | undefined {
57
68
  if (kind === "npm-version") {
58
69
  return NPM_SPEC.test(rest) ? { kind: "npm-version", spec: rest } : undefined;
59
70
  }
71
+ if (kind === "rate-limit-reset") {
72
+ // Spelled `rate-limit-reset:github`: the grammar requires a non-empty rest,
73
+ // and `github` names the one GraphQL provider the fleet is on.
74
+ return rest === "github" ? { kind: "rate-limit-reset" } : undefined;
75
+ }
60
76
  return undefined;
61
77
  }
62
78
 
63
- /** The three accepted forms, for a refusal that can be acted on in one turn. */
79
+ /** The six accepted forms, for a refusal that can be acted on in one turn. */
64
80
  export const CONDITION_FORMS = [
65
81
  "pr-merged:https://github.com/owner/repo/pull/123",
66
82
  "issue-closed:123",
67
83
  "npm-version:omp-conductor@0.4.3",
84
+ "pr-checks-green:https://github.com/owner/repo/pull/123",
85
+ "pr-mergeable:https://github.com/owner/repo/pull/123",
86
+ "rate-limit-reset:github",
68
87
  ] as const;
69
88
 
70
89
  /** Probe for `npm-version`, injectable so tests never reach the network. */
@@ -90,6 +109,33 @@ export const probeNpmVersion: NpmProbe = async (spec) => {
90
109
  }
91
110
  };
92
111
 
112
+ /** Probe for `rate-limit-reset`, injectable so tests never reach the network. */
113
+ export type RateLimitProbe = () => Promise<boolean>;
114
+
115
+ /**
116
+ * `gh api rate_limit` — any remaining GraphQL quota means the limit has reset
117
+ * (or never bound). Bounded at 10 s like {@link probeNpmVersion}, for the same
118
+ * reason: this runs inside a tick, and a hung `gh` must cost one unevaluated
119
+ * decision, not the tick.
120
+ */
121
+ export const probeRateLimitReset: RateLimitProbe = async () => {
122
+ const proc = Bun.spawn(
123
+ ["gh", "api", "rate_limit", "--jq", ".resources.graphql.remaining"],
124
+ { stdout: "pipe", stderr: "ignore" },
125
+ );
126
+ const timer = setTimeout(() => {
127
+ proc.kill();
128
+ }, 10_000);
129
+ try {
130
+ const [text, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
131
+ return code === 0 && Number.parseInt(text.trim(), 10) > 0;
132
+ } catch {
133
+ return false;
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+ };
138
+
93
139
  /**
94
140
  * Check every open decision that carries a condition and has not met it yet.
95
141
  *
@@ -105,7 +151,7 @@ export async function evaluateDecisionConditions(
105
151
  store: Store,
106
152
  project: string,
107
153
  tracker: Tracker,
108
- probeNpm: NpmProbe,
154
+ probes: { npm: NpmProbe; rateLimit: RateLimitProbe },
109
155
  now: () => number,
110
156
  ): Promise<DecisionRecord[]> {
111
157
  const met: DecisionRecord[] = [];
@@ -119,8 +165,22 @@ export async function evaluateDecisionConditions(
119
165
  satisfied = (await tracker.prState(condition.url)) === "merged";
120
166
  } else if (condition.kind === "issue-closed") {
121
167
  satisfied = (await tracker.issueState(condition.issue)) === "closed";
168
+ } else if (condition.kind === "npm-version") {
169
+ satisfied = await probes.npm(condition.spec);
170
+ } else if (condition.kind === "pr-checks-green") {
171
+ // The same conclusion values the daemon's failure classifier treats as
172
+ // a green verdict (`success` / `neutral`, lowercased): a non-empty list
173
+ // in which every check is terminally successful and none is failing or
174
+ // pending (#189).
175
+ const checks = await tracker.checkConclusions(condition.url);
176
+ satisfied =
177
+ checks.length > 0 && checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true);
178
+ } else if (condition.kind === "pr-mergeable") {
179
+ // `clean` is the tracker's "this PR can merge" literal; `unknown` and a
180
+ // conflict are both unsatisfied (#189).
181
+ satisfied = (await tracker.mergeable(condition.url)) === "clean";
122
182
  } else {
123
- satisfied = await probeNpm(condition.spec);
183
+ satisfied = await probes.rateLimit();
124
184
  }
125
185
  } catch {
126
186
  continue;
package/src/fleet.ts CHANGED
@@ -42,6 +42,8 @@ import {
42
42
  formatReleaseGrants,
43
43
  formatSalvagedRuns,
44
44
  isPaused,
45
+ pauseProvenance,
46
+ pausedAt,
45
47
  setPaused,
46
48
  statusSnapshot,
47
49
  type StatusSnapshot,
@@ -55,6 +57,7 @@ import {
55
57
  SYSTEMD_UNIT,
56
58
  } from "./lifecycle.ts";
57
59
  import { formatRss, rssBytesFromHealthz } from "./host.ts";
60
+ import { fetchRateLimit } from "./tracker/github.ts";
58
61
  import {
59
62
  readTickConfig,
60
63
  readTickRuntimeStatus,
@@ -252,9 +255,9 @@ export interface HaltWithPaneResult extends HaltResult {
252
255
  pane: PaneStopResult;
253
256
  }
254
257
 
255
- export function hold(projectName?: string): HoldResult {
258
+ export function hold(projectName?: string, source: string = "hold"): HoldResult {
256
259
  const wasPaused = isPaused();
257
- setPaused(true);
260
+ setPaused(true, { source });
258
261
  return { wasPaused, disarmed: disarmTicks(projectName) };
259
262
  }
260
263
 
@@ -263,7 +266,7 @@ export function releaseHold(): void {
263
266
  }
264
267
 
265
268
  export async function halt(projectName?: string): Promise<HaltResult> {
266
- const held = hold(projectName);
269
+ const held = hold(projectName, "halt");
267
270
  const stop = await stopDaemon();
268
271
  return { hold: held, stop };
269
272
  }
@@ -1019,8 +1022,27 @@ export function formatFleetStatus(
1019
1022
 
1020
1023
  const graphBlock = formatCodeGraphHealth(codeGraph, now);
1021
1024
 
1025
+ // Pause provenance (`pause --reason`, an integrity/spend-cap/upgrade pause,
1026
+ // a halt) answers "who stopped the fleet" without opening a file (#185). An
1027
+ // unparseable sentinel — paused but with no datable line 1 — is itself news:
1028
+ // it means a run admitted before an *unknown* pause cannot prove innocence
1029
+ // (#174), so every mutation is refused.
1030
+ const dispatchLine =
1031
+ layers.dispatch === "paused"
1032
+ ? (() => {
1033
+ const prov = pauseProvenance();
1034
+ if (prov !== undefined) {
1035
+ const reason = prov.reason === undefined ? "" : ` — "${prov.reason}"`;
1036
+ return `dispatch paused (source: ${prov.source}${reason})`;
1037
+ }
1038
+ return isPaused() && pausedAt() === undefined
1039
+ ? "dispatch paused (unparseable sentinel — all mutations refused)"
1040
+ : "dispatch paused";
1041
+ })()
1042
+ : `dispatch ${layers.dispatch}`;
1043
+
1022
1044
  return [
1023
- `dispatch ${layers.dispatch}`,
1045
+ dispatchLine,
1024
1046
  tickLine,
1025
1047
  ...(nextTickLine === undefined ? [] : [nextTickLine]),
1026
1048
  paneLine,
@@ -1053,6 +1075,13 @@ function formatProjectBody(s: StatusSnapshot): string {
1053
1075
  // independent controls and an operator has to see which one stopped the
1054
1076
  // fleet (#110).
1055
1077
  ` plan usage ${planUsageLine(s.planUsage)}`,
1078
+ // The tracker's API budget, when the renderer could read it. Absent on a
1079
+ // broken `gh`: one missing row, never a broken report (#188).
1080
+ ...(s.github === undefined
1081
+ ? []
1082
+ : [
1083
+ ` github graphql ${s.github.graphql.remaining}/${s.github.graphql.limit}, core ${s.github.core.remaining}/${s.github.core.limit} (graphql resets ${Math.max(0, Math.round((s.github.graphql.reset * 1000 - Date.now()) / 60_000))}m)`,
1084
+ ]),
1056
1085
  ` new worker turns ${s.caps.workerMaxTurns}`,
1057
1086
  ` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
1058
1087
  ` failed attempts ${s.caps.maxAttemptsPerIssue}`,
@@ -1097,18 +1126,22 @@ export async function renderStatus(projectName?: string): Promise<string> {
1097
1126
  const layers = fleetLayers(projectName);
1098
1127
  const project = findProject(loadConfig(), projectName);
1099
1128
  const rec = livingDaemon();
1100
- const [health, telegram, planUsage] = await Promise.all([
1129
+ const [health, telegram, planUsage, github] = await Promise.all([
1101
1130
  rec === undefined ? undefined : healthCheck(rec.port),
1102
1131
  probeTelegramHealth(projectName),
1103
1132
  // Read here rather than in `statusSnapshot`, which is synchronous and used
1104
1133
  // by callers that must not shell out. An unmetered project never spawns
1105
1134
  // the provider at all.
1106
1135
  readPlanUsage(s.caps.planUsage, sharedUsageSource()),
1136
+ // Same reasoning as `planUsage`: the snapshot is synchronous, this read is
1137
+ // a shell-out, and undefined on any failure — one missing row, never a
1138
+ // broken report (#188).
1139
+ fetchRateLimit(),
1107
1140
  ]);
1108
1141
  const cached = codeGraphFromHealthz(health?.body, project.name);
1109
1142
  const codeGraph = cached ?? (await probeCodeGraph(project));
1110
1143
  return formatFleetStatus(
1111
- { ...s, planUsage },
1144
+ { ...s, planUsage, github },
1112
1145
  layers,
1113
1146
  health,
1114
1147
  telegram,
package/src/lifecycle.ts CHANGED
@@ -292,6 +292,13 @@ export type StopResult =
292
292
  | { kind: "stopped"; pid: number; via: "systemctl" | "signal" }
293
293
  | { kind: "not-running" };
294
294
 
295
+ /** What {@link restartDaemon} returns: the pre-restart record, the new one, and the path taken. */
296
+ export interface RestartResult {
297
+ previous: DaemonRecord | undefined;
298
+ record: DaemonRecord;
299
+ via: "systemctl" | "cli";
300
+ }
301
+
295
302
  /**
296
303
  * Stops the daemon.
297
304
  *
@@ -365,7 +372,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
365
372
  */
366
373
  export async function restartDaemon(
367
374
  o: { port?: number; project?: string; timeoutMs?: number } = {},
368
- ): Promise<{ previous: DaemonRecord | undefined; record: DaemonRecord; via: "systemctl" | "cli" }> {
375
+ ): Promise<RestartResult> {
369
376
  const previous = livingDaemon();
370
377
  const ownership = probeUnit();
371
378
  if (ownership.kind === "unknown") {
@@ -70,6 +70,7 @@ import {
70
70
  import {
71
71
  DEFAULT_REPORT_SCOPE,
72
72
  DENIED_RELEASE_GRANTS,
73
+ type DispatchSummary,
73
74
  type FrictionSignal,
74
75
  type ReportScope,
75
76
  type ResolvedGrants,
@@ -132,6 +133,13 @@ const RETRY_OWNERSHIP_MS = 60_000;
132
133
  export const STALL_MARKER_FILE = ".conductor-stalled";
133
134
  export const STALL_TICKS = 2;
134
135
 
136
+ /** A turn older than this has its remaining tool calls refused (#189). */
137
+ const DEFAULT_TICK_BUDGET_SECONDS = 600;
138
+ /** Grace before a queued operator message preempts the turn's tool calls. */
139
+ const PENDING_MESSAGE_GRACE_MS = 60_000;
140
+ /** Routable candidates below which the queue digest tells the orchestrator to groom (#181). */
141
+ const DEFAULT_GROOM_BELOW = 4;
142
+
135
143
  /**
136
144
  * Written by herdr-conductor `recover.sh` *before* `agent start`, so a resumed
137
145
  * fleet can reconcile orphans without waiting a full `intervalSeconds`. Cleared
@@ -244,6 +252,8 @@ interface TickApi {
244
252
  */
245
253
  getActiveTools(): string[];
246
254
  on(event: "session_start", handler: (event: { type: "session_start" }, ctx: TickContext) => void): void;
255
+ on(event: "turn_start", handler: (event: { type: "turn_start" }, ctx: TickContext) => void): void;
256
+ on(event: "turn_end", handler: (event: { type: "turn_end" }, ctx: TickContext) => void): void;
247
257
  /**
248
258
  * `deliverAs: "followUp"` + `triggerTurn: true`, verified against
249
259
  * `AgentSession.sendCustomMessage` rather than assumed:
@@ -276,6 +286,13 @@ export interface TickConfig {
276
286
  armedFile?: string;
277
287
  accessFile?: string;
278
288
  message?: string;
289
+ /**
290
+ * Seconds a turn may run before the tick guard refuses its remaining tool
291
+ * calls (#189). Optional; defaults to {@link DEFAULT_TICK_BUDGET_SECONDS} (10
292
+ * minutes, at or below a normal tick interval so a runaway turn is caught
293
+ * before the next tick).
294
+ */
295
+ budgetSeconds?: number;
279
296
  /**
280
297
  * The herdr agent name this fleet's orchestrator pane is registered under, and
281
298
  * the whole of {@link resolveTickOwnership}'s identity test under herdr.
@@ -353,6 +370,32 @@ export function recoveryDigestLine(recovered: readonly RunRecord[]): string | un
353
370
  return `Auto-recovered since last tick: ${recovered.length} (${named}${rest}) — already handled, do not re-triage these.`;
354
371
  }
355
372
 
373
+ /**
374
+ * One line telling the orchestrator the routable queue is running dry (#181),
375
+ * or `undefined` when healthy — no dispatch recorded yet, or the routable count
376
+ * is at/above the grooming trigger.
377
+ */
378
+ export function queueDigestLine(
379
+ summary: DispatchSummary | undefined,
380
+ queueLabel: string,
381
+ labelPrefix: string,
382
+ groomBelow: number,
383
+ ): string | undefined {
384
+ if (summary === undefined) return undefined;
385
+ if (summary.ready === 0) {
386
+ return `Queue: empty — nothing carries "${queueLabel}". Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report why there is nothing to do.`;
387
+ }
388
+ if (summary.routed === 0) {
389
+ return `Queue: ${summary.ready} ready but 0 routable — each needs exactly one "${labelPrefix}<repo>" label before dispatch can ever see it.`;
390
+ }
391
+ if (summary.routed >= groomBelow) return undefined;
392
+ let line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
393
+ if (summary.admitted === 0 && summary.holds.length > 0) {
394
+ line += ` All held: ${summary.holds.map((h) => `${h.reason} ${h.count}`).join(", ")}.`;
395
+ }
396
+ return line;
397
+ }
398
+
356
399
  export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
357
400
  material: "Report material events per your brief.",
358
401
  escalations:
@@ -614,6 +657,15 @@ export function readTickConfig(cwd: string): TickConfigResult {
614
657
  intervalSeconds = interval;
615
658
  }
616
659
 
660
+ // Tolerant like the other optional fields: an integer >= the minimum is the
661
+ // turn budget in seconds, anything else degrades to absent (the shipped
662
+ // default) rather than invalidating the config.
663
+ const budgetRaw = raw["budgetSeconds"];
664
+ const budgetSeconds =
665
+ typeof budgetRaw === "number" && Number.isInteger(budgetRaw) && budgetRaw >= MIN_INTERVAL_SECONDS
666
+ ? budgetRaw
667
+ : undefined;
668
+
617
669
  // Relative paths resolve against the session cwd, so the files can sit beside
618
670
  // the config that names them (`state/armed`) without hard-coding a deploy path.
619
671
  const armedFile = optionalPath(raw["armedFile"], "armedFile", cwd, problems);
@@ -646,6 +698,7 @@ export function readTickConfig(cwd: string): TickConfigResult {
646
698
  path,
647
699
  config: {
648
700
  intervalSeconds,
701
+ ...(budgetSeconds === undefined ? {} : { budgetSeconds }),
649
702
  ...(armedFile === undefined ? {} : { armedFile }),
650
703
  ...(accessFile === undefined ? {} : { accessFile }),
651
704
  ...(message === undefined ? {} : { message }),
@@ -1012,6 +1065,41 @@ export function tickDecision(input: {
1012
1065
  return { send: true, reason: "armed, nothing pending" };
1013
1066
  }
1014
1067
 
1068
+ /**
1069
+ * The tick guard's decision, extracted pure from {@link armTickGuard} so it can
1070
+ * be pinned without a fake host: whether a turn's remaining tool calls should be
1071
+ * refused. Budget first, then a queued operator message; otherwise nothing is
1072
+ * blocked (#189).
1073
+ */
1074
+ export function tickGuardDecision(input: {
1075
+ turnStartedAt: number | undefined;
1076
+ now: number;
1077
+ budgetMs: number;
1078
+ hasPending: boolean;
1079
+ }): { block: true; reason: string } | undefined {
1080
+ if (input.turnStartedAt === undefined) return undefined;
1081
+ const elapsed = input.now - input.turnStartedAt;
1082
+ if (elapsed > input.budgetMs) {
1083
+ return {
1084
+ block: true,
1085
+ reason:
1086
+ `Blocked: this turn has run ${Math.round(elapsed / 1000)}s, past the ${Math.round(input.budgetMs / 1000)}s tick budget (#189). ` +
1087
+ `Finish the turn NOW with a short report. Park anything you were waiting on as a watch — ` +
1088
+ `omp-conductor decision open --question "..." --resolves-when pr-checks-green:<url> | pr-mergeable:<url> | pr-merged:<url> | rate-limit-reset:github — ` +
1089
+ `or hand it to a subagent; the daemon flags met conditions in your next tick digest.`,
1090
+ };
1091
+ }
1092
+ if (input.hasPending && elapsed > PENDING_MESSAGE_GRACE_MS) {
1093
+ return {
1094
+ block: true,
1095
+ reason:
1096
+ `Blocked: an operator message is queued behind this turn (#189). End the turn now and answer it — the person is waiting. ` +
1097
+ `Park any wait as a decision watch (--resolves-when) instead of polling.`,
1098
+ };
1099
+ }
1100
+ return undefined;
1101
+ }
1102
+
1015
1103
  /**
1016
1104
  * Whether the Telegram bridge can still reach a person: a bot token, enabled,
1017
1105
  * with exactly one paired owner.
@@ -1269,6 +1357,22 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1269
1357
  frictionStore.recoveredSince(scope.projectName, now - 2 * config.intervalSeconds * 1_000),
1270
1358
  );
1271
1359
  if (recovered !== undefined) content = `${content}\n${recovered}`;
1360
+ // The dispatch pass already persists the numbers that answer "is the
1361
+ // queue running dry", so this reads the store rather than asking the
1362
+ // tracker (#181). A failed config read skips the line rather than
1363
+ // wedging the tick.
1364
+ try {
1365
+ const project = findProject(loadConfig());
1366
+ const queue = queueDigestLine(
1367
+ frictionStore.latestDispatch(scope.projectName),
1368
+ project.queueLabel,
1369
+ project.routing.labelPrefix,
1370
+ project.groomBelow ?? DEFAULT_GROOM_BELOW,
1371
+ );
1372
+ if (queue !== undefined) content = `${content}\n${queue}`;
1373
+ } catch {
1374
+ // unreadable config: no queue digest this tick
1375
+ }
1272
1376
  } catch (err) {
1273
1377
  frictionStore?.close();
1274
1378
  frictionStore = undefined;
@@ -1409,6 +1513,43 @@ function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, ses
1409
1513
  tick(pi, ctx, config, session);
1410
1514
  }
1411
1515
 
1516
+ /**
1517
+ * The mechanical guard against a turn that never ends (#189).
1518
+ *
1519
+ * The harness delivers `turn_start`/`turn_end`, so the guard times a turn from
1520
+ * the first to the last tool-eligible phase and refuses further tool calls once
1521
+ * it exceeds `budgetMs` — or once an operator message has been queued behind it
1522
+ * for longer than {@link PENDING_MESSAGE_GRACE_MS}. Refusing every tool forces
1523
+ * the model to end the turn with text; nothing else unblocks the queue. `yield`
1524
+ * stays callable so a session that is also a task can still return.
1525
+ */
1526
+ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
1527
+ let turnStartedAt: number | undefined;
1528
+ pi.on("turn_start", () => {
1529
+ turnStartedAt = Date.now();
1530
+ });
1531
+ pi.on("turn_end", () => {
1532
+ turnStartedAt = undefined;
1533
+ });
1534
+ (pi as TickApi & {
1535
+ on(
1536
+ event: "tool_call",
1537
+ handler: (
1538
+ event: { toolName: string; input: Record<string, unknown> },
1539
+ ctx: unknown,
1540
+ ) => { block: true; reason: string } | undefined,
1541
+ ): void;
1542
+ }).on("tool_call", (event) => {
1543
+ if (event.toolName === "yield") return undefined;
1544
+ return tickGuardDecision({
1545
+ turnStartedAt,
1546
+ now: Date.now(),
1547
+ budgetMs,
1548
+ hasPending: ctx.hasPendingMessages(),
1549
+ });
1550
+ });
1551
+ }
1552
+
1412
1553
  export default function orchestratorTickExtension(pi: TickApi): void {
1413
1554
  // Scoped to this registration rather than the module, so a second
1414
1555
  // `session_start` can neither install a second heartbeat on the same session
@@ -1426,6 +1567,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1426
1567
  pendingSkips: 0,
1427
1568
  };
1428
1569
  let releaseGateArmed = false;
1570
+ let guardArmed = false;
1429
1571
  // An activation file makes this a fleet directory before Herdr can prove
1430
1572
  // which pane owns it. The gate therefore starts closed and only honours a
1431
1573
  // configured grant after ownership is accepted.
@@ -1594,6 +1736,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1594
1736
  if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
1595
1737
  releaseAuthorityAccepted = true;
1596
1738
  armTickHeartbeat(pi, ctx, config, session);
1739
+ if (!guardArmed) {
1740
+ guardArmed = true;
1741
+ armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
1742
+ }
1597
1743
  pi.logger.info(`[omp-conductor] orchestrator tick active: ownership resolved on retry`, { agentName });
1598
1744
  }, retryMs);
1599
1745
  decided = true;
@@ -1608,6 +1754,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1608
1754
  if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
1609
1755
  releaseAuthorityAccepted = true;
1610
1756
  armTickHeartbeat(pi, ctx, config, session);
1757
+ if (!guardArmed) {
1758
+ guardArmed = true;
1759
+ armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
1760
+ }
1611
1761
  decided = true;
1612
1762
  // Both gates are named at startup: "why is it not ticking?" is answered by
1613
1763
  // looking at the files this line lists, and an unset channel gate on a fleet
package/src/plugin.ts CHANGED
@@ -1358,7 +1358,7 @@ export default function conductorPlugin(pi: PluginApi): void {
1358
1358
  }
1359
1359
 
1360
1360
  case "pause":
1361
- setPaused(true);
1361
+ setPaused(true, { source: "pause", reason: "via /conductor pause" });
1362
1362
  ctx.ui.notify("Paused claiming only — ticks keep firing if armed. Prefer /conductor hold.", "info");
1363
1363
  break;
1364
1364
 
package/src/setup.ts CHANGED
@@ -1167,7 +1167,7 @@ export const AMEND_AREAS: {
1167
1167
  const spend =
1168
1168
  c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
1169
1169
  return (
1170
- `${c.maxConcurrentWorkers} workers, ${c.workerMaxTurns} turns, ` +
1170
+ `${c.maxConcurrentWorkers} workers (${c.maxConcurrentWorkersPerRepo}/repo), ${c.workerMaxTurns} turns, ` +
1171
1171
  `${Math.round(c.workerWallClockMs / 60000)}m, ${spend}, ` +
1172
1172
  `${c.maxAttemptsPerIssue} failed attempt${c.maxAttemptsPerIssue === 1 ? "" : "s"}, ` +
1173
1173
  `${c.maxContinuationsPerIssue} continuation${c.maxContinuationsPerIssue === 1 ? "" : "s"}` +