omp-conductor 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +12 -8
- package/package.json +1 -1
- package/schema/config.schema.json +40 -1
- package/src/admission.ts +263 -44
- package/src/ask.ts +39 -3
- package/src/availability.ts +27 -1
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +38 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/arm.ts +6 -3
- package/src/commands/message.ts +32 -4
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +53 -0
- package/src/config.ts +97 -1
- package/src/daemon.ts +1479 -1483
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +71 -15
- package/src/fleet.ts +189 -34
- package/src/gitops.ts +103 -24
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +43 -7
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +430 -162
- package/src/release-policy.ts +177 -5
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +1257 -78
- package/src/setup.ts +153 -6
- package/src/status-render.ts +36 -4
- package/src/store.ts +411 -17
- package/src/tracker/github.ts +607 -12
- package/src/types.ts +331 -5
- 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 +270 -13
- package/src/worker.ts +239 -6
- package/src/worktree.ts +115 -8
- 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
|
|
|
@@ -148,10 +188,23 @@ export function providerCreditRefusal(error: {
|
|
|
148
188
|
}
|
|
149
189
|
|
|
150
190
|
/** Provider text that names a per-request stream fault. Deliberately narrow:
|
|
151
|
-
* a 429 is rate limiting and a 402 is credit — different remedies (#220).
|
|
191
|
+
* a 429 is rate limiting and a 402 is credit — different remedies (#220).
|
|
192
|
+
*
|
|
193
|
+
* The structural `kind` that `readSessionError` marks outranks this list: a
|
|
194
|
+
* transcript record the harness itself attributed to a provider abort is a
|
|
195
|
+
* per-request stream fault whatever the provider called it, so no vendor
|
|
196
|
+
* prose needs enumerating here (#743). The list remains for transcripts that
|
|
197
|
+
* only ever carried prose, like the original "stream stalled" stall. */
|
|
152
198
|
const TRANSIENT_FAULT_SIGNATURES = ["stream stalled"] as const;
|
|
153
199
|
|
|
154
|
-
export function providerTransientFault(error: {
|
|
200
|
+
export function providerTransientFault(error: {
|
|
201
|
+
status?: number;
|
|
202
|
+
message: string;
|
|
203
|
+
kind?: "provider-stream";
|
|
204
|
+
}): string | undefined {
|
|
205
|
+
if (error.kind === "provider-stream") {
|
|
206
|
+
return error.message.split("\n")[0]?.trim() ?? error.message;
|
|
207
|
+
}
|
|
155
208
|
const text = error.message.toLowerCase();
|
|
156
209
|
if (!TRANSIENT_FAULT_SIGNATURES.some((s) => text.includes(s))) return undefined;
|
|
157
210
|
return error.message.split("\n")[0]?.trim() ?? error.message;
|
|
@@ -466,16 +519,19 @@ export function classifyRun(
|
|
|
466
519
|
// beats ci-deterministic because charging an implementation attempt for a
|
|
467
520
|
// rate limit is exactly the waste that class exists to prevent.
|
|
468
521
|
if (facts.failingLog !== undefined) {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
522
|
+
// Matched exactly, without paren-stripping: GitHub's full setup/codeload
|
|
523
|
+
// sentence is its own closed entry above, and a generic paren-strip would
|
|
524
|
+
// broaden the other signatures into waiving real deterministic failures
|
|
525
|
+
// (#177). A bare "429" or "too many requests" (parenthesized or not)
|
|
526
|
+
// still never matches without that sentence.
|
|
527
|
+
const signature = infraLogSignature(facts.failingLog);
|
|
528
|
+
if (signature !== undefined) {
|
|
529
|
+
const check = checks.find((c) => normalise(c.state) === "failure");
|
|
530
|
+
return {
|
|
531
|
+
cls: "ci-infra",
|
|
532
|
+
recovery: "rerun-checks",
|
|
533
|
+
evidence: `infrastructure signature in ${check?.name === undefined ? "failed check" : check.name} log: "${signature}"`,
|
|
534
|
+
};
|
|
479
535
|
}
|
|
480
536
|
}
|
|
481
537
|
const failing = unresolved.filter((c) => normalise(c.state) === "failure");
|
package/src/fleet.ts
CHANGED
|
@@ -27,15 +27,29 @@ 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
54
|
import { isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
41
55
|
import {
|
|
@@ -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 {
|
|
@@ -1068,8 +1143,26 @@ function bareReadProject(): string | undefined {
|
|
|
1068
1143
|
|
|
1069
1144
|
export function fleetLayers(projectName?: string): FleetLayers {
|
|
1070
1145
|
const rec = livingDaemon();
|
|
1146
|
+
// systemd is the authoritative liveness witness for a unit-owned daemon: a
|
|
1147
|
+
// pidfile that is missing, stale, or skewed against the unit's MainPID must
|
|
1148
|
+
// not declare the daemon dead (#716). Consulted only when the pidfile says
|
|
1149
|
+
// dead — a healthy record needs no shell-out, and a host without systemctl
|
|
1150
|
+
// answers `inactive`, so those hosts behave exactly as before. `failed`,
|
|
1151
|
+
// `unknown`, and a mid-restart unit (MainPID 0, process gone) all answer
|
|
1152
|
+
// undefined: a genuinely stopped daemon never reports running.
|
|
1153
|
+
let unitPid: number | undefined;
|
|
1154
|
+
if (rec === undefined) {
|
|
1155
|
+
const ownership = probeUnit();
|
|
1156
|
+
if (ownership.kind === "active" && isAlive(ownership.pid)) unitPid = ownership.pid;
|
|
1157
|
+
}
|
|
1158
|
+
const daemon: FleetLayers["daemon"] =
|
|
1159
|
+
rec !== undefined
|
|
1160
|
+
? { running: true, pid: rec.pid, port: rec.port }
|
|
1161
|
+
: unitPid !== undefined
|
|
1162
|
+
? { running: true, pid: unitPid }
|
|
1163
|
+
: { running: false };
|
|
1071
1164
|
const paused = isPaused(projectName ?? bareReadProject());
|
|
1072
|
-
const dispatch: DispatchLayer =
|
|
1165
|
+
const dispatch: DispatchLayer = daemon.running ? (paused ? "paused" : "running") : "stopped";
|
|
1073
1166
|
|
|
1074
1167
|
const tick = resolveTickConfig(projectName);
|
|
1075
1168
|
let ticks: TicksLayer;
|
|
@@ -1149,7 +1242,7 @@ export function fleetLayers(projectName?: string): FleetLayers {
|
|
|
1149
1242
|
...(tickConfigPath === undefined ? {} : { tickConfigPath }),
|
|
1150
1243
|
...(haltPath === undefined ? {} : { paneHaltPath: haltPath }),
|
|
1151
1244
|
paused,
|
|
1152
|
-
daemon
|
|
1245
|
+
daemon,
|
|
1153
1246
|
};
|
|
1154
1247
|
}
|
|
1155
1248
|
|
|
@@ -1732,6 +1825,68 @@ function makeChallengeCode(): string {
|
|
|
1732
1825
|
return `FLEET-${hex}`;
|
|
1733
1826
|
}
|
|
1734
1827
|
|
|
1828
|
+
/**
|
|
1829
|
+
* The one armed-marker write both proofs share: same content, same mode, and
|
|
1830
|
+
* the same restamp of the pre-per-project shared marker the heartbeat still
|
|
1831
|
+
* honours — a project that just armed must not leave the bare marker around to
|
|
1832
|
+
* re-arm future fleets through `disarm` (#316).
|
|
1833
|
+
*/
|
|
1834
|
+
function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
|
|
1835
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1836
|
+
writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
|
|
1837
|
+
if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* The verdict's view of the session surface: the same dirs a challenge would
|
|
1842
|
+
* watch, minus the transcript throw. An outside-tree claim is not a reason to
|
|
1843
|
+
* stop here — the claim-only verdict names that failure itself
|
|
1844
|
+
* (`claim-session-outside` / `dm-owner-unrelated`) with the same session
|
|
1845
|
+
* identity rules, so the refusal carries the fact, not an early transcript
|
|
1846
|
+
* wording.
|
|
1847
|
+
*/
|
|
1848
|
+
function armVerdictScanDirs(cwd: string, claimed: string | undefined): string[] {
|
|
1849
|
+
const cwdDir = sessionDirForCwd(cwd);
|
|
1850
|
+
if (claimed === undefined) return [cwdDir];
|
|
1851
|
+
const root = sessionsRoot();
|
|
1852
|
+
const claimDir = dirname(claimed);
|
|
1853
|
+
if (claimDir === cwdDir) return [cwdDir];
|
|
1854
|
+
const insideTree = claimDir === root || claimDir.startsWith(join(root, sep));
|
|
1855
|
+
return insideTree ? [cwdDir, claimDir] : [cwdDir];
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* What each claim-only refusal names, in the operator's words (#613). The
|
|
1860
|
+
* verdict's reason enum is the fact; this is the sentence that says it — the
|
|
1861
|
+
* same facts the doctor's `telegram-plumbing` finding spells out, without the
|
|
1862
|
+
* finding's remediation (the refusal is one line an unattended recovery can
|
|
1863
|
+
* escalate verbatim).
|
|
1864
|
+
*/
|
|
1865
|
+
function claimOnlyFailureText(reason: TelegramPlumbingFailureReason): string {
|
|
1866
|
+
switch (reason) {
|
|
1867
|
+
case "channel-down":
|
|
1868
|
+
return "the paired inbound channel is not up";
|
|
1869
|
+
case "registry-unreadable":
|
|
1870
|
+
return "omp-telegram's threads.json is absent, unreadable or malformed — the topic's claim liveness cannot be verified";
|
|
1871
|
+
case "no-topic-claim":
|
|
1872
|
+
return "no live omp-telegram claim carries the topic arming would send into";
|
|
1873
|
+
case "claim-dead":
|
|
1874
|
+
return "the topic's omp-telegram claim records no live pid — the pane behind it is gone";
|
|
1875
|
+
case "claim-session-outside":
|
|
1876
|
+
return "the topic's claim names a session file outside the session directories arm scans — the reply could never be seen";
|
|
1877
|
+
case "no-dm-owner":
|
|
1878
|
+
return "no flat-chat reply recipient: no dm-owner.json, and bot.lock names no session inside the arm scan surface";
|
|
1879
|
+
case "dm-owner-dead":
|
|
1880
|
+
return "omp-telegram's dm-owner.json records no live pid — the flat-chat recipient is gone";
|
|
1881
|
+
case "dm-owner-unrelated":
|
|
1882
|
+
return "the dm-owner.json session sits outside the arm scan surface — a flat reply would land where no challenge can read it";
|
|
1883
|
+
case "no-poller-state":
|
|
1884
|
+
return "omp-telegram's bot.lock is absent, unreadable or malformed — nothing owns the poll, so nothing is receiving inbound";
|
|
1885
|
+
case "poller-dead":
|
|
1886
|
+
return "omp-telegram's bot.lock records no live owner and no fresh heartbeat — Telegram isn't being polled";
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1735
1890
|
async function sendTelegramMessage(
|
|
1736
1891
|
token: string,
|
|
1737
1892
|
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. */
|