omp-conductor 0.16.2 → 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/README.md +38 -4
- package/REFERENCE.md +18 -12
- package/package.json +2 -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/clack-ui.ts +83 -0
- package/src/command-manifest.ts +16 -7
- package/src/commands/arm.ts +11 -3
- package/src/commands/decision.ts +17 -7
- package/src/commands/doctor.ts +18 -1
- package/src/commands/hold.ts +9 -7
- package/src/commands/ledger.ts +25 -4
- package/src/commands/message.ts +32 -4
- package/src/commands/setup.ts +61 -10
- package/src/commands/stats.ts +9 -5
- package/src/commands/status.ts +32 -5
- package/src/commands/tail.ts +13 -1
- package/src/commands/watch.ts +16 -7
- 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 +224 -47
- package/src/gitops.ts +103 -24
- package/src/lifecycle.ts +7 -2
- package/src/orchestrator-tick.ts +372 -157
- package/src/privileged.ts +3 -0
- package/src/release-policy.ts +177 -5
- package/src/setup-answers.ts +135 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +2 -0
- package/src/setup-probe.ts +1 -0
- package/src/setup-wizard.ts +1296 -101
- 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/ui/progress.ts +32 -0
- package/src/ui/style.ts +11 -0
- 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/wizard-ui.ts +14 -5
- package/src/worker.ts +26 -0
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/src/failure-class.ts
CHANGED
|
@@ -61,8 +61,14 @@ const SUCCESS_CHECK_STATES: Record<string, true> = { success: true, neutral: tru
|
|
|
61
61
|
* Substrings in a failed check's log that prove the failure was infrastructure,
|
|
62
62
|
* not the diff (#177). Each is a registry/docker/runner fault a worker cannot
|
|
63
63
|
* have introduced: a rate limit, an image-manifest resolution failure, a runner
|
|
64
|
-
* being torn down under the job, or a DNS failure. Matched lowercased against
|
|
65
|
-
* the log tail.
|
|
64
|
+
* a runner being torn down under the job, or a DNS failure. Matched lowercased against
|
|
65
|
+
* the log tail. GitHub writes the rate-limit sentence "Response status code does
|
|
66
|
+
* not indicate success: 429 (Too Many Requests)" where Docker writes "429 Too
|
|
67
|
+
* Many Requests"; the full sentence is its own exact entry rather than a blanket
|
|
68
|
+
* paren-strip of the log — stripping every parenthesis would let a deterministic
|
|
69
|
+
* log like "failed to resolve source (metadata for fixture)" slip past the
|
|
70
|
+
* `failed to resolve source metadata for` signature and waive an attempt it
|
|
71
|
+
* genuinely spent (#177).
|
|
66
72
|
*
|
|
67
73
|
* Deliberately *not* matching bare `failed to solve:` — a docker build failure
|
|
68
74
|
* often prints it with a real resolution error, so the closing words carry the
|
|
@@ -70,12 +76,46 @@ const SUCCESS_CHECK_STATES: Record<string, true> = { success: true, neutral: tru
|
|
|
70
76
|
*/
|
|
71
77
|
const INFRA_LOG_SIGNATURES = [
|
|
72
78
|
"429 too many requests",
|
|
79
|
+
// GitHub's setup/action-download sentence for the same rate limit — the full
|
|
80
|
+
// lowercased phrase, never the bare parenthesized status: an application log
|
|
81
|
+
// like "expected 200, got 429 (Too Many Requests)" is a product verdict, not
|
|
82
|
+
// the runner's, and must not be waived (#637, #639).
|
|
83
|
+
"response status code does not indicate success: 429 (too many requests)",
|
|
73
84
|
"failed to resolve source metadata for",
|
|
74
85
|
"the runner has received a shutdown signal",
|
|
75
86
|
"could not resolve host",
|
|
76
87
|
];
|
|
77
88
|
|
|
78
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The closed infra signature a failed check log must contain for the failure
|
|
91
|
+
* to be infrastructure rather than the diff, or `undefined` when the log
|
|
92
|
+
* carries none (#177). Exported so the historical reconciliation recognises
|
|
93
|
+
* exactly what the forward classifier does — one definition of "the log
|
|
94
|
+
* proves infra", or a repaired row and a fresh row would diverge (#638).
|
|
95
|
+
*/
|
|
96
|
+
export function infraLogSignature(log: string): string | undefined {
|
|
97
|
+
const lower = log.toLowerCase();
|
|
98
|
+
return INFRA_LOG_SIGNATURES.find((signature) => lower.includes(signature));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A stable fingerprint of the infrastructure signature list (#638). The
|
|
103
|
+
* historical reconciliation persists a per-project review cursor stamped with
|
|
104
|
+
* this version so a bounded pass resumes where the last one stopped; when the
|
|
105
|
+
* classifier learns a new signature, the fingerprint changes, the stored
|
|
106
|
+
* cursor is stale, and the pass restarts from the newest row so the newly
|
|
107
|
+
* recognised evidence is never skipped past. Two values are equal exactly when
|
|
108
|
+
* the signature set is — a join cannot collide, because each entry is a
|
|
109
|
+
* distinct delimiter-free token sequence.
|
|
110
|
+
*/
|
|
111
|
+
export function infraSignatureVersion(): string {
|
|
112
|
+
return INFRA_LOG_SIGNATURES.join("|");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Lowercased check state — `gh pr checks` has emitted both `failure` and
|
|
116
|
+
* `FAILURE` across versions, and the classifier's callers must agree on one
|
|
117
|
+
* spelling so log selection and classification see the same set of checks. */
|
|
118
|
+
export function normalise(state: string): string {
|
|
79
119
|
return state.trim().toLowerCase();
|
|
80
120
|
}
|
|
81
121
|
|
|
@@ -466,16 +506,19 @@ export function classifyRun(
|
|
|
466
506
|
// beats ci-deterministic because charging an implementation attempt for a
|
|
467
507
|
// rate limit is exactly the waste that class exists to prevent.
|
|
468
508
|
if (facts.failingLog !== undefined) {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
509
|
+
// Matched exactly, without paren-stripping: GitHub's full setup/codeload
|
|
510
|
+
// sentence is its own closed entry above, and a generic paren-strip would
|
|
511
|
+
// broaden the other signatures into waiving real deterministic failures
|
|
512
|
+
// (#177). A bare "429" or "too many requests" (parenthesized or not)
|
|
513
|
+
// still never matches without that sentence.
|
|
514
|
+
const signature = infraLogSignature(facts.failingLog);
|
|
515
|
+
if (signature !== undefined) {
|
|
516
|
+
const check = checks.find((c) => normalise(c.state) === "failure");
|
|
517
|
+
return {
|
|
518
|
+
cls: "ci-infra",
|
|
519
|
+
recovery: "rerun-checks",
|
|
520
|
+
evidence: `infrastructure signature in ${check?.name === undefined ? "failed check" : check.name} log: "${signature}"`,
|
|
521
|
+
};
|
|
479
522
|
}
|
|
480
523
|
}
|
|
481
524
|
const failing = unresolved.filter((c) => normalise(c.state) === "failure");
|
package/src/fleet.ts
CHANGED
|
@@ -27,17 +27,31 @@ import {
|
|
|
27
27
|
import { createInterface } from "node:readline";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { dirname, join, sep } from "node:path";
|
|
30
|
-
import { findProject, loadConfig, stateDir } from "./config.ts";
|
|
30
|
+
import { findProject, loadConfig, resolveArmProof, stateDir } from "./config.ts";
|
|
31
31
|
import { clearArmChallenge, recordArmChallenge } from "./arm-challenge.ts";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
claimedTelegramTopics,
|
|
34
|
+
lockPidAlive,
|
|
35
|
+
pidAlive,
|
|
36
|
+
readTelegramDmOwner,
|
|
37
|
+
readTelegramPollState,
|
|
38
|
+
resolveClaimedSessionFile,
|
|
39
|
+
resolveProjectTopicId,
|
|
40
|
+
sendTelegram,
|
|
41
|
+
telegramPlumbingVerdict,
|
|
42
|
+
TELEGRAM_LOCK_FRESH_MS,
|
|
43
|
+
type TelegramPlumbingFailureReason,
|
|
44
|
+
type TelegramPlumbingProbe,
|
|
45
|
+
type TelegramPlumbingVerdict,
|
|
46
|
+
} from "./escalate.ts";
|
|
33
47
|
import { readPlanUsage, sharedUsageSource } from "./usage.ts";
|
|
34
48
|
import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
|
|
35
49
|
import { inspectBriefLayout } from "./brief-upgrade.ts";
|
|
36
50
|
import { dbPath, openStore } from "./store.ts";
|
|
37
51
|
import { renderBriefForProject } from "./setup.ts";
|
|
38
|
-
import type
|
|
52
|
+
import { DEFAULT_ARM_PROOF, type ArmProof, type DaemonStop, type ProjectConfig, type Store } from "./types.ts";
|
|
39
53
|
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
40
|
-
import { isPaused, setPaused, statusSnapshot } from "./daemon.ts";
|
|
54
|
+
import { isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
41
55
|
import {
|
|
42
56
|
healthCheck,
|
|
43
57
|
isAlive,
|
|
@@ -68,6 +82,7 @@ import {
|
|
|
68
82
|
resolveArmState,
|
|
69
83
|
TICK_CONFIG_FILE,
|
|
70
84
|
tickConfigMatchesProject,
|
|
85
|
+
type ArmState,
|
|
71
86
|
type TickConfig,
|
|
72
87
|
type TickConfigResult,
|
|
73
88
|
} from "./orchestrator-tick.ts";
|
|
@@ -281,7 +296,13 @@ export interface ArmResult {
|
|
|
281
296
|
path: string;
|
|
282
297
|
alreadyArmed: boolean;
|
|
283
298
|
owner: string;
|
|
284
|
-
challenge
|
|
299
|
+
/** The challenge code that proved arming, present only for `challenge` proof. */
|
|
300
|
+
challenge?: string;
|
|
301
|
+
/**
|
|
302
|
+
* Which proof armed the fleet (#613): `challenge` for the authenticated
|
|
303
|
+
* round-trip, `claim-only` for the live-plumbing verdict with no send.
|
|
304
|
+
*/
|
|
305
|
+
proof: ArmProof;
|
|
285
306
|
}
|
|
286
307
|
|
|
287
308
|
export interface ArmDeps {
|
|
@@ -306,6 +327,17 @@ export interface ArmDeps {
|
|
|
306
327
|
now?: () => number;
|
|
307
328
|
sleep?: (ms: number) => Promise<void>;
|
|
308
329
|
timeoutMs?: number;
|
|
330
|
+
/**
|
|
331
|
+
* Liveness seams for the claim-only verdict (#613), with omp-telegram's own
|
|
332
|
+
* rules (#612): `pidAlive` judges a claim or dm-owner (EPERM is dead),
|
|
333
|
+
* `lockPidAlive` judges a bot.lock owner (EPERM is live), and `lockFresh`
|
|
334
|
+
* applies the heartbeat window. Tests inject deterministic answers without
|
|
335
|
+
* owning another uid's process; the verdict itself and every state file it
|
|
336
|
+
* reads run for real.
|
|
337
|
+
*/
|
|
338
|
+
pidAlive?: (pid: number) => boolean;
|
|
339
|
+
lockPidAlive?: (pid: number) => boolean;
|
|
340
|
+
lockFresh?: (mtimeMs: number) => boolean;
|
|
309
341
|
}
|
|
310
342
|
|
|
311
343
|
export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promise<ArmResult> {
|
|
@@ -346,14 +378,81 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
346
378
|
// answering a question they cannot attribute.
|
|
347
379
|
const named = tick.config.project ?? projectName;
|
|
348
380
|
|
|
381
|
+
// The arming proof is a declared per-project policy (#613). A config that
|
|
382
|
+
// cannot name the project fails safe to `challenge` — today's authenticated
|
|
383
|
+
// round-trip — so a missing or unreadable config never silently weakens the
|
|
384
|
+
// gate.
|
|
385
|
+
let proof: ArmProof = DEFAULT_ARM_PROOF;
|
|
386
|
+
try {
|
|
387
|
+
proof = resolveArmProof(findProject(loadConfig(), named));
|
|
388
|
+
} catch {
|
|
389
|
+
/* no project config — keep today's challenge behaviour */
|
|
390
|
+
}
|
|
391
|
+
|
|
349
392
|
// Resolve the orchestrator's live session file *before* the challenge goes
|
|
350
393
|
// out — the same claim the send follows (#600). A pane resumed from a
|
|
351
394
|
// session created elsewhere (herdr pins it to the original transcript)
|
|
352
395
|
// writes a session file outside the directory the tick cwd implies, and a
|
|
353
396
|
// cwd-derived scan would poll the one place the reply is guaranteed not to
|
|
354
397
|
// be. A claim outside the session tree arm scans can never be answered, so
|
|
355
|
-
// that is a stop, not five minutes of polling.
|
|
398
|
+
// that is a stop, not five minutes of polling. (Claim-only resolves the
|
|
399
|
+
// claim too — the verdict checks the same session identity — but names the
|
|
400
|
+
// refusal itself rather than throwing the transcript wording.)
|
|
356
401
|
const claimed = deps.claimedSessionFile !== undefined ? deps.claimedSessionFile() : claimedOrchestratorSessionFile(named);
|
|
402
|
+
|
|
403
|
+
// Prefer the project's live forum topic so arm challenges land where
|
|
404
|
+
// escalations already do (#318), following the bridge's current claim when the
|
|
405
|
+
// pinned id has gone stale (#407). Missing project config keeps flat-chat 0.13.
|
|
406
|
+
let sendTopic: number | undefined;
|
|
407
|
+
if (named !== undefined) {
|
|
408
|
+
try {
|
|
409
|
+
sendTopic = resolveProjectTopicId(findProject(loadConfig(), named));
|
|
410
|
+
} catch {
|
|
411
|
+
/* no project config */
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const path = tick.config.armedFile;
|
|
416
|
+
// The gate as the heartbeat reads it, so "replaced previous marker" is not a
|
|
417
|
+
// lie about a fleet the shared marker was arming, and so the write below knows
|
|
418
|
+
// whether it is superseding that marker.
|
|
419
|
+
const arm = resolveArmState(path, named);
|
|
420
|
+
const alreadyArmed = arm.armed;
|
|
421
|
+
|
|
422
|
+
if (proof === "claim-only") {
|
|
423
|
+
// The human-intent gate is declared satisfied by policy, so #612's shared
|
|
424
|
+
// verdict is the whole proof: the same state reads and the same liveness
|
|
425
|
+
// rules the doctor's "telegram-plumbing" finding applies, on the route a
|
|
426
|
+
// challenge would have ridden. No Telegram send, no transcript wait, no
|
|
427
|
+
// pending-challenge record. A failed fact refuses arming by name — never
|
|
428
|
+
// a silent pass from file existence, and never a marker.
|
|
429
|
+
const scan = armVerdictScanDirs(tick.cwd, claimed);
|
|
430
|
+
const probe: TelegramPlumbingProbe = {
|
|
431
|
+
// `channel` is up here — the paired-channel block above already threw on
|
|
432
|
+
// down — but the verdict re-reads it from the same state the challenge
|
|
433
|
+
// would send over, so the two proofs cannot disagree about the transport.
|
|
434
|
+
channel,
|
|
435
|
+
registry: claimedTelegramTopics(),
|
|
436
|
+
poll: readTelegramPollState(),
|
|
437
|
+
dmOwner: readTelegramDmOwner(),
|
|
438
|
+
alive: deps.pidAlive ?? pidAlive,
|
|
439
|
+
lockAlive: deps.lockPidAlive ?? lockPidAlive,
|
|
440
|
+
fresh: deps.lockFresh ?? ((mtimeMs) => (deps.now ?? Date.now)() - mtimeMs < TELEGRAM_LOCK_FRESH_MS),
|
|
441
|
+
};
|
|
442
|
+
const verdict = telegramPlumbingVerdict(sendTopic, { dirs: scan }, probe);
|
|
443
|
+
if (!verdict.ok) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
`arm: claim-only proof refused — ${claimOnlyFailureText(verdict.reason)}. ` +
|
|
446
|
+
`NOT armed; no marker was written`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
writeArmedMarker(path, channel.owner, arm);
|
|
450
|
+
return { path, alreadyArmed, owner: channel.owner, proof };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// The transcript proof needs a session tree to poll. The claim-only verdict
|
|
454
|
+
// needs no such thing — it reads session identity from omp-telegram's own
|
|
455
|
+
// state — so this stop stays on the challenge path only.
|
|
357
456
|
const dirs = armSessionScanDirs(tick.cwd, claimed);
|
|
358
457
|
if (dirs.every((d) => !existsSync(d))) {
|
|
359
458
|
throw new Error(
|
|
@@ -362,30 +461,12 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
362
461
|
);
|
|
363
462
|
}
|
|
364
463
|
|
|
365
|
-
const path = tick.config.armedFile;
|
|
366
|
-
// The gate as the heartbeat reads it, so "replaced previous marker" is not a
|
|
367
|
-
// lie about a fleet the shared marker was arming, and so the write below knows
|
|
368
|
-
// whether it is superseding that marker.
|
|
369
|
-
const arm = resolveArmState(path, tick.config.project ?? projectName);
|
|
370
|
-
const alreadyArmed = arm.armed;
|
|
371
464
|
const code = makeChallengeCode();
|
|
372
465
|
const text =
|
|
373
466
|
`Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
|
|
374
467
|
`Reply to this chat with exactly:\n${code}\n` +
|
|
375
468
|
`Nothing will be dispatched until that reply is seen in the orchestrator session.`;
|
|
376
469
|
|
|
377
|
-
// Prefer the project's live forum topic so arm challenges land where
|
|
378
|
-
// escalations already do (#318), following the bridge's current claim when the
|
|
379
|
-
// pinned id has gone stale (#407). Missing project config keeps flat-chat 0.13.
|
|
380
|
-
let topicId: number | undefined;
|
|
381
|
-
if (named !== undefined) {
|
|
382
|
-
try {
|
|
383
|
-
topicId = resolveProjectTopicId(findProject(loadConfig(), named));
|
|
384
|
-
} catch {
|
|
385
|
-
/* no project config */
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
470
|
const send = deps.sendChallenge ?? sendTelegramMessage;
|
|
390
471
|
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
391
472
|
// Read before the send, not after: a transcript untouched since this instant
|
|
@@ -397,7 +478,7 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
397
478
|
// and cleared the moment this end settles (#415).
|
|
398
479
|
recordArmChallenge(named, code, sentAt + timeoutMs);
|
|
399
480
|
try {
|
|
400
|
-
await send(token, channel.owner, text,
|
|
481
|
+
await send(token, channel.owner, text, sendTopic);
|
|
401
482
|
} catch (err) {
|
|
402
483
|
// The challenge never went out, so it must not linger as a classifiable
|
|
403
484
|
// proof either.
|
|
@@ -443,18 +524,12 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
443
524
|
);
|
|
444
525
|
}
|
|
445
526
|
|
|
446
|
-
|
|
447
|
-
writeFileSync(path, `armed ${new Date().toISOString()} owner=${channel.owner}\n`, { mode: 0o600 });
|
|
448
|
-
// This project now has its own marker, so the shared one it was borrowing has
|
|
449
|
-
// done its last job. Left in place it would survive the next `disarm` as a
|
|
450
|
-
// marker that re-arms the fleet, and turn into a meaningless legacy warning
|
|
451
|
-
// the moment a second project is configured.
|
|
452
|
-
if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
|
|
527
|
+
writeArmedMarker(path, channel.owner, arm);
|
|
453
528
|
// The transcript proof landed and this project is armed: the record has done
|
|
454
529
|
// its job. A stale record would also keep a later unsolicited lookalike alive
|
|
455
530
|
// longer than the fresh challenge it was cut for.
|
|
456
531
|
clearArmChallenge(named);
|
|
457
|
-
return { path, alreadyArmed, owner: channel.owner, challenge: code };
|
|
532
|
+
return { path, alreadyArmed, owner: channel.owner, challenge: code, proof };
|
|
458
533
|
}
|
|
459
534
|
|
|
460
535
|
export interface HoldResult {
|
|
@@ -1311,8 +1386,24 @@ function servedProjectName(payload: object): string | undefined {
|
|
|
1311
1386
|
return undefined;
|
|
1312
1387
|
}
|
|
1313
1388
|
|
|
1314
|
-
export
|
|
1315
|
-
|
|
1389
|
+
export type FleetStatusReport = StatusSnapshot & {
|
|
1390
|
+
observedAt: number;
|
|
1391
|
+
layers: FleetLayers;
|
|
1392
|
+
daemon: FleetDaemonProbe | undefined;
|
|
1393
|
+
telegram: TelegramHealth;
|
|
1394
|
+
codeGraph: CodeGraphHealth;
|
|
1395
|
+
brief: string | undefined;
|
|
1396
|
+
decisions: string | undefined;
|
|
1397
|
+
failureClasses: string | undefined;
|
|
1398
|
+
workerPhases: { issue: number; phase: WorkerPausePhase }[];
|
|
1399
|
+
intake: string | undefined;
|
|
1400
|
+
lastStop: DaemonStop | undefined;
|
|
1401
|
+
siblings: { project: string; live: number }[];
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
/** Collects the complete status payload once for both text and JSON renderers. */
|
|
1405
|
+
export async function collectFleetStatus(projectName?: string): Promise<FleetStatusReport> {
|
|
1406
|
+
const snapshot = statusSnapshot(projectName);
|
|
1316
1407
|
const layers = fleetLayers(projectName);
|
|
1317
1408
|
const project = findProject(loadConfig(), projectName);
|
|
1318
1409
|
const rec = livingDaemon();
|
|
@@ -1325,7 +1416,7 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1325
1416
|
// Read here rather than in `statusSnapshot`, which is synchronous and used
|
|
1326
1417
|
// by callers that must not shell out. An unmetered project never spawns
|
|
1327
1418
|
// the provider at all.
|
|
1328
|
-
readPlanUsage(
|
|
1419
|
+
readPlanUsage(snapshot.caps.planUsage, sharedUsageSource()),
|
|
1329
1420
|
// Same reasoning as `planUsage`: the snapshot is synchronous, this read is
|
|
1330
1421
|
// a shell-out, and undefined on any failure — one missing row, never a
|
|
1331
1422
|
// broken report (#188).
|
|
@@ -1347,7 +1438,9 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1347
1438
|
const healthBody = projectHealth.kind === "ok" ? rawHealth?.body : undefined;
|
|
1348
1439
|
const cached = codeGraphFromHealthz(healthBody, project.name);
|
|
1349
1440
|
const codeGraph = cached ?? (await probeCodeGraph(project));
|
|
1350
|
-
const workerPhases = workerPhasesFromHealthz(healthBody, project.name)
|
|
1441
|
+
const workerPhases = [...workerPhasesFromHealthz(healthBody, project.name)].map(
|
|
1442
|
+
([issue, phase]) => ({ issue, phase }),
|
|
1443
|
+
);
|
|
1351
1444
|
// The newest host-wide stop/restart provenance (#378). Read here — not in
|
|
1352
1445
|
// `statusSnapshot`, which is synchronous and belongs to the daemon module —
|
|
1353
1446
|
// and rendered identically from either project: the daemon_stops table is
|
|
@@ -1359,32 +1452,54 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1359
1452
|
try {
|
|
1360
1453
|
lastStop = store.latestDaemonStop();
|
|
1361
1454
|
// Shared-daemon visibility (#545): every configured project other than the
|
|
1362
|
-
// one being viewed, with its live-run count.
|
|
1363
|
-
// as the provenance above, so a status reader can tell "my fleet is idle"
|
|
1364
|
-
// from "the process I am about to stop is busy".
|
|
1455
|
+
// one being viewed, with its live-run count.
|
|
1365
1456
|
siblings = loadConfig()
|
|
1366
1457
|
.projects.filter((p) => p.name !== project.name)
|
|
1367
1458
|
.map((p) => ({ project: p.name, live: store.liveRuns(p.name).length }));
|
|
1368
1459
|
} finally {
|
|
1369
1460
|
store.close();
|
|
1370
1461
|
}
|
|
1371
|
-
return
|
|
1372
|
-
|
|
1462
|
+
return {
|
|
1463
|
+
...snapshot,
|
|
1464
|
+
planUsage,
|
|
1465
|
+
github,
|
|
1466
|
+
observedAt: Date.now(),
|
|
1373
1467
|
layers,
|
|
1374
1468
|
daemon,
|
|
1375
1469
|
telegram,
|
|
1376
|
-
Date.now(),
|
|
1377
1470
|
codeGraph,
|
|
1378
|
-
briefStatusLine(project),
|
|
1379
|
-
decisionStatusLine(project.name),
|
|
1380
|
-
failureClassBlock(project.name),
|
|
1471
|
+
brief: briefStatusLine(project),
|
|
1472
|
+
decisions: decisionStatusLine(project.name),
|
|
1473
|
+
failureClasses: failureClassBlock(project.name),
|
|
1381
1474
|
workerPhases,
|
|
1382
|
-
intakeStatusLine(project.name),
|
|
1475
|
+
intake: intakeStatusLine(project.name),
|
|
1383
1476
|
lastStop,
|
|
1384
1477
|
siblings,
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
export function renderFleetStatusReport(report: FleetStatusReport): string {
|
|
1482
|
+
return formatFleetStatus(
|
|
1483
|
+
report,
|
|
1484
|
+
report.layers,
|
|
1485
|
+
report.daemon,
|
|
1486
|
+
report.telegram,
|
|
1487
|
+
report.observedAt,
|
|
1488
|
+
report.codeGraph,
|
|
1489
|
+
report.brief,
|
|
1490
|
+
report.decisions,
|
|
1491
|
+
report.failureClasses,
|
|
1492
|
+
new Map(report.workerPhases.map(({ issue, phase }) => [issue, phase])),
|
|
1493
|
+
report.intake,
|
|
1494
|
+
report.lastStop,
|
|
1495
|
+
report.siblings,
|
|
1385
1496
|
);
|
|
1386
1497
|
}
|
|
1387
1498
|
|
|
1499
|
+
export async function renderStatus(projectName?: string): Promise<string> {
|
|
1500
|
+
return renderFleetStatusReport(await collectFleetStatus(projectName));
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1388
1503
|
/**
|
|
1389
1504
|
* One line naming the brief layout, or nothing when it cannot be read.
|
|
1390
1505
|
*
|
|
@@ -1692,6 +1807,68 @@ function makeChallengeCode(): string {
|
|
|
1692
1807
|
return `FLEET-${hex}`;
|
|
1693
1808
|
}
|
|
1694
1809
|
|
|
1810
|
+
/**
|
|
1811
|
+
* The one armed-marker write both proofs share: same content, same mode, and
|
|
1812
|
+
* the same restamp of the pre-per-project shared marker the heartbeat still
|
|
1813
|
+
* honours — a project that just armed must not leave the bare marker around to
|
|
1814
|
+
* re-arm future fleets through `disarm` (#316).
|
|
1815
|
+
*/
|
|
1816
|
+
function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
|
|
1817
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1818
|
+
writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
|
|
1819
|
+
if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* The verdict's view of the session surface: the same dirs a challenge would
|
|
1824
|
+
* watch, minus the transcript throw. An outside-tree claim is not a reason to
|
|
1825
|
+
* stop here — the claim-only verdict names that failure itself
|
|
1826
|
+
* (`claim-session-outside` / `dm-owner-unrelated`) with the same session
|
|
1827
|
+
* identity rules, so the refusal carries the fact, not an early transcript
|
|
1828
|
+
* wording.
|
|
1829
|
+
*/
|
|
1830
|
+
function armVerdictScanDirs(cwd: string, claimed: string | undefined): string[] {
|
|
1831
|
+
const cwdDir = sessionDirForCwd(cwd);
|
|
1832
|
+
if (claimed === undefined) return [cwdDir];
|
|
1833
|
+
const root = sessionsRoot();
|
|
1834
|
+
const claimDir = dirname(claimed);
|
|
1835
|
+
if (claimDir === cwdDir) return [cwdDir];
|
|
1836
|
+
const insideTree = claimDir === root || claimDir.startsWith(join(root, sep));
|
|
1837
|
+
return insideTree ? [cwdDir, claimDir] : [cwdDir];
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* What each claim-only refusal names, in the operator's words (#613). The
|
|
1842
|
+
* verdict's reason enum is the fact; this is the sentence that says it — the
|
|
1843
|
+
* same facts the doctor's `telegram-plumbing` finding spells out, without the
|
|
1844
|
+
* finding's remediation (the refusal is one line an unattended recovery can
|
|
1845
|
+
* escalate verbatim).
|
|
1846
|
+
*/
|
|
1847
|
+
function claimOnlyFailureText(reason: TelegramPlumbingFailureReason): string {
|
|
1848
|
+
switch (reason) {
|
|
1849
|
+
case "channel-down":
|
|
1850
|
+
return "the paired inbound channel is not up";
|
|
1851
|
+
case "registry-unreadable":
|
|
1852
|
+
return "omp-telegram's threads.json is absent, unreadable or malformed — the topic's claim liveness cannot be verified";
|
|
1853
|
+
case "no-topic-claim":
|
|
1854
|
+
return "no live omp-telegram claim carries the topic arming would send into";
|
|
1855
|
+
case "claim-dead":
|
|
1856
|
+
return "the topic's omp-telegram claim records no live pid — the pane behind it is gone";
|
|
1857
|
+
case "claim-session-outside":
|
|
1858
|
+
return "the topic's claim names a session file outside the session directories arm scans — the reply could never be seen";
|
|
1859
|
+
case "no-dm-owner":
|
|
1860
|
+
return "no flat-chat reply recipient: no dm-owner.json, and bot.lock names no session inside the arm scan surface";
|
|
1861
|
+
case "dm-owner-dead":
|
|
1862
|
+
return "omp-telegram's dm-owner.json records no live pid — the flat-chat recipient is gone";
|
|
1863
|
+
case "dm-owner-unrelated":
|
|
1864
|
+
return "the dm-owner.json session sits outside the arm scan surface — a flat reply would land where no challenge can read it";
|
|
1865
|
+
case "no-poller-state":
|
|
1866
|
+
return "omp-telegram's bot.lock is absent, unreadable or malformed — nothing owns the poll, so nothing is receiving inbound";
|
|
1867
|
+
case "poller-dead":
|
|
1868
|
+
return "omp-telegram's bot.lock records no live owner and no fresh heartbeat — Telegram isn't being polled";
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1695
1872
|
async function sendTelegramMessage(
|
|
1696
1873
|
token: string,
|
|
1697
1874
|
owner: string,
|
package/src/gitops.ts
CHANGED
|
@@ -127,62 +127,141 @@ export interface RunLaneInput {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
130
|
+
* One file a run's lane covers, tagged with the probe read that produced it.
|
|
131
|
+
* The source is what lets a `file-lane` hold say *which* occupancy it is — the
|
|
132
|
+
* run's live worktree (`"worktree"`: uncommitted authored changes, from the
|
|
133
|
+
* porcelain read) or its committed work (`"branch"`: the base-relative diff,
|
|
134
|
+
* worktree or mirror) — so "held by run #N" can be told apart from the
|
|
135
|
+
* base-reconciliation noise #684 filters out entirely.
|
|
134
136
|
*/
|
|
135
|
-
export
|
|
137
|
+
export interface LaneFile {
|
|
138
|
+
file: string;
|
|
139
|
+
source: LaneSource;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Which of the probe's reads produced one occupied file. */
|
|
143
|
+
export type LaneSource = "worktree" | "branch";
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The set of files one active run has touched relative to base, deduped and
|
|
147
|
+
* sorted, each tagged with the read that produced it. Persistable but
|
|
148
|
+
* advisory: the admitting gate treats an unreadable or absent lane as "no
|
|
149
|
+
* overlap" (fail open), so no probe bug can refuse a well-formed issue.
|
|
150
|
+
*/
|
|
151
|
+
export type RunLaneProbe = (input: RunLaneInput) => Promise<LaneFile[]>;
|
|
136
152
|
|
|
137
153
|
/** The real one, reading the run's own worktree and/or the mirror branch. */
|
|
138
154
|
export async function probeRunLane(
|
|
139
155
|
input: RunLaneInput,
|
|
140
156
|
exec: Exec = spawnCaptured,
|
|
141
|
-
): Promise<
|
|
142
|
-
const files = new
|
|
143
|
-
const add = (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
157
|
+
): Promise<LaneFile[]> {
|
|
158
|
+
const files = new Map<string, LaneSource>();
|
|
159
|
+
const add = (path: string, source: LaneSource): void => {
|
|
160
|
+
if (path !== "" && !files.has(path)) files.set(path, source);
|
|
161
|
+
};
|
|
162
|
+
const addDiff = (stdout: string, source: LaneSource): void => {
|
|
163
|
+
for (const line of stdout.split("\n")) add(line.trim(), source);
|
|
148
164
|
};
|
|
149
165
|
if (input.worktree !== "") {
|
|
150
166
|
const status = await exec(["git", "-C", input.worktree, "status", "--porcelain"], {});
|
|
151
167
|
if (status.code === 0) {
|
|
152
|
-
|
|
168
|
+
const { untracked, tracked } = parsePorcelain(status.stdout);
|
|
169
|
+
// Untracked files are authored by construction: a merge stages the files
|
|
170
|
+
// it brings in, it never leaves them untracked.
|
|
171
|
+
for (const path of untracked) add(path, "worktree");
|
|
172
|
+
if (tracked.length > 0) {
|
|
173
|
+
// While a merge of the base is in progress — the reconciliation every
|
|
174
|
+
// continuation brief requires — the porcelain read reports every file
|
|
175
|
+
// the merge staged, which is every file the base changed since the
|
|
176
|
+
// branch's merge-base, none of them the run's own work (#684). The
|
|
177
|
+
// three-dot diff is immune (merge-base relative), so the porcelain
|
|
178
|
+
// half is gated on a live MERGE_HEAD and filtered to files whose
|
|
179
|
+
// worktree content has actually diverged from base: reconciliation
|
|
180
|
+
// brings base content forward (worktree == base), authored work does
|
|
181
|
+
// not. Unresolved conflicts were already excluded by
|
|
182
|
+
// {@link parsePorcelain}: a conflicted file is not the run's
|
|
183
|
+
// resolution. An unreadable divergence read fails open — no tracked
|
|
184
|
+
// occupancy is claimed while the merge makes the read ambiguous.
|
|
185
|
+
const merge = await exec(
|
|
186
|
+
["git", "-C", input.worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD"],
|
|
187
|
+
{},
|
|
188
|
+
);
|
|
189
|
+
if (merge.code === 0) {
|
|
190
|
+
const diverged = await exec(
|
|
191
|
+
["git", "-C", input.worktree, "diff", "--name-only", input.baseRef],
|
|
192
|
+
{},
|
|
193
|
+
);
|
|
194
|
+
if (diverged.code === 0) {
|
|
195
|
+
const set = new Set(
|
|
196
|
+
diverged.stdout.split("\n").map((line) => line.trim()).filter((line) => line !== ""),
|
|
197
|
+
);
|
|
198
|
+
for (const path of tracked) if (set.has(path)) add(path, "worktree");
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
for (const path of tracked) add(path, "worktree");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
153
204
|
}
|
|
154
205
|
const diff = await exec(
|
|
155
206
|
["git", "-C", input.worktree, "diff", "--name-only", `${input.baseRef}...HEAD`],
|
|
156
207
|
{},
|
|
157
208
|
);
|
|
158
|
-
if (diff.code === 0)
|
|
209
|
+
if (diff.code === 0) addDiff(diff.stdout, "branch");
|
|
159
210
|
}
|
|
160
211
|
if (input.branchRef !== undefined && input.mirror !== undefined && input.mirror !== "") {
|
|
161
212
|
const diff = await exec(
|
|
162
213
|
["git", "--git-dir", input.mirror, "diff", "--name-only", `${input.baseRef}...${input.branchRef}`],
|
|
163
214
|
{},
|
|
164
215
|
);
|
|
165
|
-
if (diff.code === 0)
|
|
216
|
+
if (diff.code === 0) addDiff(diff.stdout, "branch");
|
|
166
217
|
}
|
|
167
|
-
return [...files
|
|
218
|
+
return [...files.entries()]
|
|
219
|
+
.map(([file, source]) => ({ file, source }))
|
|
220
|
+
.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
168
221
|
}
|
|
169
222
|
|
|
170
223
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
224
|
+
* The porcelain codes `git status --porcelain` emits for an unmerged index
|
|
225
|
+
* entry — a merge/rebase/cherry-pick conflict. The X/Y letters are then the
|
|
226
|
+
* stage-2/stage-3 states and only ever appear in this exact set (git-status(1)
|
|
227
|
+
* lists them all), so membership is unambiguous.
|
|
228
|
+
*/
|
|
229
|
+
const UNMERGED_PORCELAIN: Record<string, true> = {
|
|
230
|
+
UU: true,
|
|
231
|
+
AA: true,
|
|
232
|
+
DD: true,
|
|
233
|
+
AU: true,
|
|
234
|
+
UA: true,
|
|
235
|
+
DU: true,
|
|
236
|
+
UD: true,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Parse `git status --porcelain` output into the paths it names, split by how
|
|
241
|
+
* they may occupy a lane. A rename or copy is reported as `XY old -> new`, and
|
|
242
|
+
* the *new* path is the one that occupies the lane (it is the path the other
|
|
243
|
+
* run's future writes would collide with), so the arrow form keeps only its
|
|
244
|
+
* target. Untracked (`??`) paths are always the run's own work. Unmerged
|
|
245
|
+
* (`UU`-class) paths are dropped: a conflicted file is not the run's
|
|
246
|
+
* resolution, so it neither occupies a lane nor proves authorship.
|
|
175
247
|
*/
|
|
176
|
-
function parsePorcelain(stdout: string): string[] {
|
|
177
|
-
const
|
|
248
|
+
function parsePorcelain(stdout: string): { untracked: string[]; tracked: string[] } {
|
|
249
|
+
const untracked: string[] = [];
|
|
250
|
+
const tracked: string[] = [];
|
|
178
251
|
for (const raw of stdout.split("\n")) {
|
|
179
252
|
if (raw.length < 3) continue;
|
|
253
|
+
const code = raw.slice(0, 2);
|
|
254
|
+
if (code === "??") {
|
|
255
|
+
untracked.push(raw.slice(2).trim());
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (UNMERGED_PORCELAIN[code] === true) continue;
|
|
180
259
|
let rest = raw.slice(2).trim();
|
|
181
260
|
const arrow = rest.indexOf(" -> ");
|
|
182
261
|
if (arrow !== -1) rest = rest.slice(arrow + 4).trim();
|
|
183
|
-
if (rest !== "")
|
|
262
|
+
if (rest !== "") tracked.push(rest);
|
|
184
263
|
}
|
|
185
|
-
return
|
|
264
|
+
return { untracked, tracked };
|
|
186
265
|
}
|
|
187
266
|
|
|
188
267
|
/** GitHub slug parsed off the clone URL, falling back to the routing name. */
|