omp-conductor 0.16.0 → 0.16.2
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 +25 -0
- package/REFERENCE.md +3 -0
- package/package.json +2 -1
- package/schema/config.schema.json +1 -0
- package/src/admission.ts +111 -7
- package/src/briefs/orchestrator.md +21 -6
- package/src/briefs/policy.md +13 -5
- package/src/cli.ts +110 -381
- package/src/command-help.ts +220 -0
- package/src/command-manifest.ts +471 -0
- package/src/commands/complete.ts +93 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/setup.ts +68 -2
- package/src/commands/tail.ts +204 -44
- package/src/config-schema.ts +4 -0
- package/src/config.ts +31 -1
- package/src/daemon.ts +16 -0
- package/src/depends-on.ts +77 -28
- package/src/doctor.ts +120 -1
- package/src/escalate.ts +77 -4
- package/src/fleet.ts +127 -42
- package/src/setup-host.ts +29 -11
- package/src/setup-wizard.ts +10 -9
- package/src/transcript.ts +1 -1
- package/src/upgrade-verify.ts +25 -3
- package/src/upgrade.ts +61 -2
- package/src/worker.ts +196 -0
- package/src/worktree.ts +13 -1
- package/systemd/omp-conductor-recover.sh +1 -1
- package/systemd/recover-unit-test.sh +2 -2
package/src/doctor.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
} from "node:fs";
|
|
36
36
|
import { spawnSync } from "node:child_process";
|
|
37
37
|
import { homedir } from "node:os";
|
|
38
|
-
import { dirname, join } from "node:path";
|
|
38
|
+
import { dirname, join, sep } from "node:path";
|
|
39
39
|
import type { Stats } from "node:fs";
|
|
40
40
|
import { Database } from "bun:sqlite";
|
|
41
41
|
import { stringify } from "yaml";
|
|
@@ -54,9 +54,17 @@ import {
|
|
|
54
54
|
DEFAULT_HERDR_UNIT,
|
|
55
55
|
probeTelegramHealth,
|
|
56
56
|
resolveHerdrSessionWithBridge,
|
|
57
|
+
sessionsRoot,
|
|
58
|
+
sessionDirForCwd,
|
|
57
59
|
telegramStateDir,
|
|
58
60
|
} from "./fleet.ts";
|
|
59
61
|
import type { TelegramHealth } from "./status-render.ts";
|
|
62
|
+
import {
|
|
63
|
+
claimForProject,
|
|
64
|
+
claimedTelegramTopics,
|
|
65
|
+
telegramTopicsTidy,
|
|
66
|
+
type ClaimedTopic,
|
|
67
|
+
} from "./escalate.ts";
|
|
60
68
|
import {
|
|
61
69
|
herdrConductorPluginConfigDir,
|
|
62
70
|
planHostRuntime,
|
|
@@ -204,6 +212,10 @@ export interface DoctorDeps {
|
|
|
204
212
|
herdrConfig?: () => string | undefined;
|
|
205
213
|
/** The live herdr-conductor plugin `config.env`, or undefined. */
|
|
206
214
|
herdrEnv?: () => string | undefined;
|
|
215
|
+
/** Live omp-telegram topic claims (threads.json), for the #600 probes. */
|
|
216
|
+
claimedTopics?: () => ClaimedTopic[];
|
|
217
|
+
/** Whether the bridge's access.json runs per-session topic tidy on. */
|
|
218
|
+
topicsTidy?: () => boolean;
|
|
207
219
|
/** The fleet agent name the tick config of one project names, or undefined
|
|
208
220
|
* when there is no (readable) tick — the expected live herdr pane identity. */
|
|
209
221
|
tickAgentName?: (project: ProjectConfig) => string | undefined;
|
|
@@ -1068,6 +1080,103 @@ function herdrAgentNameProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1068
1080
|
);
|
|
1069
1081
|
}
|
|
1070
1082
|
|
|
1083
|
+
/**
|
|
1084
|
+
* #600 check — the orchestrator's claimed session file vs the directory arm
|
|
1085
|
+
* scans.
|
|
1086
|
+
*
|
|
1087
|
+
* A pane resumed from a session created elsewhere keeps the original transcript
|
|
1088
|
+
* path (herdr pins the pane to it), so the claimed file can live outside the
|
|
1089
|
+
* session directory the tick cwd implies. arm follows the claim, so the
|
|
1090
|
+
* mismatch alone is handled — but it is exactly the shape of host that once
|
|
1091
|
+
* made arming silently impossible, and an operator reading transcript paths
|
|
1092
|
+
* should not have to rediscover it. A claimed file *outside the session tree*
|
|
1093
|
+
* is worse: arm refuses to arm there (the reply could never be seen), so
|
|
1094
|
+
* doctor fails rather than letting the fleet sit disarmed.
|
|
1095
|
+
*/
|
|
1096
|
+
function armSessionDirProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
1097
|
+
const claims = probes.claimedTopics();
|
|
1098
|
+
if (claims.length === 0) {
|
|
1099
|
+
return passFinding(
|
|
1100
|
+
"arm-session-dir",
|
|
1101
|
+
`[${p.name}] no live omp-telegram claim — arm scans the tick-cwd session directory`,
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
const claim = claimForProject(claims, p.name);
|
|
1105
|
+
if (claim === undefined || claim.sessionFile === undefined) {
|
|
1106
|
+
return passFinding(
|
|
1107
|
+
"arm-session-dir",
|
|
1108
|
+
`[${p.name}] the live claim names no session file — arm scans the tick-cwd session directory`,
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
const root = sessionsRoot();
|
|
1112
|
+
const claimDir = dirname(claim.sessionFile);
|
|
1113
|
+
const scanDir = sessionDirForCwd(tickCwdForProject(p));
|
|
1114
|
+
if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
|
|
1115
|
+
return failFinding(
|
|
1116
|
+
"arm-session-dir",
|
|
1117
|
+
`[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} is outside the session tree arm scans (${root}) — ` +
|
|
1118
|
+
`arm cannot arm while the reply can only land there`,
|
|
1119
|
+
`start the orchestrator pane's session under ${root}, or from the tick cwd ${scanDir}`,
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
if (claimDir !== scanDir) {
|
|
1123
|
+
return warnFinding(
|
|
1124
|
+
"arm-session-dir",
|
|
1125
|
+
`[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} lives in ${claimDir}, not the tick-cwd session directory ${scanDir} — ` +
|
|
1126
|
+
`arm follows the claim, so arming works, but this is the resumed-pane shape #600 names`,
|
|
1127
|
+
`optional tidy-up: start the pane's session from the tick cwd so transcript and cwd agree again`,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
return passFinding(
|
|
1131
|
+
"arm-session-dir",
|
|
1132
|
+
`[${p.name}] the orchestrator's claimed session file is inside the session directory arm scans`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* #600 check — a pinned escalation.telegramTopicId that no live claim carries.
|
|
1138
|
+
*
|
|
1139
|
+
* The wizard pins from the claims at setup time, but the bridge re-claims
|
|
1140
|
+
* topics across restarts and conductor restarts the service itself (#407), so
|
|
1141
|
+
* the pin drifts. On a host with `topicsTidy` on it drifts *structurally*:
|
|
1142
|
+
* every pane exit closes the topic, so a pinned id can never be live again.
|
|
1143
|
+
* There the pin is vestigial — reporting a dead id as the live destination is
|
|
1144
|
+
* the exact false belief this check exists to remove.
|
|
1145
|
+
*/
|
|
1146
|
+
function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
1147
|
+
const pinned = p.escalation.telegramTopicId;
|
|
1148
|
+
if (pinned === undefined) {
|
|
1149
|
+
return passFinding(
|
|
1150
|
+
"topic-pin",
|
|
1151
|
+
`[${p.name}] no pinned topic id — sends follow the live claim or the flat chat`,
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
const claims = probes.claimedTopics();
|
|
1155
|
+
if (claims.length === 0) {
|
|
1156
|
+
return passFinding(
|
|
1157
|
+
"topic-pin",
|
|
1158
|
+
`[${p.name}] pinned topic ${pinned} — no live claims to compare (bridge state unreadable, or not a forum host)`,
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
if (claims.some((claim) => claim.threadId === pinned)) {
|
|
1162
|
+
return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim`);
|
|
1163
|
+
}
|
|
1164
|
+
if (probes.topicsTidy()) {
|
|
1165
|
+
return warnFinding(
|
|
1166
|
+
"topic-pin",
|
|
1167
|
+
`[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, and this host runs topicsTidy — ` +
|
|
1168
|
+
`the pin is vestigial and can never be live again (every pane exit closes its topic)`,
|
|
1169
|
+
`remove escalation.telegramTopicId from ${p.name}'s config (sends already follow the live claim), or turn topicsTidy off`,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
return warnFinding(
|
|
1173
|
+
"topic-pin",
|
|
1174
|
+
`[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims (${claims.map((c) => c.threadId).join(", ")}) — ` +
|
|
1175
|
+
`sends fall back to the live claim or the flat chat`,
|
|
1176
|
+
`update or remove escalation.telegramTopicId in ${p.name}'s config`,
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1071
1180
|
/**
|
|
1072
1181
|
* #541 check 2 — `[session] resume_agents_on_restore` on the live herdr config.
|
|
1073
1182
|
*
|
|
@@ -1260,6 +1369,12 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1260
1369
|
findings.push(
|
|
1261
1370
|
passFinding("herdr-agent-name", projectProblem === undefined ? "no project resolved — nothing to check" : `agent name uncheckable: ${projectProblem}`),
|
|
1262
1371
|
);
|
|
1372
|
+
findings.push(
|
|
1373
|
+
passFinding("arm-session-dir", projectProblem === undefined ? "no project resolved — nothing to check" : `arm session dir uncheckable: ${projectProblem}`),
|
|
1374
|
+
);
|
|
1375
|
+
findings.push(
|
|
1376
|
+
passFinding("topic-pin", projectProblem === undefined ? "no project resolved — nothing to check" : `topic pin uncheckable: ${projectProblem}`),
|
|
1377
|
+
);
|
|
1263
1378
|
findings.push(
|
|
1264
1379
|
passFinding("omp-settings", projectProblem === undefined ? "no project resolved — nothing to check" : `omp settings overlay uncheckable: ${projectProblem}`),
|
|
1265
1380
|
);
|
|
@@ -1267,6 +1382,8 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1267
1382
|
for (const p of projects) {
|
|
1268
1383
|
findings.push(await labelProbe(probes, p));
|
|
1269
1384
|
findings.push(herdrAgentNameProbe(probes, p));
|
|
1385
|
+
findings.push(armSessionDirProbe(probes, p));
|
|
1386
|
+
findings.push(topicPinProbe(probes, p));
|
|
1270
1387
|
findings.push(ompSettingsProbe(p, sessionRootState));
|
|
1271
1388
|
}
|
|
1272
1389
|
}
|
|
@@ -1331,6 +1448,8 @@ export function defaultProbes(): Probes {
|
|
|
1331
1448
|
herdrSession: () => resolveHerdrSessionWithBridge(),
|
|
1332
1449
|
herdrConfig: defaultHerdrConfig,
|
|
1333
1450
|
herdrEnv: defaultHerdrEnv,
|
|
1451
|
+
claimedTopics: () => claimedTelegramTopics(),
|
|
1452
|
+
topicsTidy: () => telegramTopicsTidy(),
|
|
1334
1453
|
tickAgentName: (p) => {
|
|
1335
1454
|
const tick = readTickConfig(tickCwdForProject(p));
|
|
1336
1455
|
if (tick.kind !== "ok") return undefined;
|
package/src/escalate.ts
CHANGED
|
@@ -436,6 +436,13 @@ export interface ClaimedTopic {
|
|
|
436
436
|
name: string;
|
|
437
437
|
/** The herdr space the claiming pane sits in, when the bridge captured one. */
|
|
438
438
|
workspaceLabel?: string;
|
|
439
|
+
/**
|
|
440
|
+
* The transcript the claiming pane actually writes, when the bridge captured
|
|
441
|
+
* one. This is the *live* session file — herdr pins a restored pane to it
|
|
442
|
+
* (`omp --resume=<that path>`) — so it can sit in a different session
|
|
443
|
+
* directory than the pane's cwd implies (#600).
|
|
444
|
+
*/
|
|
445
|
+
sessionFile?: string;
|
|
439
446
|
}
|
|
440
447
|
|
|
441
448
|
/**
|
|
@@ -470,17 +477,47 @@ export function resolveProjectTopicId(project: ProjectConfig): number | undefine
|
|
|
470
477
|
const claims = claimedTelegramTopics();
|
|
471
478
|
if (claims.length === 0) return pinned;
|
|
472
479
|
if (claims.some((claim) => claim.threadId === pinned)) return pinned;
|
|
473
|
-
const
|
|
474
|
-
const match = bySpace ?? soleClaim(claims, (claim) => claim.name === project.name);
|
|
480
|
+
const match = claimForProject(claims, project.name);
|
|
475
481
|
if (match === undefined) return pinned;
|
|
476
482
|
warn(
|
|
477
483
|
`escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
|
|
478
484
|
`following omp-telegram's current "${project.name}" ` +
|
|
479
|
-
`${
|
|
485
|
+
`${match.workspaceLabel === project.name ? "herdr space claim" : "claim"} instead`,
|
|
480
486
|
);
|
|
481
487
|
return match.threadId;
|
|
482
488
|
}
|
|
483
489
|
|
|
490
|
+
/**
|
|
491
|
+
* The one claim answering to a project's identity — herdr space first, title
|
|
492
|
+
* second — or nothing when none or several do (#412).
|
|
493
|
+
*/
|
|
494
|
+
export function claimForProject(
|
|
495
|
+
claims: readonly ClaimedTopic[],
|
|
496
|
+
projectName: string,
|
|
497
|
+
): ClaimedTopic | undefined {
|
|
498
|
+
const bySpace = soleClaim(claims, (claim) => claim.workspaceLabel === projectName);
|
|
499
|
+
return bySpace ?? soleClaim(claims, (claim) => claim.name === projectName);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The transcript this project's live omp-telegram claim names — where the
|
|
504
|
+
* orchestrator pane's reply actually lands.
|
|
505
|
+
*
|
|
506
|
+
* `arm` derives its scan directory from the tick cwd, but a pane resumed from
|
|
507
|
+
* a session created elsewhere keeps the original transcript path (herdr pins
|
|
508
|
+
* the pane to it), so the cwd-derived directory is the one place the reply is
|
|
509
|
+
* guaranteed *not* to be (#600). The claim's `sessionFile` is the live answer;
|
|
510
|
+
* identity resolves exactly as {@link resolveProjectTopicId}'s substitution
|
|
511
|
+
* does, via {@link claimForProject}. Absent when the bridge has no claim or
|
|
512
|
+
* the claim names no file — the caller then falls back to the cwd-derived
|
|
513
|
+
* directory, which is the honest answer for a host that predates claims.
|
|
514
|
+
*/
|
|
515
|
+
export function resolveClaimedSessionFile(project: ProjectConfig): string | undefined {
|
|
516
|
+
const claims = claimedTelegramTopics();
|
|
517
|
+
if (claims.length === 0) return undefined;
|
|
518
|
+
return claimForProject(claims, project.name)?.sessionFile;
|
|
519
|
+
}
|
|
520
|
+
|
|
484
521
|
/**
|
|
485
522
|
* The one claim answering to `predicate`, or nothing when none or several do.
|
|
486
523
|
*
|
|
@@ -523,6 +560,7 @@ export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
|
|
|
523
560
|
if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
|
|
524
561
|
let name = id;
|
|
525
562
|
let workspaceLabel: string | undefined;
|
|
563
|
+
let sessionFile: string | undefined;
|
|
526
564
|
if (typeof entry === "object" && entry !== null) {
|
|
527
565
|
if ("name" in entry) {
|
|
528
566
|
const candidate = entry.name;
|
|
@@ -532,12 +570,47 @@ export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
|
|
|
532
570
|
const candidate = entry.workspaceLabel;
|
|
533
571
|
if (typeof candidate === "string" && candidate.trim() !== "") workspaceLabel = candidate.trim();
|
|
534
572
|
}
|
|
573
|
+
if ("sessionFile" in entry) {
|
|
574
|
+
const candidate = entry.sessionFile;
|
|
575
|
+
if (typeof candidate === "string" && candidate.trim() !== "") sessionFile = candidate.trim();
|
|
576
|
+
}
|
|
535
577
|
}
|
|
536
|
-
out.push(
|
|
578
|
+
out.push(
|
|
579
|
+
workspaceLabel === undefined && sessionFile === undefined
|
|
580
|
+
? { threadId, name }
|
|
581
|
+
: workspaceLabel === undefined
|
|
582
|
+
? { threadId, name, sessionFile }
|
|
583
|
+
: sessionFile === undefined
|
|
584
|
+
? { threadId, name, workspaceLabel }
|
|
585
|
+
: { threadId, name, workspaceLabel, sessionFile },
|
|
586
|
+
);
|
|
537
587
|
}
|
|
538
588
|
return out;
|
|
539
589
|
}
|
|
540
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Whether the paired bridge runs with per-session topic tidy on (access.json
|
|
593
|
+
* `topicsTidy: true` — `/telegram topics tidy on`).
|
|
594
|
+
*
|
|
595
|
+
* On a tidy host every pane exit closes (forum) or deletes (DM) its own topic,
|
|
596
|
+
* so a pinned `escalation.telegramTopicId` is structurally stale: it can never
|
|
597
|
+
* be among the live claims again, and treating it as a live pin is the false
|
|
598
|
+
* belief #600 names. The bridge's own setting is read here so `doctor` can say
|
|
599
|
+
* "vestigial" rather than "moved once".
|
|
600
|
+
*/
|
|
601
|
+
export function telegramTopicsTidy(stateDirPath?: string): boolean {
|
|
602
|
+
const override = stateDirPath?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
603
|
+
const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
604
|
+
try {
|
|
605
|
+
const parsed = JSON.parse(readFileSync(join(dir, "access.json"), "utf8")) as {
|
|
606
|
+
readonly topicsTidy?: unknown;
|
|
607
|
+
};
|
|
608
|
+
return parsed.topicsTidy === true;
|
|
609
|
+
} catch {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
541
614
|
/**
|
|
542
615
|
* The one Telegram send in this package. Exported so the report outbox (#123)
|
|
543
616
|
* reuses it rather than forking it: the response handling below is load-bearing
|
package/src/fleet.ts
CHANGED
|
@@ -26,10 +26,10 @@ import {
|
|
|
26
26
|
} from "node:fs";
|
|
27
27
|
import { createInterface } from "node:readline";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
|
-
import { dirname, join } from "node:path";
|
|
29
|
+
import { dirname, join, sep } from "node:path";
|
|
30
30
|
import { findProject, loadConfig, stateDir } from "./config.ts";
|
|
31
31
|
import { clearArmChallenge, recordArmChallenge } from "./arm-challenge.ts";
|
|
32
|
-
import { resolveProjectTopicId, sendTelegram } from "./escalate.ts";
|
|
32
|
+
import { resolveClaimedSessionFile, resolveProjectTopicId, sendTelegram } from "./escalate.ts";
|
|
33
33
|
import { readPlanUsage, sharedUsageSource } from "./usage.ts";
|
|
34
34
|
import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
|
|
35
35
|
import { inspectBriefLayout } from "./brief-upgrade.ts";
|
|
@@ -287,13 +287,22 @@ export interface ArmResult {
|
|
|
287
287
|
export interface ArmDeps {
|
|
288
288
|
sendChallenge?: (token: string, owner: string, text: string, topicId?: number) => Promise<void>;
|
|
289
289
|
/**
|
|
290
|
-
*
|
|
291
|
-
*
|
|
290
|
+
* The orchestrator session file the live omp-telegram claim names for this
|
|
291
|
+
* project, or undefined when there is no (readable) claim. The default
|
|
292
|
+
* resolves the claim the same way the send does; tests inject a fixture.
|
|
293
|
+
* Absent → arm watches the cwd-derived session directory, the pre-claim
|
|
294
|
+
* behaviour.
|
|
295
|
+
*/
|
|
296
|
+
claimedSessionFile?: () => string | undefined;
|
|
297
|
+
/**
|
|
298
|
+
* Waits for the challenge to appear as a user turn somewhere under the scan
|
|
299
|
+
* directories. The waiter owns transcript discovery — not the caller —
|
|
292
300
|
* because the reply may land in a session that starts *after* the send, so a
|
|
293
301
|
* path resolved before the challenge went out can be the wrong file by the
|
|
294
|
-
* time the operator answers (#142)
|
|
302
|
+
* time the operator answers (#142), and because the claimed session file can
|
|
303
|
+
* sit in a different directory than the tick cwd implies (#600).
|
|
295
304
|
*/
|
|
296
|
-
waitForUserTurn?: (
|
|
305
|
+
waitForUserTurn?: (dirs: readonly string[], code: string, sentAt: number, timeoutMs: number) => Promise<boolean>;
|
|
297
306
|
now?: () => number;
|
|
298
307
|
sleep?: (ms: number) => Promise<void>;
|
|
299
308
|
timeoutMs?: number;
|
|
@@ -332,10 +341,23 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
332
341
|
);
|
|
333
342
|
}
|
|
334
343
|
|
|
335
|
-
|
|
336
|
-
|
|
344
|
+
// One bot, one chat, and — once a host runs more than one fleet — more than
|
|
345
|
+
// one pane that can ask. The challenge names which one, or the operator is
|
|
346
|
+
// answering a question they cannot attribute.
|
|
347
|
+
const named = tick.config.project ?? projectName;
|
|
348
|
+
|
|
349
|
+
// Resolve the orchestrator's live session file *before* the challenge goes
|
|
350
|
+
// out — the same claim the send follows (#600). A pane resumed from a
|
|
351
|
+
// session created elsewhere (herdr pins it to the original transcript)
|
|
352
|
+
// writes a session file outside the directory the tick cwd implies, and a
|
|
353
|
+
// cwd-derived scan would poll the one place the reply is guaranteed not to
|
|
354
|
+
// be. A claim outside the session tree arm scans can never be answered, so
|
|
355
|
+
// that is a stop, not five minutes of polling.
|
|
356
|
+
const claimed = deps.claimedSessionFile !== undefined ? deps.claimedSessionFile() : claimedOrchestratorSessionFile(named);
|
|
357
|
+
const dirs = armSessionScanDirs(tick.cwd, claimed);
|
|
358
|
+
if (dirs.every((d) => !existsSync(d))) {
|
|
337
359
|
throw new Error(
|
|
338
|
-
`no orchestrator session directory
|
|
360
|
+
`no orchestrator session directory under ${dirs.join(" or ")} — ` +
|
|
339
361
|
`the inbound proof is read from a user turn in a transcript there. Start the pane orchestrator, let it settle, then arm again`,
|
|
340
362
|
);
|
|
341
363
|
}
|
|
@@ -347,10 +369,6 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
347
369
|
const arm = resolveArmState(path, tick.config.project ?? projectName);
|
|
348
370
|
const alreadyArmed = arm.armed;
|
|
349
371
|
const code = makeChallengeCode();
|
|
350
|
-
// One bot, one chat, and — once a host runs more than one fleet — more than
|
|
351
|
-
// one pane that can ask. The challenge names which one, or the operator is
|
|
352
|
-
// answering a question they cannot attribute.
|
|
353
|
-
const named = tick.config.project ?? projectName;
|
|
354
372
|
const text =
|
|
355
373
|
`Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
|
|
356
374
|
`Reply to this chat with exactly:\n${code}\n` +
|
|
@@ -391,25 +409,37 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
391
409
|
|
|
392
410
|
const injected = deps.waitForUserTurn;
|
|
393
411
|
const scan: SessionScan = injected
|
|
394
|
-
? { seen: await injected(
|
|
395
|
-
: await waitForChallengeInSessions(
|
|
412
|
+
? { seen: await injected(dirs, code, sentAt, timeoutMs), scanned: [], ignored: [] }
|
|
413
|
+
: await waitForChallengeInSessions(dirs, code, sentAt, timeoutMs, deps);
|
|
396
414
|
if (!scan.seen) {
|
|
397
415
|
// The proof missed its window: a lookalike reply must not stay classifiable
|
|
398
416
|
// after arming gave up, so the record is cleared before the failure lands.
|
|
399
417
|
clearArmChallenge(named);
|
|
418
|
+
// The claimed session file is named before the bridge/token/chat block: a
|
|
419
|
+
// rotation under the window is visible from this error alone, and on the
|
|
420
|
+
// host this guard exists for the claimed file is the one that held the
|
|
421
|
+
// answer (#600).
|
|
400
422
|
const listing = [
|
|
401
|
-
|
|
423
|
+
...(claimed === undefined
|
|
424
|
+
? []
|
|
425
|
+
: [
|
|
426
|
+
`claimed orchestrator session file: ${claimed}` +
|
|
427
|
+
(dirs.includes(dirname(claimed))
|
|
428
|
+
? " (watched)"
|
|
429
|
+
: ` (NOT under any watched dir — a reply there can never be seen)`),
|
|
430
|
+
]),
|
|
431
|
+
`session dir: ${dirs.join(", ")}`,
|
|
402
432
|
...scan.scanned.map((f) => ` watched: ${f}`),
|
|
403
433
|
...scan.ignored.map((f) => ` ignored (stale, last written before the challenge): ${f}`),
|
|
404
434
|
].join("\n");
|
|
405
435
|
throw new Error(
|
|
406
436
|
`arm: the challenge never arrived as a user turn in time — NOT armed.\n` +
|
|
407
|
-
|
|
437
|
+
listing +
|
|
438
|
+
`\nInbound Telegram is not reaching the omp session. Check, in order:\n` +
|
|
408
439
|
` * is the bridge polling? attach and run: /telegram status\n` +
|
|
409
440
|
` * is another process holding this bot token? Telegram allows exactly one\n` +
|
|
410
441
|
` getUpdates consumer and rejects the second with HTTP 409.\n` +
|
|
411
|
-
` * did you reply in the DM with the bot, not another chat?\n
|
|
412
|
-
listing,
|
|
442
|
+
` * did you reply in the DM with the bot, not another chat?\n`,
|
|
413
443
|
);
|
|
414
444
|
}
|
|
415
445
|
|
|
@@ -1605,6 +1635,56 @@ export function sessionDirForCwd(cwd: string): string {
|
|
|
1605
1635
|
return join(home, ".omp", "agent", "sessions", slug.replaceAll("/", "-"));
|
|
1606
1636
|
}
|
|
1607
1637
|
|
|
1638
|
+
/** The session tree arm scans: every transcript under it, whatever the cwd slug. */
|
|
1639
|
+
export function sessionsRoot(): string {
|
|
1640
|
+
return join(homedir(), ".omp", "agent", "sessions");
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* The orchestrator session file omp-telegram's live claim names for this
|
|
1645
|
+
* project — the transcript a resumed/restored pane actually writes, which can
|
|
1646
|
+
* live in a different session directory than the tick cwd implies (#600).
|
|
1647
|
+
* Never throws: no claim, or an unreadable config, means undefined and arm
|
|
1648
|
+
* falls back to the cwd-derived directory.
|
|
1649
|
+
*/
|
|
1650
|
+
function claimedOrchestratorSessionFile(named: string | undefined): string | undefined {
|
|
1651
|
+
if (named === undefined) return undefined;
|
|
1652
|
+
try {
|
|
1653
|
+
return resolveClaimedSessionFile(findProject(loadConfig(), named));
|
|
1654
|
+
} catch {
|
|
1655
|
+
return undefined;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* The session directories `arm` watches for the reply — the cwd-derived one
|
|
1661
|
+
* plus, when the live claim's file lives elsewhere, that file's directory.
|
|
1662
|
+
*
|
|
1663
|
+
* The cwd-derived directory is where a *fresh* session started from the tick
|
|
1664
|
+
* cwd writes; the claimed file's directory is where the *restored* pane keeps
|
|
1665
|
+
* writing (herdr pins it to the original transcript). Watching both covers a
|
|
1666
|
+
* resumed session, a rotated one, and a session that starts after the send.
|
|
1667
|
+
*
|
|
1668
|
+
* A claim outside the session tree is a stop, not a slower failure: arm polls
|
|
1669
|
+
* only transcripts under {@link sessionsRoot}, so a claimed file elsewhere can
|
|
1670
|
+
* never satisfy the challenge, and five minutes of polling cannot discover a
|
|
1671
|
+
* file that is not in the search set. The throw names both paths.
|
|
1672
|
+
*/
|
|
1673
|
+
function armSessionScanDirs(cwd: string, claimed: string | undefined): string[] {
|
|
1674
|
+
const cwdDir = sessionDirForCwd(cwd);
|
|
1675
|
+
if (claimed === undefined) return [cwdDir];
|
|
1676
|
+
const root = sessionsRoot();
|
|
1677
|
+
const claimDir = dirname(claimed);
|
|
1678
|
+
if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
|
|
1679
|
+
throw new Error(
|
|
1680
|
+
`arm: the orchestrator's claimed session file ${claimed} is outside the session tree arm scans (${root}) — ` +
|
|
1681
|
+
`no transcript there can satisfy the challenge. Resume the pane under ${root}, or start it from ${cwd}; ` +
|
|
1682
|
+
`arm would otherwise poll ${cwdDir} until the timer runs out`,
|
|
1683
|
+
);
|
|
1684
|
+
}
|
|
1685
|
+
return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1608
1688
|
function makeChallengeCode(): string {
|
|
1609
1689
|
const bytes = new Uint8Array(4);
|
|
1610
1690
|
crypto.getRandomValues(bytes);
|
|
@@ -1631,20 +1711,23 @@ interface SessionScan {
|
|
|
1631
1711
|
}
|
|
1632
1712
|
|
|
1633
1713
|
/**
|
|
1634
|
-
* Polls the session
|
|
1714
|
+
* Polls the session directories — re-read on every pass, never snapshotted —
|
|
1635
1715
|
* until the challenge shows up as a user turn or the deadline passes.
|
|
1636
1716
|
*
|
|
1637
1717
|
* Discovery lives here because the reply can land in a transcript that does not
|
|
1638
1718
|
* exist yet when the challenge is sent: a rotated session, or the first one of
|
|
1639
|
-
* a pane started right after arming (#142).
|
|
1640
|
-
*
|
|
1719
|
+
* a pane started right after arming (#142). And the scan set can be two
|
|
1720
|
+
* directories when the live claim's session file lives outside the tick-cwd
|
|
1721
|
+
* directory: the restored pane keeps writing the claimed file, so its
|
|
1722
|
+
* directory is watched alongside the cwd-derived one (#600).
|
|
1641
1723
|
*
|
|
1642
1724
|
* Files untouched since just before the send are named, not parsed: an append
|
|
1643
1725
|
* bumps mtime, so a transcript older than the challenge cannot hold the reply,
|
|
1644
|
-
* and skipping it keeps a large stale session out of every 5 s pass.
|
|
1726
|
+
* and skipping it keeps a large stale session out of every 5 s pass. A missing
|
|
1727
|
+
* directory is not an error — it can vanish under a rotation and reappear.
|
|
1645
1728
|
*/
|
|
1646
1729
|
async function waitForChallengeInSessions(
|
|
1647
|
-
|
|
1730
|
+
dirs: readonly string[],
|
|
1648
1731
|
code: string,
|
|
1649
1732
|
sentAt: number,
|
|
1650
1733
|
timeoutMs: number,
|
|
@@ -1656,28 +1739,30 @@ async function waitForChallengeInSessions(
|
|
|
1656
1739
|
for (;;) {
|
|
1657
1740
|
const scanned: string[] = [];
|
|
1658
1741
|
const ignored: string[] = [];
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
names = readdirSync(dir);
|
|
1662
|
-
} catch {
|
|
1663
|
-
/* the directory can go away under a rotation; the next pass re-reads it */
|
|
1664
|
-
}
|
|
1665
|
-
for (const name of names.sort()) {
|
|
1666
|
-
if (!name.endsWith(".jsonl")) continue;
|
|
1667
|
-
const path = join(dir, name);
|
|
1668
|
-
let mtimeMs: number;
|
|
1742
|
+
for (const dir of dirs) {
|
|
1743
|
+
let names: string[] = [];
|
|
1669
1744
|
try {
|
|
1670
|
-
|
|
1745
|
+
names = readdirSync(dir);
|
|
1671
1746
|
} catch {
|
|
1672
|
-
|
|
1747
|
+
/* the directory can go away under a rotation; the next pass re-reads it */
|
|
1673
1748
|
}
|
|
1674
|
-
const
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1749
|
+
for (const name of names.sort()) {
|
|
1750
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
1751
|
+
const path = join(dir, name);
|
|
1752
|
+
let mtimeMs: number;
|
|
1753
|
+
try {
|
|
1754
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
1755
|
+
} catch {
|
|
1756
|
+
continue; /* race */
|
|
1757
|
+
}
|
|
1758
|
+
const label = `${name} (mtime ${new Date(mtimeMs).toISOString()})`;
|
|
1759
|
+
if (mtimeMs < sentAt - 1_000) {
|
|
1760
|
+
ignored.push(label);
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
scanned.push(label);
|
|
1764
|
+
if (await transcriptHasUserCode(path, code)) return { seen: true, scanned, ignored };
|
|
1678
1765
|
}
|
|
1679
|
-
scanned.push(label);
|
|
1680
|
-
if (await transcriptHasUserCode(path, code)) return { seen: true, scanned, ignored };
|
|
1681
1766
|
}
|
|
1682
1767
|
if (now() >= deadline) return { seen: false, scanned, ignored };
|
|
1683
1768
|
await sleep(5_000);
|
package/src/setup-host.ts
CHANGED
|
@@ -211,6 +211,14 @@ export interface HostRuntimePlan {
|
|
|
211
211
|
* the installed unit matched.
|
|
212
212
|
*/
|
|
213
213
|
installedAction: PlannedWrite<string>["action"];
|
|
214
|
+
/**
|
|
215
|
+
* The installed destinations whose live bytes differ from this version's
|
|
216
|
+
* render — exactly the files the privileged steps would rewrite. `upgrade`
|
|
217
|
+
* names them after a release that re-rendered a template, and
|
|
218
|
+
* {@link currentInstall} is `true` iff this list is empty, so the two can
|
|
219
|
+
* never disagree (#598).
|
|
220
|
+
*/
|
|
221
|
+
drift: readonly string[];
|
|
214
222
|
/**
|
|
215
223
|
* True when every file the privileged install steps would write is already
|
|
216
224
|
* at its destination with the current bytes. `runHostInstall` uses it to
|
|
@@ -1285,18 +1293,27 @@ export function planHostRuntime(
|
|
|
1285
1293
|
// nothing to restart. The herdr unit is absent on a host without herdr, and
|
|
1286
1294
|
// an absent unit that would not be provisioned is nothing to do.
|
|
1287
1295
|
const installedAction = actionFor(installedPath, serviceContent);
|
|
1296
|
+
// The same comparison as a destination list, so `upgrade` can name the
|
|
1297
|
+
// drifted files and `currentInstall` can never disagree with that list: a
|
|
1298
|
+
// plan is current exactly when no installed destination differs from the
|
|
1299
|
+
// render.
|
|
1300
|
+
const drift: string[] = [];
|
|
1301
|
+
const noteDrift = (path: string, content: string | undefined): void => {
|
|
1302
|
+
if (content !== undefined && actionFor(path, content) !== "keep") drift.push(path);
|
|
1303
|
+
};
|
|
1304
|
+
noteDrift(installedPath, serviceContent);
|
|
1305
|
+
noteDrift(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent);
|
|
1306
|
+
noteDrift(recoverScriptInstallPath, recoverScriptContent);
|
|
1307
|
+
if (herdrUnit !== undefined) noteDrift(installedHerdr, herdrUnit.content);
|
|
1308
|
+
// The pane-shell file is a destination like the units: a plan with all
|
|
1309
|
+
// units current but the config merge still pending must not report
|
|
1310
|
+
// "nothing to install" and skip the very write it exists to make.
|
|
1311
|
+
if (herdrConfig !== undefined) noteDrift(herdrConfigPath, herdrConfig.content);
|
|
1312
|
+
// Same for the herdr-conductor config.env: a pending env merge is pending
|
|
1313
|
+
// work, not an already-current install.
|
|
1314
|
+
if (herdrEnv !== undefined) noteDrift(herdrEnvTarget, herdrEnv.content);
|
|
1288
1315
|
const currentInstall =
|
|
1289
|
-
|
|
1290
|
-
actionFor(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent) === "keep" &&
|
|
1291
|
-
actionFor(recoverScriptInstallPath, recoverScriptContent) === "keep" &&
|
|
1292
|
-
(herdrUnit === undefined || actionFor(installedHerdr, herdrUnit.content) === "keep") &&
|
|
1293
|
-
// The pane-shell file is a destination like the units: a re-run with all
|
|
1294
|
-
// units current but the config merge still pending must not report
|
|
1295
|
-
// "nothing to install" and skip the very write the plan exists to make.
|
|
1296
|
-
(herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep") &&
|
|
1297
|
-
// Same for the herdr-conductor config.env: pending env merge is pending
|
|
1298
|
-
// work, not an already-current install.
|
|
1299
|
-
(herdrEnv === undefined || actionFor(herdrEnvTarget, herdrEnv.content) === "keep");
|
|
1316
|
+
drift.length === 0;
|
|
1300
1317
|
return {
|
|
1301
1318
|
service,
|
|
1302
1319
|
// The herdr unit and pane-shell config stand and fall together: no herdr, no
|
|
@@ -1349,6 +1366,7 @@ export function planHostRuntime(
|
|
|
1349
1366
|
cliSource: runtime.cli === undefined ? "plugin" : "global",
|
|
1350
1367
|
installedPath,
|
|
1351
1368
|
installedAction,
|
|
1369
|
+
drift,
|
|
1352
1370
|
currentInstall,
|
|
1353
1371
|
};
|
|
1354
1372
|
}
|