omp-conductor 0.20.0 → 0.20.1
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/package.json +1 -1
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +54 -3
- package/src/briefs/console.md +10 -5
- package/src/commands/arm.ts +7 -5
- package/src/daemon/groom-pass.ts +16 -6
- package/src/daemon/runtime.ts +55 -3
- package/src/daemon/settle-pass.ts +19 -2
- package/src/daemon.ts +2 -2
- package/src/diff-flags.ts +111 -6
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +182 -1
- package/src/fleet.ts +13 -20
- package/src/orchestrator-tick.ts +296 -24
- package/src/settlement.ts +35 -5
- package/src/status-render.ts +11 -7
- package/src/store.ts +14 -2
- package/src/to-spec.ts +233 -24
- package/src/types.ts +18 -3
- package/src/worker.ts +149 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/admission.ts
CHANGED
|
@@ -270,7 +270,9 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
|
|
|
270
270
|
* optional bold/heading markers) whose rest carries the paths as
|
|
271
271
|
* backtick-delimited spans (the brief form), with a bare
|
|
272
272
|
* comma/space-separated fallback that keeps tokens that look like relative
|
|
273
|
-
* paths.
|
|
273
|
+
* paths. The paths are the *leading* backticked run: a comma/space-separated
|
|
274
|
+
* list at the line's start, and any backticked path later on the line is
|
|
275
|
+
* prose, not a write target (#1073).
|
|
274
276
|
* - The write-lane section (#825): a markdown heading "Exact write lane" (or
|
|
275
277
|
* "Write lane" / "write-lane") whose immediately following bullet items
|
|
276
278
|
* carry the paths — the package-floor decomposition format, where a groomed
|
|
@@ -278,7 +280,9 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
|
|
|
278
280
|
* the contiguous bullet run under that heading is read: a following
|
|
279
281
|
* paragraph (like a "Read only:" caveat) or a later section ends it, so
|
|
280
282
|
* read-only entry points, proof commands and acceptance bullets elsewhere in
|
|
281
|
-
* the issue are never captured.
|
|
283
|
+
* the issue are never captured. Each bullet contributes its leading
|
|
284
|
+
* backticked path run alone — a filename named in a bullet's prose (say, to
|
|
285
|
+
* note that it does not exist) is prose, never a lane entry (#1073).
|
|
282
286
|
*
|
|
283
287
|
* Inline wins when both are present — the machine-shaped sentence has held
|
|
284
288
|
* since #555, and the mediated label echo shows exactly which declaration
|
|
@@ -295,20 +299,59 @@ export function laneDeclaration(text: string): LaneDeclaration | undefined {
|
|
|
295
299
|
return inlineLaneDeclaration(text) ?? sectionLaneDeclaration(text);
|
|
296
300
|
}
|
|
297
301
|
|
|
302
|
+
/** One declared surface's paths (#1073). The canonical spelling is a leading
|
|
303
|
+
* run of backticked paths — a comma/space-separated list at the surface's
|
|
304
|
+
* start. A backticked path later in the same surface is prose, not part of
|
|
305
|
+
* the lane: a parenthetical like "(there is no `settle-pass.test.ts`)" is a
|
|
306
|
+
* sentence for the reader, and reading it as a write target makes a correct
|
|
307
|
+
* issue permanently refuse. A surface with no leading backticked path keeps
|
|
308
|
+
* the fallback: every backticked pathlike token, else bare
|
|
309
|
+
* comma/space-separated pathlike tokens (the unbackticked spelling). */
|
|
310
|
+
function writeLanePaths(text: string): string[] {
|
|
311
|
+
const fromRun = leadingBacktickedRun(text).filter(isPathLike);
|
|
312
|
+
if (fromRun.length > 0) return fromRun;
|
|
313
|
+
const backticked = [...text.matchAll(/`([^`]+)`/g)]
|
|
314
|
+
.map((m) => m[1]!.trim())
|
|
315
|
+
.filter(isPathLike);
|
|
316
|
+
return backticked.length > 0
|
|
317
|
+
? [...new Set(backticked)]
|
|
318
|
+
: [...new Set(text.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The leading backticked run of a surface (or bullet): consecutive
|
|
322
|
+
* backtick-delimited tokens separated by commas or whitespace, starting at
|
|
323
|
+
* the first character. A run is a *list*; the first non-backtick,
|
|
324
|
+
* non-separator character ends it, so "`a.ts`, `b.ts`" yields both while
|
|
325
|
+
* "`a.ts` — see `b.ts`" yields only `a.ts`. Empty when the surface does not
|
|
326
|
+
* open with a backtick. */
|
|
327
|
+
function leadingBacktickedRun(text: string): string[] {
|
|
328
|
+
const trimmed = text.trim();
|
|
329
|
+
if (!trimmed.startsWith("`")) return [];
|
|
330
|
+
const run: string[] = [];
|
|
331
|
+
let pos = 0;
|
|
332
|
+
while (true) {
|
|
333
|
+
if (trimmed[pos] !== "`") break;
|
|
334
|
+
const close = trimmed.indexOf("`", pos + 1);
|
|
335
|
+
if (close < 0) break;
|
|
336
|
+
const token = trimmed.slice(pos + 1, close).trim();
|
|
337
|
+
if (token === "") break;
|
|
338
|
+
run.push(token);
|
|
339
|
+
pos = close + 1;
|
|
340
|
+
const sep = /^[,\s]+/.exec(trimmed.slice(pos));
|
|
341
|
+
if (sep === null) break;
|
|
342
|
+
pos += sep[0].length;
|
|
343
|
+
}
|
|
344
|
+
return run;
|
|
345
|
+
}
|
|
346
|
+
|
|
298
347
|
/** The inline `File lane:`/`File-lane=` sentence grammar (see {@link laneDeclaration}). */
|
|
299
348
|
function inlineLaneDeclaration(text: string): LaneDeclaration | undefined {
|
|
300
349
|
const match = text.match(
|
|
301
|
-
/^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*([^\n]*)$/im,
|
|
350
|
+
/^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*(?:(?:\*\*|\*|__|_)\s*)?([^\n]*)$/im,
|
|
302
351
|
);
|
|
303
352
|
if (match === null) return undefined;
|
|
304
353
|
const rest = match[1] ?? "";
|
|
305
|
-
const
|
|
306
|
-
.map((m) => m[1]!.trim())
|
|
307
|
-
.filter(isPathLike);
|
|
308
|
-
const files =
|
|
309
|
-
backticked.length > 0
|
|
310
|
-
? [...new Set(backticked)]
|
|
311
|
-
: [...new Set(rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
|
|
354
|
+
const files = writeLanePaths(rest);
|
|
312
355
|
if (files.length === 0) return undefined;
|
|
313
356
|
return { files, source: match[0].trim() };
|
|
314
357
|
}
|
|
@@ -351,10 +394,11 @@ function sectionLaneDeclaration(text: string): LaneDeclaration | undefined {
|
|
|
351
394
|
const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/);
|
|
352
395
|
if (marker === null) break;
|
|
353
396
|
const rest = line.slice(marker[0].length);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
397
|
+
// One bullet contributes its leading backticked path run — what it
|
|
398
|
+
// declares it writes, not every backticked token its prose mentions.
|
|
399
|
+
// A bullet without a leading backticked path keeps the fallback
|
|
400
|
+
// (bare pathlike tokens), so unbackticked lanes are unaffected.
|
|
401
|
+
files.push(...writeLanePaths(rest));
|
|
358
402
|
bullets.push(line.trim());
|
|
359
403
|
}
|
|
360
404
|
if (files.length === 0) return undefined;
|
package/src/arm-challenge.ts
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
* orchestrator-workflow redesign).
|
|
6
6
|
*
|
|
7
7
|
* `armTicks` (fleet.ts) sends a short-lived `FLEET-…` code to the operator and
|
|
8
|
-
* files the challenge here. Verification
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* files the challenge here. Verification classifies the operator's message
|
|
9
|
+
* against these records and arms the projects the challenge named — either
|
|
10
|
+
* mechanically in the orchestrator session, which owns the project topic the
|
|
11
|
+
* challenge is sent to (#1061), or through the CLI step
|
|
12
|
+
* `omp-conductor arm --reply "<the operator's message>"` from a console host
|
|
13
|
+
* whose DM the operator answered in.
|
|
11
14
|
*
|
|
12
15
|
* Nothing waits for the reply any more. The console session owns the operator
|
|
13
16
|
* DM, and no tick extension runs there — so the in-session acknowledgement
|
|
@@ -498,3 +501,51 @@ export function observeArmChallenge(project: string | undefined): ArmChallengeSi
|
|
|
498
501
|
...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }),
|
|
499
502
|
};
|
|
500
503
|
}
|
|
504
|
+
|
|
505
|
+
/** One expired, unsettled ceremony, with the key the notice clears it under. */
|
|
506
|
+
export interface ExpiredArmChallenge {
|
|
507
|
+
/**
|
|
508
|
+
* The record's own state key — the project key or {@link FLEET_ARM_KEY} —
|
|
509
|
+
* passed straight back to {@link clearArmTransaction} after the notice, so
|
|
510
|
+
* the clear is addressed to the record that actually expired.
|
|
511
|
+
*/
|
|
512
|
+
key: string;
|
|
513
|
+
/** The expired transaction's id. */
|
|
514
|
+
id: string;
|
|
515
|
+
/** The sighting, acknowledgement included when a reply was seen. */
|
|
516
|
+
sighting: ArmChallengeSighting;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Every pending challenge that is no longer a proof — the project's own record
|
|
521
|
+
* and the fleet-wide one, in the same consultation order as
|
|
522
|
+
* {@link resolveArmReply} — whose window has passed without being settled.
|
|
523
|
+
* An acknowledgement may already exist (the reply was seen but the settle
|
|
524
|
+
* never completed), which is still unsettled: the notice must name that
|
|
525
|
+
* reason, and {@link doctor} does the same. This is what lets the fleet
|
|
526
|
+
* session tell the operator a ceremony died instead of letting the record sit
|
|
527
|
+
* invisible until the next arm replaces it.
|
|
528
|
+
*/
|
|
529
|
+
export function expiredArmChallenges(
|
|
530
|
+
project: string | undefined,
|
|
531
|
+
now: number,
|
|
532
|
+
): ExpiredArmChallenge[] {
|
|
533
|
+
const keys = projectKey(project) === FLEET_ARM_KEY ? [FLEET_ARM_KEY] : [projectKey(project), FLEET_ARM_KEY];
|
|
534
|
+
const expired: ExpiredArmChallenge[] = [];
|
|
535
|
+
for (const key of keys) {
|
|
536
|
+
const pending = readPendingFor(key);
|
|
537
|
+
if (pending === undefined || pending.expiresAt > now) continue;
|
|
538
|
+
const ack = readArmAcknowledgement(pending.id);
|
|
539
|
+
expired.push({
|
|
540
|
+
key,
|
|
541
|
+
id: pending.id,
|
|
542
|
+
sighting: {
|
|
543
|
+
id: pending.id,
|
|
544
|
+
...(typeof pending.sentAt === "number" ? { sentAt: pending.sentAt } : {}),
|
|
545
|
+
...(typeof pending.expiresAt === "number" ? { expiresAt: pending.expiresAt } : {}),
|
|
546
|
+
...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }),
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return expired;
|
|
551
|
+
}
|
package/src/briefs/console.md
CHANGED
|
@@ -156,13 +156,18 @@ same turn — an approved answer is work to execute, not a proposal to re-open.
|
|
|
156
156
|
|
|
157
157
|
## Arm ceremony
|
|
158
158
|
|
|
159
|
-
Ticks are gated on an operator-owned marker, and
|
|
160
|
-
that writes it. Two mechanical
|
|
159
|
+
Ticks are gated on an operator-owned marker, and something real always owns
|
|
160
|
+
the ceremony that writes it. Two mechanical halves, and nothing waits
|
|
161
|
+
anywhere:
|
|
161
162
|
|
|
162
163
|
1. `omp-conductor arm [--project {{PROJECT}}]` — records the challenge, sends it
|
|
163
|
-
to the
|
|
164
|
-
It writes no marker.
|
|
165
|
-
2.
|
|
164
|
+
to the project topic (this fleet's own chat), and returns immediately,
|
|
165
|
+
naming the exact follow-up command. It writes no marker.
|
|
166
|
+
2. A reply settles the ceremony. A reply sent in the project topic is consumed
|
|
167
|
+
mechanically by the orchestrator session itself — the challenge is sent to
|
|
168
|
+
the topic that session claims, so on a host with no console session the
|
|
169
|
+
ceremony still completes on its own. A reply sent here in the DM is yours:
|
|
170
|
+
`omp-conductor arm --reply "<the operator's message, verbatim>" [--project
|
|
166
171
|
{{PROJECT}}]` — classifies that reply and, on a match, writes the marker for
|
|
167
172
|
the targets the ceremony recorded.
|
|
168
173
|
|
package/src/commands/arm.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* `arm` files a challenge, sends it, and returns immediately naming the
|
|
5
5
|
* follow-up command. `arm --reply "<the operator's message>"` verifies that
|
|
6
6
|
* message and writes the arm marker for exactly the projects the challenge
|
|
7
|
-
* recorded. Nothing waits anywhere:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* recorded. Nothing waits anywhere: a reply sent to the project topic is
|
|
8
|
+
* consumed mechanically by the orchestrator pane itself (#1061), and this
|
|
9
|
+
* command remains the console's route for a reply that landed in the operator
|
|
10
|
+
* DM — the in-session acknowledgement wait this replaced could never be
|
|
11
|
+
* satisfied, because the console session runs no tick extension.
|
|
11
12
|
*
|
|
12
13
|
* Both halves print copy-pasteable commands, because the reader is usually an
|
|
13
14
|
* agent in a console pane rather than a human at a prompt.
|
|
@@ -95,7 +96,8 @@ function challengeReceipt(sent: ArmChallengeSent): string {
|
|
|
95
96
|
return (
|
|
96
97
|
`CHALLENGE SENT — a code went to owner ${sent.owner}, valid for ${sent.validFor} ` +
|
|
97
98
|
`(challenge ${sent.challengeId}).\n` +
|
|
98
|
-
`NOTHING IS ARMED YET.
|
|
99
|
+
`NOTHING IS ARMED YET. A reply in the challenge's chat settles it automatically; ` +
|
|
100
|
+
`on a console host run:\n` +
|
|
99
101
|
` ${sent.followUp}\n` +
|
|
100
102
|
`That reply will arm ${String(sent.targets.length)} project(s):\n` +
|
|
101
103
|
sent.targets
|
package/src/daemon/groom-pass.ts
CHANGED
|
@@ -245,14 +245,23 @@ export async function handleToSpecGrooming(
|
|
|
245
245
|
at: Date.now(),
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
-
// The scout's answer, unjudged, through the one validator.
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
248
|
+
// The scout's answer, unjudged, through the one validator (#1064). Only the
|
|
249
|
+
// structured payload is graded: a session that produced no payload at all is
|
|
250
|
+
// its own refusal class (`no-answer`), with the narration recorded as
|
|
251
|
+
// context, and a payload recovered from the text carries its `via` marker so
|
|
252
|
+
// the record says it did not come through the yield contract. `raw: ""`
|
|
253
|
+
// therefore means "no answer", and the pass never grades `result.report` as
|
|
254
|
+
// if it were one.
|
|
252
255
|
const outcome = recordToSpecGrooming(d.store, {
|
|
253
256
|
project: project.name,
|
|
254
257
|
issue,
|
|
255
|
-
input: result.raw
|
|
258
|
+
input: result.raw,
|
|
259
|
+
via: result.via === "text" ? "text" : result.raw === "" ? undefined : "yield",
|
|
260
|
+
report: result.report,
|
|
261
|
+
repaired: result.repaired,
|
|
262
|
+
killedAtCeiling: result.killedAtCeiling,
|
|
263
|
+
maxTurns: TO_SPEC_MAX_TURNS,
|
|
264
|
+
turns: result.turns,
|
|
256
265
|
launchedAt: batch.launchedAt,
|
|
257
266
|
});
|
|
258
267
|
const ran = result.model === undefined ? "" : ` by ${result.model}`;
|
|
@@ -261,9 +270,10 @@ export async function handleToSpecGrooming(
|
|
|
261
270
|
// stores and what the stats lane renders.
|
|
262
271
|
const cost =
|
|
263
272
|
result.turns > 0 && result.spendUsd === 0 ? "unmetered" : `$${result.spendUsd.toFixed(2)}`;
|
|
273
|
+
const repairNote = result.repaired === true ? " (repaired)" : "";
|
|
264
274
|
log(
|
|
265
275
|
`#${issue} groomed ${outcome.record.verdict}(${outcome.record.reason})${ran} in batch ${batch.id} — ` +
|
|
266
|
-
`${result.turns} turn(s), ${cost}`,
|
|
276
|
+
`${result.turns} turn(s), ${cost}${repairNote}`,
|
|
267
277
|
);
|
|
268
278
|
if (outcome.kind !== "persisted" || outcome.record.verdict !== "promotable") return;
|
|
269
279
|
await promoteGroomedVerdict(d, outcome.record);
|
package/src/daemon/runtime.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { reconcileOrphanedRuns } from "../settlement.ts";
|
|
|
33
33
|
import { dbPath, openStore, utcDay } from "../store.ts";
|
|
34
34
|
import { checkTelegramFreshness } from "../telegram-freshness.ts";
|
|
35
35
|
import { GraphqlBreaker, makeTracker } from "../tracker/github.ts";
|
|
36
|
-
import { RELEASE_SHAPES, type ProjectConfig, type ResolvedGrants, type RunRecord } from "../types.ts";
|
|
36
|
+
import { RELEASE_SHAPES, type Escalation, type ProjectConfig, type ResolvedGrants, type RunRecord } from "../types.ts";
|
|
37
37
|
import { runCommand } from "../upgrade-verify.ts";
|
|
38
38
|
import { inspectSurfaces } from "../upgrade.ts";
|
|
39
39
|
import { sharedUsageSource } from "../usage.ts";
|
|
@@ -203,6 +203,54 @@ export async function runDispatchLoop(o: DispatchLoopOptions): Promise<void> {
|
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/** What the daemon knows about its tier-1 orchestrator at diversion time. */
|
|
207
|
+
export interface Tier1DiversionFacts {
|
|
208
|
+
/** The project's configured tier-1 transport is the external orchestrator
|
|
209
|
+
* session: issue comments are the *configured* destination, not a
|
|
210
|
+
* fallback. */
|
|
211
|
+
external: boolean;
|
|
212
|
+
/** Why the daemon-owned orchestrator session failed to start, when it did. */
|
|
213
|
+
startError?: string;
|
|
214
|
+
/** Whether the daemon-owned orchestrator session is alive right now
|
|
215
|
+
* (`OrchestratorHandle.alive()` — event-driven, false once the session
|
|
216
|
+
* exited). */
|
|
217
|
+
alive: boolean;
|
|
218
|
+
/** Whether the daemon-owned orchestrator session is mid-turn right now
|
|
219
|
+
* (`OrchestratorHandle.busy()`). */
|
|
220
|
+
busy: boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The one-line record of a tier-1 escalation that landed on the issue-comment
|
|
225
|
+
* fallback instead of the orchestrator injection (#1068).
|
|
226
|
+
*
|
|
227
|
+
* "while the orchestrator was down" is reserved for what it actually means —
|
|
228
|
+
* a session that failed to start or one that has exited. The #1062 shape was
|
|
229
|
+
* neither: the orchestrator was mid-tick, its session did not take the
|
|
230
|
+
* injection, and a reader of "diverted … while the orchestrator was down"
|
|
231
|
+
* went looking for a dead session that did not exist. When the session is
|
|
232
|
+
* alive the line says where the escalation was delivered and why it was not
|
|
233
|
+
* injected; when the project runs an external orchestrator the comments *are*
|
|
234
|
+
* the configured transport and the line says so instead of calling it a
|
|
235
|
+
* diversion.
|
|
236
|
+
*/
|
|
237
|
+
export function tier1DiversionLine(e: Escalation, facts: Tier1DiversionFacts): string {
|
|
238
|
+
const ref = escalationIssueRef(e.issue);
|
|
239
|
+
if (facts.external) {
|
|
240
|
+
return `orchestrator: tier-1 escalation on ${ref} posted as an issue comment (external orchestrator — the configured tier-1 transport)`;
|
|
241
|
+
}
|
|
242
|
+
if (facts.startError !== undefined || !facts.alive) {
|
|
243
|
+
return (
|
|
244
|
+
`orchestrator: tier-1 escalation on ${ref} diverted to issue comments while the orchestrator was down` +
|
|
245
|
+
(facts.startError !== undefined ? ` (start failed: ${facts.startError})` : " (session exited)")
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return (
|
|
249
|
+
`orchestrator: tier-1 escalation on ${ref} posted as an issue comment — the orchestrator session ` +
|
|
250
|
+
`is alive but did not take the injection${facts.busy ? " (mid-turn)" : ""}`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
206
254
|
export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
207
255
|
// A `--once` tick is still a dispatcher: it settles rows, projects labels,
|
|
208
256
|
// admits and launches workers, so it must hold the same exclusive daemon/
|
|
@@ -391,8 +439,12 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
391
439
|
// the outage diverted (#288).
|
|
392
440
|
store.bumpOrchestratorDiverted(project.name, 1);
|
|
393
441
|
projectLog(
|
|
394
|
-
|
|
395
|
-
|
|
442
|
+
tier1DiversionLine(e, {
|
|
443
|
+
external: project.escalation.orchestrator === "external",
|
|
444
|
+
startError: orchestratorStartError,
|
|
445
|
+
alive: orchestrator?.alive() ?? false,
|
|
446
|
+
busy: orchestrator?.busy() ?? false,
|
|
447
|
+
}),
|
|
396
448
|
);
|
|
397
449
|
},
|
|
398
450
|
);
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { infraLogSignature, infraSignatureVersion } from "../failure-class.ts";
|
|
18
18
|
import { errText, log, safeEscalate } from "../log.ts";
|
|
19
|
+
import { GhPrMissingError } from "../tracker/github.ts";
|
|
19
20
|
import type { BaseHealth, RunRecord, SettlementFlag, WorkflowRun } from "../types.ts";
|
|
20
21
|
import { cleanupRetainedWorktree, mirrorPathFor, type RetainedWorktreeCleanup } from "../worktree.ts";
|
|
21
22
|
import { githubRepo, type Deps, type RetainedCleanupCursor } from "./deps.ts";
|
|
@@ -393,8 +394,24 @@ export async function cleanupRetainedRuns(
|
|
|
393
394
|
terminal = issue === "closed";
|
|
394
395
|
}
|
|
395
396
|
} catch (err) {
|
|
396
|
-
|
|
397
|
-
|
|
397
|
+
// The tracker throws exactly one classified error: `GhPrMissingError`,
|
|
398
|
+
// an individual REST 404 it corroborated with a same-repository
|
|
399
|
+
// pulls-list read, so the claimed PR definitively does not exist
|
|
400
|
+
// (#779). That is a fact, not "could not tell": retrying can never
|
|
401
|
+
// conjure the PR, so the missing PR is terminal and the retained row
|
|
402
|
+
// settles in this same pass like a closed one (#1065). The row's
|
|
403
|
+
// phantom URL is cleared with the settlement so a repeat pass neither
|
|
404
|
+
// asks GitHub about it again nor logs about it again. Everything else —
|
|
405
|
+
// a rate limit, a 5xx, a lost network — keeps the deferred retry below,
|
|
406
|
+
// unchanged.
|
|
407
|
+
if (err instanceof GhPrMissingError) {
|
|
408
|
+
store.updateRun(run.id, { prUrl: null });
|
|
409
|
+
log(`#${run.issue} retained cleanup settled: PR ${run.prUrl} does not exist`);
|
|
410
|
+
terminal = true;
|
|
411
|
+
} else {
|
|
412
|
+
log(`#${run.issue} retained cleanup deferred: tracker state failed (${errText(err)})`);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
398
415
|
}
|
|
399
416
|
if (!terminal) continue;
|
|
400
417
|
|
package/src/daemon.ts
CHANGED
|
@@ -113,8 +113,8 @@ export { daemonHttpResponse, turnLimitResponse, workerControlResponse } from "./
|
|
|
113
113
|
|
|
114
114
|
export { mineIntakeSignals, runDbSnapshotCadence, tick, upgradeVerifyDepsFor } from "./daemon/tick.ts";
|
|
115
115
|
|
|
116
|
-
export type { DispatchLoopOptions, DispatchPace } from "./daemon/runtime.ts";
|
|
117
|
-
export { createDispatchPace, orchestratorStandingOrders, runDaemon, runDispatchLoop } from "./daemon/runtime.ts";
|
|
116
|
+
export type { DispatchLoopOptions, DispatchPace, Tier1DiversionFacts } from "./daemon/runtime.ts";
|
|
117
|
+
export { createDispatchPace, orchestratorStandingOrders, runDaemon, runDispatchLoop, tier1DiversionLine } from "./daemon/runtime.ts";
|
|
118
118
|
|
|
119
119
|
// ------------------------------------------------------------------- pause
|
|
120
120
|
// The sentinel itself lives in `pause.ts` (#938): this module imports
|
package/src/diff-flags.ts
CHANGED
|
@@ -438,20 +438,125 @@ const ASSERTION =
|
|
|
438
438
|
/^(?:await\s+)?(?:expect|assert|assert_[a-z_]+|assertEquals?|assertTrue|assertFalse|assertThat|assertRaises|assertRaisesRegex|self\.assert[A-Za-z]*|should|chai\.|t\.(?:Error|Fatal)f?|require\.[A-Z][A-Za-z]*|Expect)\s*[.(]/;
|
|
439
439
|
|
|
440
440
|
/**
|
|
441
|
-
* A named timeout and its value
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
441
|
+
* A named timeout and its value, but only on the test runner's own timeout
|
|
442
|
+
* surface ({@link onRunnerSurface}) — a matching key handed to the code under
|
|
443
|
+
* test is a domain parameter, not a runner deadline (#1062). Only a *raised*
|
|
444
|
+
* one is a finding — a brand-new timeout on a new test is not a weakening — so
|
|
445
|
+
* the value is compared against the same key on the pre-image side and silence
|
|
446
|
+
* is the answer whenever the key appears on only one side.
|
|
445
447
|
*/
|
|
446
448
|
const TIMEOUT =
|
|
447
|
-
/\b(timeout|timeoutMs|timeout_ms|timeoutSeconds|deadline|maxDuration|wallClock(?:Ms)?|setTimeout|jest\.setTimeout|retries)\b\s*[:=(]\s*(\d[\d_]*)/gi;
|
|
449
|
+
/\b(timeout|timeoutMs|timeout_ms|timeoutSeconds|deadline|maxDuration|wallClock(?:Ms)?|setTimeout|jest\.setTimeout|retries)\b\s*([:=(])\s*(\d[\d_]*)/gi;
|
|
450
|
+
|
|
451
|
+
/** Identifiers that name the test runner itself when a timeout-shaped key is
|
|
452
|
+
* called on or passed to them. `t` is the test context (vitest, node:test, a
|
|
453
|
+
* Go `*testing.T` helper); `pytest` names the Python runner module. */
|
|
454
|
+
const RUNNER_BINDINGS: Record<string, true> = {
|
|
455
|
+
test: true,
|
|
456
|
+
it: true,
|
|
457
|
+
describe: true,
|
|
458
|
+
context: true,
|
|
459
|
+
suite: true,
|
|
460
|
+
bench: true,
|
|
461
|
+
jest: true,
|
|
462
|
+
t: true,
|
|
463
|
+
pytest: true,
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Whether a {@link TIMEOUT} match sits on the runner's own timeout surface of
|
|
468
|
+
* its line, as opposed to an argument handed to the code under test.
|
|
469
|
+
*
|
|
470
|
+
* The audit sees hunks, not files, so the judgement is structural on the
|
|
471
|
+
* changed line alone, and anything it cannot vouch for stays silent: a flag is
|
|
472
|
+
* advisory, and a false positive costs the trust in every flag after it
|
|
473
|
+
* (measured: #614/#896 read a raised `timeoutMs` passed to `arm(...)` — the
|
|
474
|
+
* challenge window of the code under test — as a weakened test).
|
|
475
|
+
*
|
|
476
|
+
* Two positions qualify, both on the runner's own call:
|
|
477
|
+
*
|
|
478
|
+
* - the key is called on the runner — `jest.setTimeout(...)`,
|
|
479
|
+
* `t.timeout(...)`, `test.setTimeout(...)`, `pytest.mark.timeout(...)` —
|
|
480
|
+
* the dotted receiver before the key starts with a runner binding;
|
|
481
|
+
* - the key is a property of an object literal that is a direct argument of a
|
|
482
|
+
* runner call — the trailing per-test configuration: `test("x", fn, {
|
|
483
|
+
* timeout: 45_000 })`, `test.use({ retries: 3 })`,
|
|
484
|
+
* `describe.configure({ retries: 3 })`.
|
|
485
|
+
*
|
|
486
|
+
* Everything else is the code under test: `await arm({ timeoutMs: 60_000 })`,
|
|
487
|
+
* `const opts = { timeout: 45000 }`, `server.setTimeout(30_000)`. A shape
|
|
488
|
+
* whose decisive frame is on another line — `}, { timeout: 45000 });` after a
|
|
489
|
+
* multi-line `test(` — cannot be vouched for from the line alone and also
|
|
490
|
+
* stays silent.
|
|
491
|
+
*/
|
|
492
|
+
function onRunnerSurface(code: string, match: RegExpMatchArray): boolean {
|
|
493
|
+
const key = match[1]?.toLowerCase() ?? "";
|
|
494
|
+
if (RUNNER_BINDINGS[key.split(".")[0] ?? ""] === true) return true;
|
|
495
|
+
|
|
496
|
+
const before = code.slice(0, match.index);
|
|
497
|
+
// The dotted receiver the key is called on: `t.timeout(5000)` reads `t.`,
|
|
498
|
+
// `pytest.mark.timeout(500)` reads `pytest.mark.`. No receiver — a bare
|
|
499
|
+
// `timeout: 5000` property — falls through to the config-object rule.
|
|
500
|
+
let at = before.length - 1;
|
|
501
|
+
while (at >= 0 && /[A-Za-z0-9_$.]/.test(before[at] ?? "")) at--;
|
|
502
|
+
const receiver = before.slice(at + 1).replace(/\.$/, "").split(".")[0];
|
|
503
|
+
if (RUNNER_BINDINGS[receiver ?? ""] === true) return true;
|
|
504
|
+
|
|
505
|
+
// A property key (`key: value`) needs the object around it to be the
|
|
506
|
+
// runner's own configuration; a `key = value` assignment or a bare
|
|
507
|
+
// `key(5000)` call on the line is never that.
|
|
508
|
+
if (match[2] !== ":") return false;
|
|
509
|
+
return inRunnerConfigObject(before);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
type RunnerFrame = { kind: "call"; base: string | undefined } | { kind: "obj" };
|
|
513
|
+
|
|
514
|
+
/** Whether the key sits in an object literal that is a direct argument of a
|
|
515
|
+
* call on a runner binding — the trailing per-test configuration:
|
|
516
|
+
* `test("x", fn, { timeout: 45_000 })`, `test.use({ retries: 3 })`,
|
|
517
|
+
* `describe.configure({ retries: 3 })`. The frames are walked over the line
|
|
518
|
+
* prefix only, with strings already stripped by {@link splitCode}, so the
|
|
519
|
+
* object's nesting inside the call — a direct argument versus a property of
|
|
520
|
+
* a nested object or of the callback's own body — decides the verdict. */
|
|
521
|
+
function inRunnerConfigObject(before: string): boolean {
|
|
522
|
+
const frames: RunnerFrame[] = [];
|
|
523
|
+
for (let at = 0; at < before.length; at++) {
|
|
524
|
+
const ch = before[at];
|
|
525
|
+
if (ch === "(") {
|
|
526
|
+
// The dotted name the call was opened on, if any: `test.use(` reads
|
|
527
|
+
// `test.use`. No name — an arrow's parameter list, or a call on a
|
|
528
|
+
// previous call's result — means the object below it is not a runner
|
|
529
|
+
// surface.
|
|
530
|
+
let end = at;
|
|
531
|
+
while (end > 0 && /[A-Za-z0-9_$.]/.test(before[end - 1] ?? "")) end--;
|
|
532
|
+
const chain = before.slice(end, at);
|
|
533
|
+
frames.push({
|
|
534
|
+
kind: "call",
|
|
535
|
+
base: chain.length > 0 && !chain.endsWith(".") ? chain : undefined,
|
|
536
|
+
});
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (ch === "{" || ch === "[") {
|
|
540
|
+
frames.push({ kind: "obj" });
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (ch === ")" || ch === "}" || ch === "]") frames.pop();
|
|
544
|
+
}
|
|
545
|
+
const object = frames.at(-1);
|
|
546
|
+
if (object?.kind !== "obj") return false;
|
|
547
|
+
const enclosing = frames.at(-2);
|
|
548
|
+
if (enclosing?.kind !== "call") return false;
|
|
549
|
+
const firstSegment = enclosing.base?.split(".")[0];
|
|
550
|
+
return firstSegment !== undefined && RUNNER_BINDINGS[firstSegment] === true;
|
|
551
|
+
}
|
|
448
552
|
|
|
449
553
|
function timeouts(text: string): { key: string; value: number }[] {
|
|
450
554
|
const found: { key: string; value: number }[] = [];
|
|
451
555
|
for (const match of text.matchAll(TIMEOUT)) {
|
|
452
556
|
const key = match[1]?.toLowerCase();
|
|
453
|
-
const raw = match[
|
|
557
|
+
const raw = match[3]?.replaceAll("_", "");
|
|
454
558
|
if (key === undefined || raw === undefined) continue;
|
|
559
|
+
if (!onRunnerSurface(text, match)) continue;
|
|
455
560
|
const value = Number(raw);
|
|
456
561
|
if (Number.isSafeInteger(value)) found.push({ key, value });
|
|
457
562
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -1345,8 +1345,8 @@ function armAckProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1345
1345
|
if (sighting.expiresAt !== undefined && now >= sighting.expiresAt) {
|
|
1346
1346
|
return warnFinding(
|
|
1347
1347
|
"arm-ack",
|
|
1348
|
-
`[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused)
|
|
1349
|
-
`
|
|
1348
|
+
`[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused); the fleet session notifies the operator and clears it once its notice goes out, or the next arm replaces it`,
|
|
1349
|
+
`the code is dead — no reply to it can arm. Re-run the ceremony: \`omp-conductor arm --project ${p.name}\``,
|
|
1350
1350
|
);
|
|
1351
1351
|
}
|
|
1352
1352
|
if (sighting.acknowledgedAt !== undefined) {
|