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/README.md +16 -10
- package/package.json +1 -1
- package/src/briefs/orchestrator.md +13 -2
- package/src/cli.ts +73 -24
- package/src/config.ts +11 -0
- package/src/daemon.ts +73 -8
- package/src/decisions.ts +67 -7
- package/src/fleet.ts +39 -6
- package/src/lifecycle.ts +8 -1
- package/src/orchestrator-tick.ts +150 -0
- package/src/plugin.ts +1 -1
- package/src/setup.ts +1 -1
- package/src/tracker/github.ts +234 -73
- package/src/types.ts +10 -0
- package/src/unblock.ts +98 -13
- package/src/upgrade.ts +29 -3
- package/src/worktree.ts +23 -4
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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<
|
|
375
|
+
): Promise<RestartResult> {
|
|
369
376
|
const previous = livingDaemon();
|
|
370
377
|
const ownership = probeUnit();
|
|
371
378
|
if (ownership.kind === "unknown") {
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -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"}` +
|