omp-conductor 0.18.1 → 0.18.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 +1 -1
- package/REFERENCE.md +1 -1
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +6 -1
- package/src/arm-challenge.ts +250 -57
- package/src/briefs/orchestrator.md +21 -8
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +122 -1
- package/src/command-manifest.ts +16 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/intake.ts +4 -19
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +19 -6
- package/src/config.ts +23 -8
- package/src/daemon.ts +150 -241
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +55 -129
- package/src/escalate.ts +22 -11
- package/src/fleet.ts +93 -218
- package/src/host.ts +7 -332
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +11 -56
- package/src/orchestrator-tick.ts +225 -37
- package/src/session-host.ts +6 -41
- package/src/setup-host.ts +27 -10
- package/src/setup-wizard.ts +141 -1
- package/src/setup.ts +54 -4
- package/src/status-render.ts +138 -1
- package/src/to-spec.ts +23 -2
- package/src/types.ts +62 -6
- package/src/verbs/server.ts +38 -3
- package/src/worker.ts +0 -13
package/src/fleet.ts
CHANGED
|
@@ -15,20 +15,16 @@
|
|
|
15
15
|
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
17
|
import {
|
|
18
|
-
createReadStream,
|
|
19
18
|
existsSync,
|
|
20
19
|
mkdirSync,
|
|
21
|
-
readdirSync,
|
|
22
20
|
readFileSync,
|
|
23
21
|
rmSync,
|
|
24
|
-
statSync,
|
|
25
22
|
writeFileSync,
|
|
26
23
|
} from "node:fs";
|
|
27
|
-
import { createInterface } from "node:readline";
|
|
28
24
|
import { homedir } from "node:os";
|
|
29
25
|
import { dirname, join, sep } from "node:path";
|
|
30
26
|
import { findProject, loadConfig, resolveArmProof, stateDir } from "./config.ts";
|
|
31
|
-
import {
|
|
27
|
+
import { clearArmTransaction, readArmAcknowledgement, recordArmChallenge } from "./arm-challenge.ts";
|
|
32
28
|
import {
|
|
33
29
|
claimedTelegramTopics,
|
|
34
30
|
lockPidAlive,
|
|
@@ -81,6 +77,7 @@ import {
|
|
|
81
77
|
import type { WorkerPausePhase } from "./worker.ts";
|
|
82
78
|
import {
|
|
83
79
|
formatFleetStatus,
|
|
80
|
+
formatGroomingStatus,
|
|
84
81
|
type DaemonProjectHealth,
|
|
85
82
|
type DispatchLayer,
|
|
86
83
|
type FleetDaemonProbe,
|
|
@@ -328,19 +325,10 @@ export interface ArmDeps {
|
|
|
328
325
|
* The orchestrator session file the live omp-telegram claim names for this
|
|
329
326
|
* project, or undefined when there is no (readable) claim. The default
|
|
330
327
|
* resolves the claim the same way the send does; tests inject a fixture.
|
|
331
|
-
*
|
|
332
|
-
*
|
|
328
|
+
* Claim-only feeds it to the plumbing verdict's session-identity checks;
|
|
329
|
+
* the challenge proof never reads a transcript, so it ignores the claim.
|
|
333
330
|
*/
|
|
334
331
|
claimedSessionFile?: () => string | undefined;
|
|
335
|
-
/**
|
|
336
|
-
* Waits for the challenge to appear as a user turn somewhere under the scan
|
|
337
|
-
* directories. The waiter owns transcript discovery — not the caller —
|
|
338
|
-
* because the reply may land in a session that starts *after* the send, so a
|
|
339
|
-
* path resolved before the challenge went out can be the wrong file by the
|
|
340
|
-
* time the operator answers (#142), and because the claimed session file can
|
|
341
|
-
* sit in a different directory than the tick cwd implies (#600).
|
|
342
|
-
*/
|
|
343
|
-
waitForUserTurn?: (dirs: readonly string[], code: string, sentAt: number, timeoutMs: number) => Promise<boolean>;
|
|
344
332
|
now?: () => number;
|
|
345
333
|
sleep?: (ms: number) => Promise<void>;
|
|
346
334
|
timeoutMs?: number;
|
|
@@ -394,6 +382,13 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
394
382
|
// one pane that can ask. The challenge names which one, or the operator is
|
|
395
383
|
// answering a question they cannot attribute.
|
|
396
384
|
const named = tick.config.project ?? projectName;
|
|
385
|
+
// The handshake state key must be exactly what the orchestrator's inbound
|
|
386
|
+
// adapter computes: TickConfig.project, undefined for a legacy unstamped
|
|
387
|
+
// config. `named` may fall back to the CLI argument for the challenge text
|
|
388
|
+
// and config lookups; the state key must not — the adapter has no CLI
|
|
389
|
+
// argument to fall back to, and a mismatched key would make arming wait on
|
|
390
|
+
// an acknowledgement that can never be written.
|
|
391
|
+
const stateKey = tick.config.project;
|
|
397
392
|
|
|
398
393
|
// The arming proof is a declared per-project policy (#613). A config that
|
|
399
394
|
// cannot name the project fails safe to `challenge` — today's authenticated
|
|
@@ -406,15 +401,11 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
406
401
|
/* no project config — keep today's challenge behaviour */
|
|
407
402
|
}
|
|
408
403
|
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
// be. A claim outside the session tree arm scans can never be answered, so
|
|
415
|
-
// that is a stop, not five minutes of polling. (Claim-only resolves the
|
|
416
|
-
// claim too — the verdict checks the same session identity — but names the
|
|
417
|
-
// refusal itself rather than throwing the transcript wording.)
|
|
404
|
+
// The orchestrator's live session file per omp-telegram's claim (#600) —
|
|
405
|
+
// input to the claim-only verdict's session-identity checks below. The
|
|
406
|
+
// challenge proof never reads it: its acknowledgement is conductor state,
|
|
407
|
+
// so where (or whether) a transcript lives is no longer part of arming
|
|
408
|
+
// (#614).
|
|
418
409
|
const claimed = deps.claimedSessionFile !== undefined ? deps.claimedSessionFile() : claimedOrchestratorSessionFile(named);
|
|
419
410
|
|
|
420
411
|
// Prefer the project's live forum topic so arm challenges land where
|
|
@@ -467,17 +458,6 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
467
458
|
return { path, alreadyArmed, owner: channel.owner, proof };
|
|
468
459
|
}
|
|
469
460
|
|
|
470
|
-
// The transcript proof needs a session tree to poll. The claim-only verdict
|
|
471
|
-
// needs no such thing — it reads session identity from omp-telegram's own
|
|
472
|
-
// state — so this stop stays on the challenge path only.
|
|
473
|
-
const dirs = armSessionScanDirs(tick.cwd, claimed);
|
|
474
|
-
if (dirs.every((d) => !existsSync(d))) {
|
|
475
|
-
throw new Error(
|
|
476
|
-
`no orchestrator session directory under ${dirs.join(" or ")} — ` +
|
|
477
|
-
`the inbound proof is read from a user turn in a transcript there. Start the pane orchestrator, let it settle, then arm again`,
|
|
478
|
-
);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
461
|
const code = makeChallengeCode();
|
|
482
462
|
const text =
|
|
483
463
|
`Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
|
|
@@ -486,54 +466,38 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
486
466
|
|
|
487
467
|
const send = deps.sendChallenge ?? sendTelegramMessage;
|
|
488
468
|
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
489
|
-
// Read before the send, not after: a transcript untouched since this instant
|
|
490
|
-
// cannot contain the reply, and that is what the waiter filters on.
|
|
491
469
|
const sentAt = (deps.now ?? Date.now)();
|
|
492
|
-
// The orchestrator
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
470
|
+
// The orchestrator's inbound adapter can only acknowledge an *active*
|
|
471
|
+
// challenge, so the authenticated pending record (hash + expiry, never the
|
|
472
|
+
// code) is written before the challenge goes out and settled the moment
|
|
473
|
+
// this end finishes (#415). The returned id pins the wait below: an
|
|
474
|
+
// acknowledgement can only ever name the currently-pending id, so replacing
|
|
475
|
+
// a challenge makes every prior acknowledgement inert.
|
|
476
|
+
const challengeId = recordArmChallenge(stateKey, code, sentAt, sentAt + timeoutMs);
|
|
497
477
|
try {
|
|
498
478
|
await send(token, channel.owner, text, sendTopic);
|
|
499
479
|
} catch (err) {
|
|
500
|
-
// The challenge never went out, so
|
|
501
|
-
// proof either.
|
|
502
|
-
|
|
480
|
+
// The challenge never went out, so its transaction must not linger as a
|
|
481
|
+
// classifiable proof either.
|
|
482
|
+
clearArmTransaction(stateKey, challengeId);
|
|
503
483
|
throw new Error(
|
|
504
484
|
`arm: outbound sendMessage failed — NOT armed: ${err instanceof Error ? err.message : String(err)}`,
|
|
505
485
|
);
|
|
506
486
|
}
|
|
507
487
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
// The claimed session file is named before the bridge/token/chat block: a
|
|
517
|
-
// rotation under the window is visible from this error alone, and on the
|
|
518
|
-
// host this guard exists for the claimed file is the one that held the
|
|
519
|
-
// answer (#600).
|
|
520
|
-
const listing = [
|
|
521
|
-
...(claimed === undefined
|
|
522
|
-
? []
|
|
523
|
-
: [
|
|
524
|
-
`claimed orchestrator session file: ${claimed}` +
|
|
525
|
-
(dirs.includes(dirname(claimed))
|
|
526
|
-
? " (watched)"
|
|
527
|
-
: ` (NOT under any watched dir — a reply there can never be seen)`),
|
|
528
|
-
]),
|
|
529
|
-
`session dir: ${dirs.join(", ")}`,
|
|
530
|
-
...scan.scanned.map((f) => ` watched: ${f}`),
|
|
531
|
-
...scan.ignored.map((f) => ` ignored (stale, last written before the challenge): ${f}`),
|
|
532
|
-
].join("\n");
|
|
488
|
+
// Wait for the orchestrator's own acknowledgement — conductor state written
|
|
489
|
+
// by the inbound user-turn adapter when the real reply lands (#614). No
|
|
490
|
+
// transcript is read: the proof no longer depends on where (or whether) a
|
|
491
|
+
// session file lives, which is exactly the discovery that mis-fired on the
|
|
492
|
+
// host three times (#614). A wrong-project or lookalike reply writes no
|
|
493
|
+
// acknowledgement, so the window simply runs out fail-closed.
|
|
494
|
+
if (!(await waitForArmAcknowledgement(challengeId, timeoutMs, deps))) {
|
|
495
|
+
clearArmTransaction(stateKey, challengeId);
|
|
533
496
|
throw new Error(
|
|
534
|
-
`arm: the challenge never
|
|
535
|
-
|
|
536
|
-
|
|
497
|
+
`arm: the challenge was never acknowledged in time — NOT armed.\n` +
|
|
498
|
+
`The orchestrator's inbound adapter acknowledges the reply when it lands as a user turn; ` +
|
|
499
|
+
`no acknowledgement for challenge ${challengeId} arrived.\n` +
|
|
500
|
+
`Inbound Telegram is not reaching the omp session. Check, in order:\n` +
|
|
537
501
|
` * is the bridge polling? attach and run: /telegram status\n` +
|
|
538
502
|
` * is another process holding this bot token? Telegram allows exactly one\n` +
|
|
539
503
|
` getUpdates consumer and rejects the second with HTTP 409.\n` +
|
|
@@ -542,10 +506,11 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
542
506
|
}
|
|
543
507
|
|
|
544
508
|
writeArmedMarker(path, channel.owner, arm);
|
|
545
|
-
// The
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
509
|
+
// The acknowledgement landed and this project is armed: settling clears this
|
|
510
|
+
// transaction's pending record and acknowledgement — never a newer
|
|
511
|
+
// replacement's — so a later unsolicited lookalike stays inert past this
|
|
512
|
+
// handshake.
|
|
513
|
+
clearArmTransaction(stateKey, challengeId);
|
|
549
514
|
return { path, alreadyArmed, owner: channel.owner, challenge: code, proof };
|
|
550
515
|
}
|
|
551
516
|
|
|
@@ -1528,6 +1493,9 @@ export type FleetStatusReport = StatusSnapshot & {
|
|
|
1528
1493
|
failureClasses: string | undefined;
|
|
1529
1494
|
workerPhases: { issue: number; phase: WorkerPausePhase }[];
|
|
1530
1495
|
intake: string | undefined;
|
|
1496
|
+
/** The project-scoped durable to-spec grooming lifecycle as status lines
|
|
1497
|
+
* (#809), or nothing when there is nothing to report. */
|
|
1498
|
+
grooming: string | undefined;
|
|
1531
1499
|
lastStop: DaemonStop | undefined;
|
|
1532
1500
|
siblings: { project: string; live: number }[];
|
|
1533
1501
|
};
|
|
@@ -1585,6 +1553,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
|
|
|
1585
1553
|
let codeGraph: CodeGraphHealth;
|
|
1586
1554
|
let lastStop: DaemonStop | undefined;
|
|
1587
1555
|
let siblings: { project: string; live: number }[] = [];
|
|
1556
|
+
let grooming: string | undefined;
|
|
1588
1557
|
try {
|
|
1589
1558
|
codeGraph =
|
|
1590
1559
|
cached ??
|
|
@@ -1598,6 +1567,15 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
|
|
|
1598
1567
|
siblings = loadConfig()
|
|
1599
1568
|
.projects.filter((p) => p.name !== project.name)
|
|
1600
1569
|
.map((p) => ({ project: p.name, live: store.liveRuns(p.name).length }));
|
|
1570
|
+
// The durable to-spec grooming lifecycle (#809): project-scoped verdict
|
|
1571
|
+
// rows from the store, plus the dispatch snapshot's routed/parked counts —
|
|
1572
|
+
// no tracker read, no second cache, no evidence parsing. Rendered only
|
|
1573
|
+
// when it has something to say.
|
|
1574
|
+
grooming = formatGroomingStatus({
|
|
1575
|
+
records: store.groomingVerdicts(project.name),
|
|
1576
|
+
routed: snapshot.dispatch?.routed ?? 0,
|
|
1577
|
+
parked: snapshot.dispatch?.parked ?? 0,
|
|
1578
|
+
});
|
|
1601
1579
|
} finally {
|
|
1602
1580
|
store.close();
|
|
1603
1581
|
}
|
|
@@ -1615,6 +1593,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
|
|
|
1615
1593
|
failureClasses: failureClassBlock(project.name),
|
|
1616
1594
|
workerPhases,
|
|
1617
1595
|
intake: intakeStatusLine(project.name),
|
|
1596
|
+
grooming,
|
|
1618
1597
|
lastStop,
|
|
1619
1598
|
siblings,
|
|
1620
1599
|
};
|
|
@@ -1635,6 +1614,7 @@ export function renderFleetStatusReport(report: FleetStatusReport): string {
|
|
|
1635
1614
|
report.intake,
|
|
1636
1615
|
report.lastStop,
|
|
1637
1616
|
report.siblings,
|
|
1617
|
+
report.grooming,
|
|
1638
1618
|
);
|
|
1639
1619
|
}
|
|
1640
1620
|
|
|
@@ -1892,7 +1872,7 @@ export function sessionDirForCwd(cwd: string): string {
|
|
|
1892
1872
|
return join(home, ".omp", "agent", "sessions", slug.replaceAll("/", "-"));
|
|
1893
1873
|
}
|
|
1894
1874
|
|
|
1895
|
-
/** The session tree
|
|
1875
|
+
/** The omp session tree the claim-only verdict judges session identity against. */
|
|
1896
1876
|
export function sessionsRoot(): string {
|
|
1897
1877
|
return join(homedir(), ".omp", "agent", "sessions");
|
|
1898
1878
|
}
|
|
@@ -1901,8 +1881,9 @@ export function sessionsRoot(): string {
|
|
|
1901
1881
|
* The orchestrator session file omp-telegram's live claim names for this
|
|
1902
1882
|
* project — the transcript a resumed/restored pane actually writes, which can
|
|
1903
1883
|
* live in a different session directory than the tick cwd implies (#600).
|
|
1904
|
-
* Never throws: no claim, or an unreadable config, means undefined
|
|
1905
|
-
*
|
|
1884
|
+
* Never throws: no claim, or an unreadable config, means undefined. Only the
|
|
1885
|
+
* claim-only verdict consumes this — the challenge proof reads conductor
|
|
1886
|
+
* state, not transcripts (#614).
|
|
1906
1887
|
*/
|
|
1907
1888
|
function claimedOrchestratorSessionFile(named: string | undefined): string | undefined {
|
|
1908
1889
|
if (named === undefined) return undefined;
|
|
@@ -1913,35 +1894,6 @@ function claimedOrchestratorSessionFile(named: string | undefined): string | und
|
|
|
1913
1894
|
}
|
|
1914
1895
|
}
|
|
1915
1896
|
|
|
1916
|
-
/**
|
|
1917
|
-
* The session directories `arm` watches for the reply — the cwd-derived one
|
|
1918
|
-
* plus, when the live claim's file lives elsewhere, that file's directory.
|
|
1919
|
-
*
|
|
1920
|
-
* The cwd-derived directory is where a *fresh* session started from the tick
|
|
1921
|
-
* cwd writes; the claimed file's directory is where the *restored* pane keeps
|
|
1922
|
-
* writing (herdr pins it to the original transcript). Watching both covers a
|
|
1923
|
-
* resumed session, a rotated one, and a session that starts after the send.
|
|
1924
|
-
*
|
|
1925
|
-
* A claim outside the session tree is a stop, not a slower failure: arm polls
|
|
1926
|
-
* only transcripts under {@link sessionsRoot}, so a claimed file elsewhere can
|
|
1927
|
-
* never satisfy the challenge, and five minutes of polling cannot discover a
|
|
1928
|
-
* file that is not in the search set. The throw names both paths.
|
|
1929
|
-
*/
|
|
1930
|
-
function armSessionScanDirs(cwd: string, claimed: string | undefined): string[] {
|
|
1931
|
-
const cwdDir = sessionDirForCwd(cwd);
|
|
1932
|
-
if (claimed === undefined) return [cwdDir];
|
|
1933
|
-
const root = sessionsRoot();
|
|
1934
|
-
const claimDir = dirname(claimed);
|
|
1935
|
-
if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
|
|
1936
|
-
throw new Error(
|
|
1937
|
-
`arm: the orchestrator's claimed session file ${claimed} is outside the session tree arm scans (${root}) — ` +
|
|
1938
|
-
`no transcript there can satisfy the challenge. Resume the pane under ${root}, or start it from ${cwd}; ` +
|
|
1939
|
-
`arm would otherwise poll ${cwdDir} until the timer runs out`,
|
|
1940
|
-
);
|
|
1941
|
-
}
|
|
1942
|
-
return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
1897
|
function makeChallengeCode(): string {
|
|
1946
1898
|
const bytes = new Uint8Array(4);
|
|
1947
1899
|
crypto.getRandomValues(bytes);
|
|
@@ -1949,6 +1901,31 @@ function makeChallengeCode(): string {
|
|
|
1949
1901
|
return `FLEET-${hex}`;
|
|
1950
1902
|
}
|
|
1951
1903
|
|
|
1904
|
+
/**
|
|
1905
|
+
* Polls the acknowledgement record for one exact challenge id until the
|
|
1906
|
+
* orchestrator's inbound adapter writes it or the deadline passes (#614).
|
|
1907
|
+
*
|
|
1908
|
+
* The state is a small JSON file re-read every pass, never snapshotted: the
|
|
1909
|
+
* acknowledgement may land at any point in the window, written by the live
|
|
1910
|
+
* orchestrator process. Only a record naming this exact id satisfies the
|
|
1911
|
+
* wait — an acknowledgement cut for a replaced challenge is inert here by
|
|
1912
|
+
* construction, and no transcript anywhere is opened.
|
|
1913
|
+
*/
|
|
1914
|
+
async function waitForArmAcknowledgement(
|
|
1915
|
+
challengeId: string,
|
|
1916
|
+
timeoutMs: number,
|
|
1917
|
+
deps: ArmDeps,
|
|
1918
|
+
): Promise<boolean> {
|
|
1919
|
+
const now = deps.now ?? Date.now;
|
|
1920
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
1921
|
+
const deadline = now() + timeoutMs;
|
|
1922
|
+
for (;;) {
|
|
1923
|
+
if (readArmAcknowledgement(challengeId) !== undefined) return true;
|
|
1924
|
+
if (now() >= deadline) return false;
|
|
1925
|
+
await sleep(5_000);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1952
1929
|
/**
|
|
1953
1930
|
* The one armed-marker write both proofs share: same content, same mode, and
|
|
1954
1931
|
* the same restamp of the pre-per-project shared marker the heartbeat still
|
|
@@ -1962,12 +1939,12 @@ function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
|
|
|
1962
1939
|
}
|
|
1963
1940
|
|
|
1964
1941
|
/**
|
|
1965
|
-
* The
|
|
1966
|
-
*
|
|
1967
|
-
*
|
|
1968
|
-
*
|
|
1969
|
-
*
|
|
1970
|
-
*
|
|
1942
|
+
* The session surface the claim-only verdict judges: the tick-cwd-derived
|
|
1943
|
+
* session directory plus, when the live claim's file lives elsewhere in the
|
|
1944
|
+
* tree, that file's directory (#600). An outside-tree claim is not a reason
|
|
1945
|
+
* to stop here — the verdict names that failure itself
|
|
1946
|
+
* (`claim-session-outside` / `dm-owner-unrelated`), so the refusal carries
|
|
1947
|
+
* the fact.
|
|
1971
1948
|
*/
|
|
1972
1949
|
function armVerdictScanDirs(cwd: string, claimed: string | undefined): string[] {
|
|
1973
1950
|
const cwdDir = sessionDirForCwd(cwd);
|
|
@@ -2021,108 +1998,6 @@ async function sendTelegramMessage(
|
|
|
2021
1998
|
await sendTelegram(token, owner, text, { topicId });
|
|
2022
1999
|
}
|
|
2023
2000
|
|
|
2024
|
-
interface SessionScan {
|
|
2025
|
-
seen: boolean;
|
|
2026
|
-
/** `name (mtime <iso>)` for every transcript the last pass actually parsed. */
|
|
2027
|
-
scanned: string[];
|
|
2028
|
-
/** Same shape, for the ones skipped as written before the challenge. */
|
|
2029
|
-
ignored: string[];
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
/**
|
|
2033
|
-
* Polls the session directories — re-read on every pass, never snapshotted —
|
|
2034
|
-
* until the challenge shows up as a user turn or the deadline passes.
|
|
2035
|
-
*
|
|
2036
|
-
* Discovery lives here because the reply can land in a transcript that does not
|
|
2037
|
-
* exist yet when the challenge is sent: a rotated session, or the first one of
|
|
2038
|
-
* a pane started right after arming (#142). And the scan set can be two
|
|
2039
|
-
* directories when the live claim's session file lives outside the tick-cwd
|
|
2040
|
-
* directory: the restored pane keeps writing the claimed file, so its
|
|
2041
|
-
* directory is watched alongside the cwd-derived one (#600).
|
|
2042
|
-
*
|
|
2043
|
-
* Files untouched since just before the send are named, not parsed: an append
|
|
2044
|
-
* bumps mtime, so a transcript older than the challenge cannot hold the reply,
|
|
2045
|
-
* and skipping it keeps a large stale session out of every 5 s pass. A missing
|
|
2046
|
-
* directory is not an error — it can vanish under a rotation and reappear.
|
|
2047
|
-
*/
|
|
2048
|
-
async function waitForChallengeInSessions(
|
|
2049
|
-
dirs: readonly string[],
|
|
2050
|
-
code: string,
|
|
2051
|
-
sentAt: number,
|
|
2052
|
-
timeoutMs: number,
|
|
2053
|
-
deps: ArmDeps,
|
|
2054
|
-
): Promise<SessionScan> {
|
|
2055
|
-
const now = deps.now ?? Date.now;
|
|
2056
|
-
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
2057
|
-
const deadline = now() + timeoutMs;
|
|
2058
|
-
for (;;) {
|
|
2059
|
-
const scanned: string[] = [];
|
|
2060
|
-
const ignored: string[] = [];
|
|
2061
|
-
for (const dir of dirs) {
|
|
2062
|
-
let names: string[] = [];
|
|
2063
|
-
try {
|
|
2064
|
-
names = readdirSync(dir);
|
|
2065
|
-
} catch {
|
|
2066
|
-
/* the directory can go away under a rotation; the next pass re-reads it */
|
|
2067
|
-
}
|
|
2068
|
-
for (const name of names.sort()) {
|
|
2069
|
-
if (!name.endsWith(".jsonl")) continue;
|
|
2070
|
-
const path = join(dir, name);
|
|
2071
|
-
let mtimeMs: number;
|
|
2072
|
-
try {
|
|
2073
|
-
mtimeMs = statSync(path).mtimeMs;
|
|
2074
|
-
} catch {
|
|
2075
|
-
continue; /* race */
|
|
2076
|
-
}
|
|
2077
|
-
const label = `${name} (mtime ${new Date(mtimeMs).toISOString()})`;
|
|
2078
|
-
if (mtimeMs < sentAt - 1_000) {
|
|
2079
|
-
ignored.push(label);
|
|
2080
|
-
continue;
|
|
2081
|
-
}
|
|
2082
|
-
scanned.push(label);
|
|
2083
|
-
if (await transcriptHasUserCode(path, code)) return { seen: true, scanned, ignored };
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
if (now() >= deadline) return { seen: false, scanned, ignored };
|
|
2087
|
-
await sleep(5_000);
|
|
2088
|
-
}
|
|
2089
|
-
}
|
|
2090
|
-
|
|
2091
|
-
export async function transcriptHasUserCode(path: string, code: string): Promise<boolean> {
|
|
2092
|
-
if (!existsSync(path)) return false;
|
|
2093
|
-
const rl = createInterface({ input: createReadStream(path, { encoding: "utf8" }), crlfDelay: Infinity });
|
|
2094
|
-
try {
|
|
2095
|
-
for await (const line of rl) {
|
|
2096
|
-
if (line.length === 0) continue;
|
|
2097
|
-
let row: unknown;
|
|
2098
|
-
try {
|
|
2099
|
-
row = JSON.parse(line);
|
|
2100
|
-
} catch {
|
|
2101
|
-
continue;
|
|
2102
|
-
}
|
|
2103
|
-
if (row === null || typeof row !== "object") continue;
|
|
2104
|
-
const rec = row as { readonly [key: string]: unknown };
|
|
2105
|
-
if (rec["type"] !== "message") continue;
|
|
2106
|
-
const message = rec["message"];
|
|
2107
|
-
if (message === null || typeof message !== "object") continue;
|
|
2108
|
-
const msg = message as { readonly [key: string]: unknown };
|
|
2109
|
-
if (msg["role"] !== "user") continue;
|
|
2110
|
-
const content = msg["content"];
|
|
2111
|
-
if (!Array.isArray(content)) continue;
|
|
2112
|
-
for (const part of content) {
|
|
2113
|
-
if (part === null || typeof part !== "object") continue;
|
|
2114
|
-
const p = part as { readonly [key: string]: unknown };
|
|
2115
|
-
if (p["type"] === "text" && typeof p["text"] === "string" && p["text"].includes(code)) {
|
|
2116
|
-
return true;
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
}
|
|
2120
|
-
} finally {
|
|
2121
|
-
rl.close();
|
|
2122
|
-
}
|
|
2123
|
-
return false;
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
2001
|
// ---------------------------------------------------------------------------
|
|
2127
2002
|
// probes
|
|
2128
2003
|
// ---------------------------------------------------------------------------
|