omp-conductor 0.17.0 → 0.17.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/REFERENCE.md +1 -1
- package/package.json +1 -1
- package/schema/config.schema.json +16 -0
- package/src/admission.ts +159 -43
- package/src/availability.ts +27 -1
- package/src/briefs/worker.md +2 -0
- package/src/commands/arm.ts +6 -3
- package/src/commands/message.ts +32 -4
- package/src/config-schema.ts +20 -0
- package/src/config.ts +37 -0
- package/src/daemon.ts +1240 -18
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +56 -13
- package/src/fleet.ts +169 -32
- package/src/gitops.ts +103 -24
- package/src/lifecycle.ts +7 -2
- package/src/orchestrator-tick.ts +327 -151
- package/src/release-policy.ts +177 -5
- package/src/setup-host.ts +193 -4
- package/src/setup-wizard.ts +1182 -78
- package/src/setup.ts +60 -3
- package/src/status-render.ts +11 -1
- package/src/store.ts +333 -12
- package/src/tracker/github.ts +562 -13
- package/src/types.ts +204 -2
- package/src/upgrade.ts +50 -19
- package/src/verbs/actions.ts +66 -18
- package/src/verbs/protocol.ts +45 -0
- package/src/verbs/server.ts +212 -11
- package/src/worker.ts +26 -0
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/REFERENCE.md
CHANGED
|
@@ -1864,7 +1864,7 @@ policy instead of restating it — no threshold lives in two places.
|
|
|
1864
1864
|
|
|
1865
1865
|
| Field | Values | Default | Means |
|
|
1866
1866
|
| --- | --- | --- | --- |
|
|
1867
|
-
| `requires` | `runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `runs-settled` reads each active run's PR fact at release time: a pushed run whose PR has merged counts as settled even when the settle sweep has not yet written the terminal row — so a hold-drained release does not wait an extra tick the operator reached the gate by holding. Live workers and unmerged/unknown PRs still refuse,
|
|
1867
|
+
| `requires` | `runs-settled`, `fleet-runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `runs-settled` reads each active run of the released repo's PR fact at release time: a pushed run whose PR has merged counts as settled even when the settle sweep has not yet written the terminal row — so a hold-drained release does not wait an extra tick the operator reached the gate by holding. Runs in other routed repos never gate a repo-scoped release — they cannot invalidate the artifact being shipped; a genuinely suite-wide shape (a pin or manifest consuming several repos) opts back into project-wide strictness with the named `fleet-runs-settled` requirement. Live workers and unmerged/unknown PRs still refuse, the message names which is which and which runs are blocking by repo and issue. `base-branch-green` requires the current live head's push-triggered workflow verdict for that routed repository to be green; pending, unknown, red, or no observation refuses release. Order and duplicates do not matter; the loader canonicali…
|
|
1868
1868
|
| `requiredChecks` | any check names | `[]` | Checks that must be green on the branch being released. Empty means every check it reports. |
|
|
1869
1869
|
| `artefacts` | any names | `[]` | The packages or images this project releases. **Empty denies**: nothing has been authorised to ship. |
|
|
1870
1870
|
| `environments` | any names | `[]` | Deploy targets. **Empty denies** every environment. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.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.",
|
|
@@ -325,6 +325,21 @@
|
|
|
325
325
|
},
|
|
326
326
|
"additionalProperties": false
|
|
327
327
|
},
|
|
328
|
+
"arm": {
|
|
329
|
+
"type": "object",
|
|
330
|
+
"properties": {
|
|
331
|
+
"proof": {
|
|
332
|
+
"default": "challenge",
|
|
333
|
+
"type": "string",
|
|
334
|
+
"enum": [
|
|
335
|
+
"challenge",
|
|
336
|
+
"claim-only"
|
|
337
|
+
]
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
"additionalProperties": false,
|
|
341
|
+
"description": "How `arm` proves a human just approved arming"
|
|
342
|
+
},
|
|
328
343
|
"authority": {
|
|
329
344
|
"type": "object",
|
|
330
345
|
"properties": {
|
|
@@ -433,6 +448,7 @@
|
|
|
433
448
|
"type": "string",
|
|
434
449
|
"enum": [
|
|
435
450
|
"runs-settled",
|
|
451
|
+
"fleet-runs-settled",
|
|
436
452
|
"no-open-prs",
|
|
437
453
|
"queue-drained",
|
|
438
454
|
"base-branch-green",
|
package/src/admission.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { log, errText, safeEscalate } from "./log.ts";
|
|
|
19
19
|
import type {
|
|
20
20
|
Caps,
|
|
21
21
|
Escalation,
|
|
22
|
+
IssueComment,
|
|
22
23
|
IssueSnapshot,
|
|
23
24
|
OpenCloser,
|
|
24
25
|
ProjectConfig,
|
|
@@ -28,7 +29,7 @@ import type {
|
|
|
28
29
|
AdmissionHoldReason,
|
|
29
30
|
} from "./types.ts";
|
|
30
31
|
import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.ts";
|
|
31
|
-
import type { CriticalBaseProbe, CriticalBaseVerdict, RunLaneProbe } from "./gitops.ts";
|
|
32
|
+
import type { CriticalBaseProbe, CriticalBaseVerdict, LaneFile, LaneSource, RunLaneProbe } from "./gitops.ts";
|
|
32
33
|
import { repoSlugFor } from "./gitops.ts";
|
|
33
34
|
import { branchName, type Routed } from "./routing.ts";
|
|
34
35
|
import { parseDependsOn } from "./depends-on.ts";
|
|
@@ -77,10 +78,16 @@ export function hasFailedAttemptBudget(failures: number, maxAttempts: number): b
|
|
|
77
78
|
return failures < maxAttempts;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
/** A candidate cleared for dispatch, with the attempt number it will run as.
|
|
81
|
+
/** A candidate cleared for dispatch, with the attempt number it will run as.
|
|
82
|
+
* `lane` is the effective file-lane declaration admission resolved for it
|
|
83
|
+
* (#608): the exact snapshot the overlap gate enforced. Dispatch renders this
|
|
84
|
+
* value into the worker brief, so a changed or failed second comment read can
|
|
85
|
+
* neither hide nor reword the lane admission held — the gate and the
|
|
86
|
+
* worker-visible brief are one value, not two reads of the same thread. */
|
|
81
87
|
export interface Admission {
|
|
82
88
|
r: Routed;
|
|
83
89
|
attempt: number;
|
|
90
|
+
lane?: FileLane;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
export interface AdmissionHold {
|
|
@@ -174,27 +181,82 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
|
|
|
174
181
|
* files an issue declares it will touch (#555).
|
|
175
182
|
*
|
|
176
183
|
* The orchestrator already writes exactly this list into every promotion brief
|
|
177
|
-
* in prose; this parses that same sentence out of the issue body
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
184
|
+
* in prose; this parses that same sentence out of the issue body — or out of a
|
|
185
|
+
* pre-dispatch comment, which is the same sentence on a supported brief
|
|
186
|
+
* surface since #517 — so the interlock is load-bearing rather than advisory.
|
|
187
|
+
* A line beginning with "file lane" (case-insensitive, optional
|
|
188
|
+
* bold/heading markers) is accepted, and paths are read as backtick-delimited
|
|
189
|
+
* spans (the brief form) with a bare comma/space-separated fallback that keeps
|
|
190
|
+
* tokens that look like relative paths. A line whose tokens are not pathlike
|
|
191
|
+
* (like `File lane: none`) is not a declaration. Anything else — including an
|
|
192
|
+
* absent declaration, which is the default — is an empty lane: the issue is
|
|
193
|
+
* admitted exactly as today (`fail open`), and the gate never refuses work for
|
|
194
|
+
* wanting a lane. This is the one grammar for all surfaces; there is no second
|
|
195
|
+
* comment-only spelling.
|
|
186
196
|
*/
|
|
187
|
-
export function
|
|
188
|
-
const match =
|
|
197
|
+
export function laneDeclaration(text: string): LaneDeclaration | undefined {
|
|
198
|
+
const match = text.match(
|
|
189
199
|
/^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*([^\n]*)$/im,
|
|
190
200
|
);
|
|
191
|
-
if (match === null) return
|
|
201
|
+
if (match === null) return undefined;
|
|
192
202
|
const rest = match[1] ?? "";
|
|
193
203
|
const backticked = [...rest.matchAll(/`([^`]+)`/g)]
|
|
194
204
|
.map((m) => m[1]!.trim())
|
|
195
205
|
.filter(isPathLike);
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
const files =
|
|
207
|
+
backticked.length > 0
|
|
208
|
+
? [...new Set(backticked)]
|
|
209
|
+
: [...new Set(rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
|
|
210
|
+
if (files.length === 0) return undefined;
|
|
211
|
+
return { files, source: match[0].trim() };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One parsed `File lane:` declaration: the paths and the verbatim source
|
|
215
|
+
* line, so a renderer can reproduce the declaration itself rather than a
|
|
216
|
+
* summary of it. Exported for the brief's guarantee that the gate's effective
|
|
217
|
+
* lane is always visible to the worker (#608). */
|
|
218
|
+
export interface LaneDeclaration {
|
|
219
|
+
files: string[];
|
|
220
|
+
/** The declaration line verbatim, as written on the surface it came from. */
|
|
221
|
+
source: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The effective file lane as both admission and the worker brief must read it
|
|
226
|
+
* (#608): the latest `File lane:` declaration among the issue body and every
|
|
227
|
+
* comment, in the tracker's oldest-first order. This is the "later correction
|
|
228
|
+
* visibly supersedes" contract applied across both surfaces at once, and it is
|
|
229
|
+
* the single source of truth the gate enforces and the brief renders — so a
|
|
230
|
+
* declaration can never control admission while staying invisible to the
|
|
231
|
+
* worker. `at` records which surface won (`"body"`, or the 0-based comment
|
|
232
|
+
* index), letting the brief reproduce the declaration verbatim when the
|
|
233
|
+
* winning comment sits beyond its rendered discussion budget.
|
|
234
|
+
*/
|
|
235
|
+
export interface FileLane extends LaneDeclaration {
|
|
236
|
+
at: "body" | number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Who holds one file's lane this admission pass, and which read proved it.
|
|
240
|
+
* `"declared"` is the same-pass half: an admitted candidate's own lane
|
|
241
|
+
* occupies for the rest of the pass without any probe read. */
|
|
242
|
+
export type LaneHolder = { issue: number; source: LaneSource | "declared" };
|
|
243
|
+
|
|
244
|
+
/** Resolves the effective lane across the body and the whole comment thread. */
|
|
245
|
+
export function effectiveLane(body: string, comments: IssueComment[]): FileLane | undefined {
|
|
246
|
+
let current: FileLane | undefined;
|
|
247
|
+
const bodyDecl = laneDeclaration(body);
|
|
248
|
+
if (bodyDecl !== undefined) current = { ...bodyDecl, at: "body" };
|
|
249
|
+
for (let i = 0; i < comments.length; i++) {
|
|
250
|
+
const decl = laneDeclaration(comments[i]!.body);
|
|
251
|
+
if (decl !== undefined) current = { ...decl, at: i };
|
|
252
|
+
}
|
|
253
|
+
return current;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The paths of an issue body's declared lane (empty for no declaration).
|
|
257
|
+
* Exported so the format is pinned independent of admission. */
|
|
258
|
+
export function declaredLane(body: string): string[] {
|
|
259
|
+
return laneDeclaration(body)?.files ?? [];
|
|
198
260
|
}
|
|
199
261
|
|
|
200
262
|
/** A plausible relative path: it has a `.` extension or a directory separator. */
|
|
@@ -271,14 +333,14 @@ export async function admitCandidates(
|
|
|
271
333
|
// and one on the web repo are different files. Built lazily and once, only
|
|
272
334
|
// when the first candidate that declares a lane reaches the gate, so a queue
|
|
273
335
|
// of laneless issues pays nothing for it.
|
|
274
|
-
let laneOccupancy: Map<string, Map<string,
|
|
275
|
-
const ensureLaneOccupancy = async (): Promise<Map<string, Map<string,
|
|
336
|
+
let laneOccupancy: Map<string, Map<string, LaneHolder>> | undefined;
|
|
337
|
+
const ensureLaneOccupancy = async (): Promise<Map<string, Map<string, LaneHolder>>> => {
|
|
276
338
|
if (laneOccupancy !== undefined) return laneOccupancy;
|
|
277
|
-
const occupied = new Map<string, Map<string,
|
|
339
|
+
const occupied = new Map<string, Map<string, LaneHolder>>();
|
|
278
340
|
for (const run of activeRuns) {
|
|
279
341
|
if (d.probeWorktreeLane === undefined) break;
|
|
280
342
|
const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
|
|
281
|
-
let files:
|
|
343
|
+
let files: LaneFile[];
|
|
282
344
|
try {
|
|
283
345
|
files = await d.probeWorktreeLane({
|
|
284
346
|
worktree: run.worktree,
|
|
@@ -291,20 +353,64 @@ export async function admitCandidates(
|
|
|
291
353
|
// well-formed issue because one probe could not be answered.
|
|
292
354
|
files = [];
|
|
293
355
|
}
|
|
294
|
-
for (const file of files) {
|
|
295
|
-
if (occupied.get(run.repo)?.has(file) === true) continue;
|
|
356
|
+
for (const { file, source } of files) {
|
|
296
357
|
let perRepo = occupied.get(run.repo);
|
|
297
358
|
if (perRepo === undefined) {
|
|
298
359
|
perRepo = new Map();
|
|
299
360
|
occupied.set(run.repo, perRepo);
|
|
300
361
|
}
|
|
301
|
-
|
|
362
|
+
// First read wins: a file the run both commits and edits in its
|
|
363
|
+
// worktree is tagged by its live uncommitted half.
|
|
364
|
+
if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
|
|
302
365
|
}
|
|
303
366
|
}
|
|
304
367
|
laneOccupancy = occupied;
|
|
305
368
|
return occupied;
|
|
306
369
|
};
|
|
307
370
|
|
|
371
|
+
// The candidate half of the file-lane interlock (#555): the machine-readable
|
|
372
|
+
// lane an issue declares. Since #517 a promotion brief may live in a
|
|
373
|
+
// pre-dispatch comment rather than the body, so the lane is read from both
|
|
374
|
+
// surfaces — the same two the worker brief renders — through the tracker's
|
|
375
|
+
// existing comment port, never a second comment reader. Body and comments
|
|
376
|
+
// share the one `laneDeclaration` grammar, and the brief's "later correction
|
|
377
|
+
// visibly supersedes" contract holds: the effective lane is the latest
|
|
378
|
+
// declaration among the body and the WHOLE thread, so a correction posted as
|
|
379
|
+
// a comment replaces an earlier body lane instead of widening it (#608).
|
|
380
|
+
//
|
|
381
|
+
// Reading every comment — not just the ones the brief's discussion budget
|
|
382
|
+
// renders — is deliberate: this is the same `effectiveLane` the brief
|
|
383
|
+
// renders, and the dispatch side guarantees the winning declaration appears
|
|
384
|
+
// in the brief verbatim even when it sits beyond the budget. The gate and
|
|
385
|
+
// the worker therefore agree on one lane, which is the #608 defect's shape.
|
|
386
|
+
//
|
|
387
|
+
// Comments are read at most once per issue per pass — the lane feeds both
|
|
388
|
+
// the gate and the same-pass sibling occupancy below, so the cache is what
|
|
389
|
+
// stops one candidate costing two comment reads. Unreadable comments fail
|
|
390
|
+
// open to the body declaration (a body lane stays load-bearing), and with no
|
|
391
|
+
// readable declaration at all the #555 fail-open admission is unchanged.
|
|
392
|
+
//
|
|
393
|
+
// The resolved `FileLane` — not just its paths — is what an admitted
|
|
394
|
+
// candidate carries into dispatch (#608): the gate and the brief must agree
|
|
395
|
+
// on the *same declaration* (paths, verbatim source line, and which surface
|
|
396
|
+
// it came from), so dispatch renders this cached value rather than reading
|
|
397
|
+
// the thread a second time and hoping it did not change.
|
|
398
|
+
const laneCache = new Map<number, FileLane | undefined>();
|
|
399
|
+
const laneFor = async (r: Routed): Promise<FileLane | undefined> => {
|
|
400
|
+
const issue = r.issue.number;
|
|
401
|
+
if (laneCache.has(issue)) return laneCache.get(issue);
|
|
402
|
+
let lane: FileLane | undefined;
|
|
403
|
+
try {
|
|
404
|
+
lane = effectiveLane(r.issue.body, await tracker.listComments(issue));
|
|
405
|
+
} catch (err) {
|
|
406
|
+
const bodyDecl = laneDeclaration(r.issue.body);
|
|
407
|
+
lane = bodyDecl === undefined ? undefined : { ...bodyDecl, at: "body" };
|
|
408
|
+
log(`#${issue} comments unreadable at admission; the body's file lane stands: ${errText(err)}`);
|
|
409
|
+
}
|
|
410
|
+
laneCache.set(issue, lane);
|
|
411
|
+
return lane;
|
|
412
|
+
};
|
|
413
|
+
|
|
308
414
|
// The plan allowance is a fleet-wide question, so it is asked once per pass
|
|
309
415
|
// and answers for every candidate — unlike every gate below it, which is
|
|
310
416
|
// per-issue. It sits here rather than beside the spend cap in `tick` for one
|
|
@@ -505,25 +611,30 @@ export async function admitCandidates(
|
|
|
505
611
|
}
|
|
506
612
|
if (verdict.state === "unknown") {
|
|
507
613
|
// Fail closed: a branch that cannot be *proven* to contain the marker
|
|
508
|
-
// is refused
|
|
509
|
-
//
|
|
510
|
-
|
|
614
|
+
// is refused. This is a verification/lookup failure — the mirror fetch
|
|
615
|
+
// failed, the marker did not resolve, or no probe is wired — never
|
|
616
|
+
// evidence that the branch predates the marker. Hold with a distinct
|
|
617
|
+
// reason so status and the friction rollup do not read a provider
|
|
618
|
+
// outage as branch staleness, and prescribe no branch change: the next
|
|
619
|
+
// admission pass re-runs the probe and admits the unchanged branch
|
|
620
|
+
// once verification succeeds.
|
|
621
|
+
hold(issue, "critical-base-verify-error");
|
|
511
622
|
log(
|
|
512
|
-
`#${issue} held (
|
|
623
|
+
`#${issue} held (critical-base-verify-error): continuation branch ${branch} could not be verified ` +
|
|
513
624
|
`against critical-base marker(s) ${markers.join(", ")} (${verdict.error})`,
|
|
514
625
|
);
|
|
515
626
|
await safeEscalate(d, {
|
|
516
627
|
tier: 1,
|
|
517
628
|
project: project.name,
|
|
518
629
|
issue,
|
|
519
|
-
summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (
|
|
630
|
+
summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (critical-base-verify-error)`,
|
|
520
631
|
detail: [
|
|
521
632
|
r.issue.title,
|
|
522
633
|
r.issue.url,
|
|
523
634
|
`The retained branch ${branch} could not be verified against critical-base marker(s) ${markers.join(", ")}: ${verdict.error}`,
|
|
524
|
-
"
|
|
525
|
-
"
|
|
526
|
-
"
|
|
635
|
+
"The branch is not claimed to predate the marker: this is a verification failure.",
|
|
636
|
+
"No branch change is prescribed. The next admission pass retries the critical-base",
|
|
637
|
+
"verification automatically and admits the unchanged branch once the probe succeeds.",
|
|
527
638
|
].join("\n"),
|
|
528
639
|
});
|
|
529
640
|
continue;
|
|
@@ -795,18 +906,23 @@ export async function admitCandidates(
|
|
|
795
906
|
// a live run's *actual* lane is held until that run's work has merged, so
|
|
796
907
|
// no second worker is sent at files another worker is still writing. The
|
|
797
908
|
// gate is the mechanical version of the prose rule that failed three times
|
|
798
|
-
// in one day. Only the machine-readable lane participates
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
|
|
909
|
+
// in one day. Only the machine-readable lane participates — read from the
|
|
910
|
+
// body and pre-dispatch comments alike, so the gate sees the same surface
|
|
911
|
+
// the worker brief renders (#608) — and a candidate without one is
|
|
912
|
+
// admitted exactly as today (fail open), while a candidate's own retained
|
|
913
|
+
// run never holds it — that is the continuation it continues.
|
|
914
|
+
const lane = await laneFor(r);
|
|
915
|
+
if (lane !== undefined && lane.files.length > 0) {
|
|
803
916
|
const occupied = await ensureLaneOccupancy();
|
|
804
917
|
const perRepo = occupied.get(r.repo.name);
|
|
805
918
|
let blocked = false;
|
|
806
|
-
for (const file of lane) {
|
|
919
|
+
for (const file of lane.files) {
|
|
807
920
|
const holder = perRepo?.get(file);
|
|
808
|
-
if (holder !== undefined && holder !== issue) {
|
|
809
|
-
|
|
921
|
+
if (holder !== undefined && holder.issue !== issue) {
|
|
922
|
+
// The source names the probe read that proved the occupancy, so a
|
|
923
|
+
// hold reads as authored work — worktree or branch — rather than the
|
|
924
|
+
// base-reconciliation noise #684 filters out of the probe entirely.
|
|
925
|
+
const detail = `${file} held by run #${holder.issue} (${holder.source})`;
|
|
810
926
|
hold(issue, "file-lane", detail);
|
|
811
927
|
log(`#${issue} held (file-lane): ${detail}`);
|
|
812
928
|
blocked = true;
|
|
@@ -816,20 +932,20 @@ export async function admitCandidates(
|
|
|
816
932
|
if (blocked) continue;
|
|
817
933
|
}
|
|
818
934
|
|
|
819
|
-
admitted.push({ r, attempt: priorRuns + 1 });
|
|
935
|
+
admitted.push({ r, attempt: priorRuns + 1, lane });
|
|
820
936
|
liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
|
|
821
937
|
// Same-pass sibling occupancy for the file-lane gate: once admitted, a
|
|
822
938
|
// candidate's declared lane occupies for the rest of the pass, so a later
|
|
823
939
|
// overlapping candidate is held rather than both clearing in one tick.
|
|
824
|
-
if (lane.length > 0) {
|
|
940
|
+
if (lane !== undefined && lane.files.length > 0) {
|
|
825
941
|
const occupied = await ensureLaneOccupancy();
|
|
826
942
|
let perRepo = occupied.get(r.repo.name);
|
|
827
943
|
if (perRepo === undefined) {
|
|
828
944
|
perRepo = new Map();
|
|
829
945
|
occupied.set(r.repo.name, perRepo);
|
|
830
946
|
}
|
|
831
|
-
for (const file of lane) {
|
|
832
|
-
if (!perRepo.has(file)) perRepo.set(file, issue);
|
|
947
|
+
for (const file of lane.files) {
|
|
948
|
+
if (!perRepo.has(file)) perRepo.set(file, { issue, source: "declared" });
|
|
833
949
|
}
|
|
834
950
|
}
|
|
835
951
|
if (parent !== undefined) {
|
package/src/availability.ts
CHANGED
|
@@ -91,17 +91,35 @@ export function availabilityDisposition(
|
|
|
91
91
|
return "availability";
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** A question-kind category. Every ask surface — `message --category
|
|
95
|
+
* decision-needed`, a `QUESTION:`-marked message, and `conductor_ask`'s
|
|
96
|
+
* default — resolves to this one category, so treating it as
|
|
97
|
+
* availability-deferred covers the whole question pathway at once (#596). */
|
|
98
|
+
export const QUESTION_KIND: InterruptCategory = "decision-needed";
|
|
99
|
+
|
|
94
100
|
/**
|
|
95
101
|
* Decide one tier-2 category. `digest` means the category policy itself defers
|
|
96
102
|
* it; `availability` means it was otherwise interruptible and may be released
|
|
97
103
|
* when the configured window next opens.
|
|
104
|
+
*
|
|
105
|
+
* A question is availability-deferred, never digest-deferred (#596): the
|
|
106
|
+
* category policy decides what may interrupt the operator's phone, but a
|
|
107
|
+
* question's deferral is literally "the operator is not available" — it waits
|
|
108
|
+
* for the window and the working-hours catch-up can release it at the next
|
|
109
|
+
* opening. With no window configured the next opening is now, so a question
|
|
110
|
+
* delivers rather than being silently bound to the daily digest. The
|
|
111
|
+
* `interruptOn` list never applies to a question's deferral.
|
|
98
112
|
*/
|
|
99
113
|
export function interruptDisposition(
|
|
100
114
|
policy: ReportingPolicy | undefined,
|
|
101
115
|
category: InterruptCategory,
|
|
102
116
|
at: number,
|
|
103
117
|
): InterruptDisposition {
|
|
104
|
-
if (
|
|
118
|
+
if (
|
|
119
|
+
policy !== undefined &&
|
|
120
|
+
category !== QUESTION_KIND &&
|
|
121
|
+
!policy.interruptOn.includes(category)
|
|
122
|
+
) return "digest";
|
|
105
123
|
return availabilityDisposition(policy?.availability, category, at);
|
|
106
124
|
}
|
|
107
125
|
|
|
@@ -148,6 +166,14 @@ export function formatZonedMinute(at: number, timezone: string): string {
|
|
|
148
166
|
return `${local.date} ${local.clock} ${timezone} (${new Date(at).toISOString()})`;
|
|
149
167
|
}
|
|
150
168
|
|
|
169
|
+
/** Compact local timestamp for a one-line future, e.g. "2026-08-24 09:00 UTC":
|
|
170
|
+
* the same instant as {@link formatZonedMinute} without the ISO tail, so a
|
|
171
|
+
* held-notice line read on a phone says plainly when the window opens. */
|
|
172
|
+
export function formatNextWindowOpening(at: number, timezone: string): string {
|
|
173
|
+
const local = localMinute(at, timezone);
|
|
174
|
+
return `${local.date} ${local.clock} ${timezone}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
151
177
|
/** One prompt sentence; the runtime gate, not this prose, owns the decision. */
|
|
152
178
|
export function availabilityPrompt(policy: ReportingPolicy | undefined, now: number): string {
|
|
153
179
|
const state = availabilityState(policy, now);
|
package/src/briefs/worker.md
CHANGED
|
@@ -98,6 +98,8 @@ These are the exact gates for `{{REPO}}`:
|
|
|
98
98
|
|
|
99
99
|
{{GATES}}
|
|
100
100
|
|
|
101
|
+
{{SHARED_HOST_NOTICE}}
|
|
102
|
+
|
|
101
103
|
Run every one of them, from the directory listed, over the **whole tree** — not
|
|
102
104
|
just the directory you edited. Linting only the source dir is how an error in a
|
|
103
105
|
migration, a config file or a script reaches the runners.
|
package/src/commands/arm.ts
CHANGED
|
@@ -12,14 +12,17 @@ import { withProgress } from "../ui/progress.ts";
|
|
|
12
12
|
|
|
13
13
|
export async function armCommand(ctx: CommandContext): Promise<void> {
|
|
14
14
|
for (const project of ctx.targetProjects()) {
|
|
15
|
+
// Proof-neutral wording: `claim-only` performs no Telegram send, so the
|
|
16
|
+
// progress line cannot promise a challenge that never goes out (#613). The
|
|
17
|
+
// result line names the proof that actually armed it.
|
|
15
18
|
const r = await withProgress(
|
|
16
|
-
"arm:
|
|
17
|
-
"
|
|
19
|
+
"arm: verifying the arming proof…",
|
|
20
|
+
"Arming proof verified",
|
|
18
21
|
() => armTicks(project.name),
|
|
19
22
|
{ plainMessage: true },
|
|
20
23
|
);
|
|
21
24
|
process.stdout.write(
|
|
22
|
-
`ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
|
|
25
|
+
`ARMED — ${r.proof === "claim-only" ? "claim-only plumbing verdict proved" : `inbound round-trip proved with owner ${r.owner}`}; ticks are now live.\n` +
|
|
23
26
|
`marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
|
|
24
27
|
);
|
|
25
28
|
}
|
package/src/commands/message.ts
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
import type { CommandContext } from "./context.ts";
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { availabilityState, formatNextWindowOpening } from "../availability.ts";
|
|
18
19
|
import { findProject, loadConfig } from "../config.ts";
|
|
19
20
|
import { deliverOperatorMessage, operatorMessageCategory, type OperatorMessageOutcome } from "../reports.ts";
|
|
20
21
|
import { dbPath, openStore } from "../store.ts";
|
|
21
|
-
import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory } from "../types.ts";
|
|
22
|
+
import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory, type ProjectConfig } from "../types.ts";
|
|
22
23
|
import { validateQuestionShape } from "../ask.ts";
|
|
23
24
|
|
|
24
25
|
/** The floor's "this needs an answer" marker, as every other classifier reads it. */
|
|
@@ -96,8 +97,35 @@ export async function messageCommand(ctx: CommandContext): Promise<void> {
|
|
|
96
97
|
? // Not "into the topic": a stale topic degrades to the flat chat with
|
|
97
98
|
// its own warning on stderr, and this line must not contradict it.
|
|
98
99
|
`message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
|
|
99
|
-
:
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
: heldLine(project, outcome),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The held-notice line names the future that releases it, in words an
|
|
105
|
+
* operator can act on: the working-hours window opening, or the daily digest.
|
|
106
|
+
* The earlier spelling — "the next digest or working-hours catch-up" — read
|
|
107
|
+
* the same for both and hid exactly the 23-hour hold that #596 is about. */
|
|
108
|
+
function heldLine(
|
|
109
|
+
project: ProjectConfig,
|
|
110
|
+
outcome: Extract<OperatorMessageOutcome, { kind: "held" }>,
|
|
111
|
+
): string {
|
|
112
|
+
if (outcome.reason === "availability") {
|
|
113
|
+
const state = availabilityState(project.reporting, Date.now());
|
|
114
|
+
const opening =
|
|
115
|
+
state.mode === "quiet" && state.nextTransitionAt !== undefined && state.timezone !== undefined
|
|
116
|
+
? ` at ${formatNextWindowOpening(state.nextTransitionAt, state.timezone)}`
|
|
117
|
+
: "";
|
|
118
|
+
return (
|
|
119
|
+
`held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
120
|
+
`held until the working-hours window opens${opening})\n` +
|
|
121
|
+
"nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const digestAt = project.reporting?.digest.at;
|
|
125
|
+
const when = digestAt === undefined ? "the next digest" : `the ${digestAt} digest`;
|
|
126
|
+
return (
|
|
127
|
+
`held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
128
|
+
`digest-only — held until ${when})\n` +
|
|
129
|
+
`nothing was sent; the daemon releases it with ${when}\n`
|
|
102
130
|
);
|
|
103
131
|
}
|
package/src/config-schema.ts
CHANGED
|
@@ -22,10 +22,12 @@
|
|
|
22
22
|
|
|
23
23
|
import { z } from "zod";
|
|
24
24
|
import {
|
|
25
|
+
ARM_PROOFS,
|
|
25
26
|
AUTHORITY_HOLDERS,
|
|
26
27
|
BASE_FRESHNESS,
|
|
27
28
|
BEHIND_BASE_ACTIONS,
|
|
28
29
|
CONFIG_VERSION,
|
|
30
|
+
DEFAULT_ARM_PROOF,
|
|
29
31
|
DEFAULT_AUTHORITY,
|
|
30
32
|
DEFAULT_CAPS,
|
|
31
33
|
DEFAULT_PROJECT_POLICY,
|
|
@@ -239,6 +241,21 @@ const escalationSchema = z
|
|
|
239
241
|
})
|
|
240
242
|
.strict();
|
|
241
243
|
|
|
244
|
+
/**
|
|
245
|
+
* The per-project arming gate (conductor #613): how `arm` proves a human just
|
|
246
|
+
* approved dispatch. Absent or a legacy config without the key loads as
|
|
247
|
+
* `challenge` — the authenticated round-trip, which is what existing installs
|
|
248
|
+
* already run. `claim-only` arms on the shared live-plumbing verdict with no
|
|
249
|
+
* Telegram send or wait, so an unattended recovery can re-arm a project that
|
|
250
|
+
* opted in.
|
|
251
|
+
*/
|
|
252
|
+
const armSchema = z
|
|
253
|
+
.object({
|
|
254
|
+
proof: z.enum([...ARM_PROOFS]).default(DEFAULT_ARM_PROOF),
|
|
255
|
+
})
|
|
256
|
+
.strict()
|
|
257
|
+
.describe("How `arm` proves a human just approved arming");
|
|
258
|
+
|
|
242
259
|
const releasePolicySchema = z.union([
|
|
243
260
|
releasePolicyLegacyEnum,
|
|
244
261
|
z.record(z.string(), authorityHolderEnum),
|
|
@@ -330,6 +347,9 @@ const projectSchema = z
|
|
|
330
347
|
// overlay); anything else is dropped by the loader, like `workerModel`.
|
|
331
348
|
workerAdvisor: z.unknown().optional(),
|
|
332
349
|
escalation: escalationSchema.optional(),
|
|
350
|
+
// How `arm` proves a human approved arming (#613); absent loads as
|
|
351
|
+
// `challenge`, preserving today's authenticated round-trip.
|
|
352
|
+
arm: armSchema.optional(),
|
|
333
353
|
authority: authoritySchema.optional(),
|
|
334
354
|
releasePolicy: releasePolicySchema.optional(),
|
|
335
355
|
policy: projectPolicySchema.optional(),
|
package/src/config.ts
CHANGED
|
@@ -17,10 +17,12 @@ import { homedir } from "node:os";
|
|
|
17
17
|
import { dirname, isAbsolute, join } from "node:path";
|
|
18
18
|
import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
|
|
19
19
|
import {
|
|
20
|
+
ARM_PROOFS,
|
|
20
21
|
AUTHORITY_HOLDERS,
|
|
21
22
|
BASE_FRESHNESS,
|
|
22
23
|
BEHIND_BASE_ACTIONS,
|
|
23
24
|
CONFIG_VERSION,
|
|
25
|
+
DEFAULT_ARM_PROOF,
|
|
24
26
|
DEFAULT_AUTHORITY,
|
|
25
27
|
DEFAULT_CAPS,
|
|
26
28
|
DEFAULT_PROJECT_POLICY,
|
|
@@ -55,6 +57,7 @@ import {
|
|
|
55
57
|
type WeeklyAvailability,
|
|
56
58
|
type RepoTarget,
|
|
57
59
|
type ResolvedGrants,
|
|
60
|
+
type ArmProof,
|
|
58
61
|
} from "./types.ts";
|
|
59
62
|
import {
|
|
60
63
|
AUTHORITY_HOLDER_LIST,
|
|
@@ -313,6 +316,20 @@ export function resolvePolicy(p: ProjectConfig): ProjectPolicy {
|
|
|
313
316
|
return clonePolicy(p.policy ?? DEFAULT_PROJECT_POLICY);
|
|
314
317
|
}
|
|
315
318
|
|
|
319
|
+
/**
|
|
320
|
+
* How this project's `arm` proves a human just approved arming (#613),
|
|
321
|
+
* complete.
|
|
322
|
+
*
|
|
323
|
+
* The loader always materialises `arm` (finalizeProject), so this is only ever
|
|
324
|
+
* the fallback for a `ProjectConfig` that never went through it — a hand-built
|
|
325
|
+
* one in a test, or a config written before the key existed. Absent resolves
|
|
326
|
+
* to {@link DEFAULT_ARM_PROOF} (`challenge`): the authenticated round-trip,
|
|
327
|
+
* which is the behaviour every existing install already has.
|
|
328
|
+
*/
|
|
329
|
+
export function resolveArmProof(p: ProjectConfig): ArmProof {
|
|
330
|
+
return p.arm?.proof === "claim-only" ? "claim-only" : DEFAULT_ARM_PROOF;
|
|
331
|
+
}
|
|
332
|
+
|
|
316
333
|
/**
|
|
317
334
|
* A policy with no array shared with its source.
|
|
318
335
|
*
|
|
@@ -835,6 +852,9 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
|
|
|
835
852
|
return `reporting.availability must be an object`;
|
|
836
853
|
}
|
|
837
854
|
if (rel.length === 1 && relStr === "escalation") return `escalation must be an object`;
|
|
855
|
+
if (rel.length === 1 && relStr === "arm") {
|
|
856
|
+
return `arm must be an object with a "proof" of ${ARM_PROOF_LIST}`;
|
|
857
|
+
}
|
|
838
858
|
if (rel.length === 1 && relStr === "authority") {
|
|
839
859
|
return `authority must be an object with "merge", "release" and "promotion" of ${AUTHORITY_HOLDER_LIST}`;
|
|
840
860
|
}
|
|
@@ -995,6 +1015,9 @@ function capProblem(key: string, found: string): string {
|
|
|
995
1015
|
const POLICY_MERGE_KEYS = quoteList(Object.keys(clonePolicy(DEFAULT_PROJECT_POLICY).merge));
|
|
996
1016
|
const POLICY_RELEASE_KEYS = quoteList(Object.keys(clonePolicy(DEFAULT_PROJECT_POLICY).release));
|
|
997
1017
|
|
|
1018
|
+
/** The closed `arm.proof` vocabulary, for the not-an-object wording. */
|
|
1019
|
+
const ARM_PROOF_LIST = quoteList(ARM_PROOFS);
|
|
1020
|
+
|
|
998
1021
|
// ---------------------------------------------------------------------------
|
|
999
1022
|
// residue: cross-field coherence, migrations, defaults, path expansion
|
|
1000
1023
|
// ---------------------------------------------------------------------------
|
|
@@ -1062,6 +1085,7 @@ function finalizeProject(
|
|
|
1062
1085
|
|
|
1063
1086
|
const stateLabels = p["stateLabels"] as Raw | undefined;
|
|
1064
1087
|
const escalation = finalizeEscalation(p["escalation"] as Raw | undefined);
|
|
1088
|
+
const arm = finalizeArm(p["arm"] as Raw | undefined);
|
|
1065
1089
|
const authority = finalizeAuthority(p["authority"] as Raw | undefined);
|
|
1066
1090
|
const releasePolicy = finalizeReleasePolicy(p["releasePolicy"], label, problems);
|
|
1067
1091
|
const strandedTagRepos =
|
|
@@ -1183,6 +1207,7 @@ function finalizeProject(
|
|
|
1183
1207
|
...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
|
|
1184
1208
|
...(effectiveOmpSettings === undefined ? {} : { ompSettings: effectiveOmpSettings }),
|
|
1185
1209
|
escalation,
|
|
1210
|
+
arm,
|
|
1186
1211
|
authority,
|
|
1187
1212
|
releasePolicy,
|
|
1188
1213
|
policy,
|
|
@@ -1194,6 +1219,18 @@ function finalizeProject(
|
|
|
1194
1219
|
};
|
|
1195
1220
|
}
|
|
1196
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* The project's arming gate (#613), complete. A config written before the key
|
|
1224
|
+
* existed — or one that never answered — materialises as `challenge`, the
|
|
1225
|
+
* authenticated round-trip every existing install already runs, so an upgrade
|
|
1226
|
+
* changes no behaviour. zod has already rejected anything that is not a
|
|
1227
|
+
* `"challenge"` / `"claim-only"` string, so this only fills the absent case.
|
|
1228
|
+
*/
|
|
1229
|
+
function finalizeArm(parsed: Raw | undefined): ProjectConfig["arm"] {
|
|
1230
|
+
if (parsed === undefined) return { proof: DEFAULT_ARM_PROOF };
|
|
1231
|
+
return { proof: (parsed["proof"] as ArmProof | undefined) ?? DEFAULT_ARM_PROOF };
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1197
1234
|
function finalizeEscalation(parsed: Raw | undefined): ProjectConfig["escalation"] {
|
|
1198
1235
|
if (parsed === undefined) {
|
|
1199
1236
|
return { fallbackToIssueComment: true, orchestrator: "embedded" };
|