@hicaru/pi-rlm 0.1.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/LICENSE +21 -0
- package/README.md +237 -0
- package/README.ru.md +200 -0
- package/README.zh-CN.md +224 -0
- package/package.json +54 -0
- package/src/bridge/fallback-todo.ts +137 -0
- package/src/bridge/interactive.ts +65 -0
- package/src/bridge/llm-query.ts +124 -0
- package/src/bridge/model.ts +97 -0
- package/src/bridge/pi-interactive.ts +86 -0
- package/src/bridge/rlm-query.ts +78 -0
- package/src/commands/rlm-config.ts +42 -0
- package/src/commands/rlm.ts +165 -0
- package/src/config/defaults.ts +38 -0
- package/src/config/settings.ts +185 -0
- package/src/context/repomix-context.ts +253 -0
- package/src/core/answer.ts +97 -0
- package/src/core/compaction.ts +64 -0
- package/src/core/engine.ts +408 -0
- package/src/core/history.ts +13 -0
- package/src/core/iteration.ts +45 -0
- package/src/core/limits.ts +90 -0
- package/src/core/pipeline.ts +100 -0
- package/src/core/resource-limits.ts +14 -0
- package/src/core/types.ts +131 -0
- package/src/index.ts +165 -0
- package/src/mode/input-router.ts +23 -0
- package/src/mode/rlm-mode.ts +149 -0
- package/src/patch/apply.ts +148 -0
- package/src/patch/index.ts +37 -0
- package/src/prompts/system.ts +278 -0
- package/src/prompts/user.ts +21 -0
- package/src/sandbox/protocol.ts +191 -0
- package/src/sandbox/sandbox-manager.ts +143 -0
- package/src/sandbox/sandbox.ts +362 -0
- package/src/sandbox/worker.py +457 -0
- package/src/state/events.ts +22 -0
- package/src/state/index.ts +23 -0
- package/src/state/internal.ts +46 -0
- package/src/state/paths.ts +42 -0
- package/src/state/reads.ts +96 -0
- package/src/state/resume.ts +154 -0
- package/src/state/rows.ts +117 -0
- package/src/state/writes.ts +56 -0
- package/src/telemetry/dispatcher.ts +116 -0
- package/src/telemetry/index.ts +14 -0
- package/src/telemetry/mlflow-config.ts +15 -0
- package/src/telemetry/mlflow-sink.ts +136 -0
- package/src/telemetry/mlflow.ts +99 -0
- package/src/telemetry/sink.ts +8 -0
- package/src/text/edits.ts +16 -0
- package/src/text/parsing.ts +35 -0
- package/src/text/preview.ts +18 -0
- package/src/text/tokens.ts +64 -0
- package/src/tool/apply-diff-tool.ts +125 -0
- package/src/tool/emitter-listener.ts +24 -0
- package/src/tool/repl-details.ts +23 -0
- package/src/tool/repl-tool.ts +528 -0
- package/src/tool/rlm-aggregator.ts +115 -0
- package/src/tool/rlm-details.ts +53 -0
- package/src/tool/rlm-events.ts +215 -0
- package/src/tool/rlm-tool.ts +199 -0
- package/src/tool/subcall-render.ts +129 -0
- package/src/tool/subcall-store.ts +90 -0
- package/src/tool/tool-utils.ts +73 -0
- package/src/ui/config-panel.ts +92 -0
- package/src/ui/intro.ts +23 -0
- package/src/ui/model-picker.ts +139 -0
- package/src/ui/status.ts +26 -0
- package/src/ui/theme.ts +47 -0
- package/src/util/concurrency.ts +15 -0
- package/src/util/errors.ts +27 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-soft JSONL readers for the RLM run-state module.
|
|
3
|
+
*
|
|
4
|
+
* `readRows` parses each line in its own try/catch so a truncated trailing
|
|
5
|
+
* line cannot erase prior rows. `listRunIds` sorts directories newest-first
|
|
6
|
+
* by the slug (ISO-like timestamps are self-sorting).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { open, readFile } from "node:fs/promises";
|
|
10
|
+
import { runsDir, trailPath, contextPath } from "./paths.ts";
|
|
11
|
+
import { isHeader, isRow, type RunHeader, type Row } from "./rows.ts";
|
|
12
|
+
import { errorMessage, failSoft, listDirectoriesSorted, pathExists, warn } from "./internal.ts";
|
|
13
|
+
|
|
14
|
+
/** Every well-formed row, in trail order. Malformed line → one warn, skipped. */
|
|
15
|
+
export async function readRows(cwd: string, dir: string, runId: string): Promise<Row[]> {
|
|
16
|
+
const path = trailPath(cwd, dir, runId);
|
|
17
|
+
if (!await pathExists(path)) return [];
|
|
18
|
+
const content = await failSoft(
|
|
19
|
+
() => readFile(path, "utf-8"),
|
|
20
|
+
undefined as string | undefined,
|
|
21
|
+
);
|
|
22
|
+
const trimmed = content?.trim();
|
|
23
|
+
if (!trimmed) return [];
|
|
24
|
+
|
|
25
|
+
const rows: Row[] = [];
|
|
26
|
+
for (const line of trimmed.split("\n")) {
|
|
27
|
+
try {
|
|
28
|
+
const row = JSON.parse(line) as unknown;
|
|
29
|
+
if (isRow(row)) rows.push(row);
|
|
30
|
+
else warn("skipping invalid JSONL row shape");
|
|
31
|
+
} catch (e) {
|
|
32
|
+
warn(`skipping malformed JSONL row — ${errorMessage(e)}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return rows;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read the first line of a trail file without reading the entire file (P1). */
|
|
39
|
+
async function readFirstLine(path: string): Promise<string | undefined> {
|
|
40
|
+
return await failSoft(async () => {
|
|
41
|
+
const file = await open(path, "r");
|
|
42
|
+
try {
|
|
43
|
+
const stats = await file.stat();
|
|
44
|
+
const size = Math.min(stats.size, 65536);
|
|
45
|
+
if (size <= 0) return undefined;
|
|
46
|
+
const buffer = Buffer.alloc(size);
|
|
47
|
+
const { bytesRead } = await file.read(buffer, 0, size, 0);
|
|
48
|
+
const content = buffer.toString("utf-8", 0, bytesRead);
|
|
49
|
+
const nl = content.indexOf("\n");
|
|
50
|
+
return nl >= 0 ? content.slice(0, nl) : content.trim() || undefined;
|
|
51
|
+
} finally {
|
|
52
|
+
await file.close();
|
|
53
|
+
}
|
|
54
|
+
}, undefined as string | undefined, { warn: false });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** First well-formed header row, or undefined. Bounded read — never reads the full trail (P1). */
|
|
58
|
+
export async function readHeader(cwd: string, dir: string, runId: string): Promise<RunHeader | undefined> {
|
|
59
|
+
const line = await readFirstLine(trailPath(cwd, dir, runId));
|
|
60
|
+
if (!line) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
const row = JSON.parse(line) as unknown;
|
|
63
|
+
return isHeader(row) ? row : undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reload context from a persistent sidecar file. */
|
|
70
|
+
export async function readContextSidecar(cwd: string, dir: string, runId: string, json: boolean): Promise<unknown> {
|
|
71
|
+
const path = contextPath(cwd, dir, runId, json);
|
|
72
|
+
if (!await pathExists(path)) return undefined;
|
|
73
|
+
const content = await failSoft(
|
|
74
|
+
() => readFile(path, "utf-8"),
|
|
75
|
+
undefined as string | undefined,
|
|
76
|
+
);
|
|
77
|
+
if (content === undefined) return undefined;
|
|
78
|
+
try {
|
|
79
|
+
return json ? JSON.parse(content) as unknown : content;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
warn(e);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Enumerate run-ids by directory listing; newest first (slug sorts chronologically). */
|
|
87
|
+
export async function listRunIds(cwd: string, dir: string): Promise<string[]> {
|
|
88
|
+
return await failSoft(() => listDirectoriesSorted(runsDir(cwd, dir)), [], { warn: false });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `@latest` / explicit id resolution. */
|
|
92
|
+
export async function resolveRunId(cwd: string, dir: string, ref: string): Promise<string | undefined> {
|
|
93
|
+
const ids = await listRunIds(cwd, dir);
|
|
94
|
+
if (ref === "@latest") return ids[0];
|
|
95
|
+
return ids.includes(ref) ? ref : undefined;
|
|
96
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume fold — rebuilds engine state from a JSONL trail in one pass.
|
|
3
|
+
*
|
|
4
|
+
* The fold reuses the live engine's own prompt builders (`buildTurnPrompt`) so
|
|
5
|
+
* the rebuilt history is faithful — DRY: the fold and the live loop share the
|
|
6
|
+
* same message-construction helpers. Mid-file malformed rows fail; trailing
|
|
7
|
+
* garbage from a crash is tolerated.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { type ChatMsg } from "../bridge/model.ts";
|
|
12
|
+
import { appendUserMessage } from "../core/history.ts";
|
|
13
|
+
import { buildTurnPrompt } from "../prompts/user.ts";
|
|
14
|
+
import type { ProposedEdit } from "../sandbox/protocol.ts";
|
|
15
|
+
import { readHeader } from "./reads.ts";
|
|
16
|
+
import {
|
|
17
|
+
isCompaction,
|
|
18
|
+
isHeader,
|
|
19
|
+
isPhase,
|
|
20
|
+
isRow,
|
|
21
|
+
isTerminal,
|
|
22
|
+
isTodo,
|
|
23
|
+
isTurn,
|
|
24
|
+
STATE_SCHEMA_VERSION,
|
|
25
|
+
type Row,
|
|
26
|
+
type RunHeader,
|
|
27
|
+
} from "./rows.ts";
|
|
28
|
+
import { trailPath, snapshotPath } from "./paths.ts";
|
|
29
|
+
import { failSoft, pathExists } from "./internal.ts";
|
|
30
|
+
|
|
31
|
+
export interface PhaseRecon {
|
|
32
|
+
readonly current: string;
|
|
33
|
+
readonly advancedAt: number;
|
|
34
|
+
readonly summary?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ReconstructResult =
|
|
38
|
+
| {
|
|
39
|
+
readonly ok: true;
|
|
40
|
+
readonly header: RunHeader;
|
|
41
|
+
readonly history: ChatMsg[];
|
|
42
|
+
readonly pendingReplOutputs?: string;
|
|
43
|
+
readonly usageSeed: { readonly costUsd: number; readonly inputTokens: number; readonly outputTokens: number; readonly durationMs: number };
|
|
44
|
+
readonly best: string;
|
|
45
|
+
readonly editsAcc: ProposedEdit[];
|
|
46
|
+
readonly completedTurns: number;
|
|
47
|
+
readonly compactions: number;
|
|
48
|
+
/** R-C1: the latest turn whose per-turn snapshot file exists on disk (undefined ⇒ no restore). */
|
|
49
|
+
readonly snapshotTurn: number | undefined;
|
|
50
|
+
readonly todoRows: readonly { readonly action: string; readonly params: Record<string, unknown>; readonly result: string }[];
|
|
51
|
+
readonly terminated: boolean;
|
|
52
|
+
/** Reconstructed pipeline phase state. undefined ⇒ pre-v3 trail (treats as research). */
|
|
53
|
+
readonly phase?: PhaseRecon;
|
|
54
|
+
}
|
|
55
|
+
| { readonly ok: false; readonly reason: "no-header" | "version-mismatch" | "no-turns" | "mid-file-hole"; readonly detail: string };
|
|
56
|
+
|
|
57
|
+
/** QB: single read + parse — detects mid-file holes without reading the trail twice. */
|
|
58
|
+
async function readRowsStrict(cwd: string, dir: string, runId: string): Promise<{ readonly rows: Row[]; readonly hole: boolean }> {
|
|
59
|
+
const path = trailPath(cwd, dir, runId);
|
|
60
|
+
if (!await pathExists(path)) return { rows: [], hole: false };
|
|
61
|
+
const content = await failSoft(() => readFile(path, "utf-8"), undefined as string | undefined);
|
|
62
|
+
const trimmed = content?.trim();
|
|
63
|
+
if (!trimmed) return { rows: [], hole: false };
|
|
64
|
+
|
|
65
|
+
const rows: Row[] = [];
|
|
66
|
+
let sawBad = false;
|
|
67
|
+
for (const line of trimmed.split("\n")) {
|
|
68
|
+
try {
|
|
69
|
+
const row = JSON.parse(line) as unknown;
|
|
70
|
+
if (!isRow(row)) {
|
|
71
|
+
sawBad = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
rows.push(row);
|
|
75
|
+
if (sawBad) return { rows, hole: true }; // good line after a bad one = mid-file hole
|
|
76
|
+
} catch {
|
|
77
|
+
sawBad = true; // trailing bad line tolerated; a subsequent good line means a hole
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { rows, hole: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function reconstructRlmState(
|
|
84
|
+
cwd: string,
|
|
85
|
+
dir: string,
|
|
86
|
+
runId: string,
|
|
87
|
+
systemPrompt: string,
|
|
88
|
+
): Promise<ReconstructResult> {
|
|
89
|
+
const header = await readHeader(cwd, dir, runId);
|
|
90
|
+
if (!header) return { ok: false, reason: "no-header", detail: runId };
|
|
91
|
+
// QB: ??1 backward-compat — when bumping STATE_SCHEMA_VERSION, also bump this default
|
|
92
|
+
// so trails written without an explicit `v` field are rejected rather than silently passed.
|
|
93
|
+
if ((header.v ?? 1) !== STATE_SCHEMA_VERSION)
|
|
94
|
+
return { ok: false, reason: "version-mismatch", detail: `run ${runId} written under schema v${header.v}` };
|
|
95
|
+
|
|
96
|
+
const { rows, hole } = await readRowsStrict(cwd, dir, runId);
|
|
97
|
+
if (hole) return { ok: false, reason: "mid-file-hole", detail: runId };
|
|
98
|
+
|
|
99
|
+
let history: ChatMsg[] = [{ role: "system", content: systemPrompt }];
|
|
100
|
+
const usageSeed = { costUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 };
|
|
101
|
+
let best = "";
|
|
102
|
+
let editsAcc: ProposedEdit[] = [];
|
|
103
|
+
let completedTurns = 0;
|
|
104
|
+
let compactions = 0;
|
|
105
|
+
let snapshotTurn: number | undefined; // R-C1: latest turn with an existing snapshot file
|
|
106
|
+
let pendingReplOutputs: string | undefined;
|
|
107
|
+
const todoRows: { action: string; params: Record<string, unknown>; result: string }[] = [];
|
|
108
|
+
let terminated = false;
|
|
109
|
+
let phase: PhaseRecon | undefined;
|
|
110
|
+
|
|
111
|
+
for (const row of rows) {
|
|
112
|
+
if (isHeader(row)) continue;
|
|
113
|
+
if (isCompaction(row)) {
|
|
114
|
+
history = [...row.history];
|
|
115
|
+
compactions++;
|
|
116
|
+
usageSeed.costUsd += row.usage.costUsd;
|
|
117
|
+
usageSeed.inputTokens += row.usage.inputTokens;
|
|
118
|
+
usageSeed.outputTokens += row.usage.outputTokens;
|
|
119
|
+
pendingReplOutputs = undefined;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (isTurn(row)) {
|
|
123
|
+
const i = row.turn - 1;
|
|
124
|
+
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
125
|
+
appendUserMessage(history, buildTurnPrompt(i, header.meta.maxIterations));
|
|
126
|
+
history.push({ role: "assistant", content: row.response });
|
|
127
|
+
usageSeed.costUsd += row.usage.costUsd;
|
|
128
|
+
usageSeed.inputTokens += row.usage.inputTokens;
|
|
129
|
+
usageSeed.outputTokens += row.usage.outputTokens;
|
|
130
|
+
if (row.answerContent) best = row.answerContent;
|
|
131
|
+
else if (!best && row.response.trim()) best = row.response; // C3: mirror engine fallback
|
|
132
|
+
if (row.edits && row.edits.length > 0) editsAcc = [...row.edits];
|
|
133
|
+
completedTurns = row.turn;
|
|
134
|
+
// R-C1: verify the per-turn snapshot file exists — a crashed finalize leaves the row claiming snapshotOk:true with no pkl.
|
|
135
|
+
if (row.snapshotOk && await pathExists(snapshotPath(cwd, dir, runId, row.turn)))
|
|
136
|
+
snapshotTurn = row.turn;
|
|
137
|
+
usageSeed.durationMs = row.cumulativeDurationMs; // C2: seed wall-clock
|
|
138
|
+
pendingReplOutputs = row.replOutputs;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (isPhase(row)) {
|
|
142
|
+
phase = { current: row.phase, advancedAt: row.turn - 1, summary: row.summary };
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (isTodo(row)) {
|
|
146
|
+
todoRows.push({ action: row.action, params: row.params, result: row.result });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (isTerminal(row)) terminated = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (completedTurns === 0 && !terminated) return { ok: false, reason: "no-turns", detail: runId };
|
|
153
|
+
return { ok: true, header, history, pendingReplOutputs, usageSeed, best, editsAcc, completedTurns, compactions, snapshotTurn, todoRows, terminated, phase };
|
|
154
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-state row shapes for the RLM JSONL audit trail.
|
|
3
|
+
*
|
|
4
|
+
* REPLAY CONTRACT: every field below is part of the resume fold's reconstruction.
|
|
5
|
+
* If any field is added, removed, or its semantics change, bump STATE_SCHEMA_VERSION
|
|
6
|
+
* so older trails are rejected rather than mis-replayed.
|
|
7
|
+
*
|
|
8
|
+
* Guards accept `unknown` and narrow via `hasKind` — no `any`, no `!`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ChatMsg } from "../bridge/model.ts";
|
|
12
|
+
import type { ProposedEdit } from "../sandbox/protocol.ts";
|
|
13
|
+
|
|
14
|
+
/** Bump when a row shape changes such that the resume fold cannot replay older files. */
|
|
15
|
+
export const STATE_SCHEMA_VERSION = 5;
|
|
16
|
+
|
|
17
|
+
export interface UsageRow {
|
|
18
|
+
readonly costUsd: number;
|
|
19
|
+
readonly inputTokens: number;
|
|
20
|
+
readonly outputTokens: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Line 1 of every trail. Carries everything the fold needs to rebuild the system prompt + reload context. */
|
|
24
|
+
export interface RunHeader {
|
|
25
|
+
readonly kind: "header";
|
|
26
|
+
readonly v: number;
|
|
27
|
+
readonly runId: string;
|
|
28
|
+
readonly ts: string;
|
|
29
|
+
readonly rootPrompt: string;
|
|
30
|
+
readonly context: { readonly type: string; readonly chars: number; readonly json: boolean };
|
|
31
|
+
readonly models: { readonly model: string; readonly worker: string };
|
|
32
|
+
/** Snapshot of the replay-affecting config (maxIterations, orchestrator, pipeline…). */
|
|
33
|
+
readonly meta: {
|
|
34
|
+
readonly maxIterations: number;
|
|
35
|
+
readonly maxDepth: number;
|
|
36
|
+
readonly orchestrator: boolean;
|
|
37
|
+
/** pipeline-enabled gate behaviour (always true from v4 on). */
|
|
38
|
+
readonly pipeline?: boolean;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One completed turn. `response`+`replOutputs` rebuild history; the rest restore scalars. */
|
|
43
|
+
export interface TurnRow {
|
|
44
|
+
readonly kind: "turn";
|
|
45
|
+
readonly turn: number; // 1-based (== engine `i + 1`)
|
|
46
|
+
readonly ts: string;
|
|
47
|
+
readonly response: string; // assistant message
|
|
48
|
+
readonly replOutputs?: string; // formatReplOutputs(results) → next user message
|
|
49
|
+
readonly answerContent?: string; // restores `best`
|
|
50
|
+
readonly edits?: readonly ProposedEdit[]; // restores editsAcc (latest wins)
|
|
51
|
+
readonly error: boolean; // turnHadError → limits.observe on resume
|
|
52
|
+
readonly usage: UsageRow;
|
|
53
|
+
readonly cumulativeDurationMs: number; // limits.usage().durationMs at turn-write time
|
|
54
|
+
readonly snapshotOk: boolean; // whether sandbox.pkl reflects THIS turn
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Emitted when compaction rewrites history; the fold replaces history wholesale. */
|
|
58
|
+
export interface CompactionRow {
|
|
59
|
+
readonly kind: "compaction";
|
|
60
|
+
readonly turn: number;
|
|
61
|
+
readonly ts: string;
|
|
62
|
+
readonly history: readonly ChatMsg[]; // post-compaction array (small by design)
|
|
63
|
+
readonly usage: UsageRow; // compaction model cost added to limits
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TodoRow {
|
|
67
|
+
readonly kind: "todo";
|
|
68
|
+
readonly turn: number;
|
|
69
|
+
readonly ts: string;
|
|
70
|
+
readonly action: string;
|
|
71
|
+
readonly params: Record<string, unknown>;
|
|
72
|
+
readonly result: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TerminalRow {
|
|
76
|
+
readonly kind: "terminal";
|
|
77
|
+
readonly ts: string;
|
|
78
|
+
readonly status: "completed" | "finalized" | "aborted" | "stopped";
|
|
79
|
+
readonly answer: string;
|
|
80
|
+
readonly iterations: number;
|
|
81
|
+
readonly usage: UsageRow;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Emitted when the root RLM advances to a new pipeline phase. */
|
|
85
|
+
export interface PhaseRow {
|
|
86
|
+
readonly kind: "phase";
|
|
87
|
+
readonly turn: number; // 1-based turn when advanced
|
|
88
|
+
readonly ts: string;
|
|
89
|
+
readonly phase: string;
|
|
90
|
+
readonly summary?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type Row = RunHeader | TurnRow | CompactionRow | TodoRow | TerminalRow | PhaseRow;
|
|
94
|
+
|
|
95
|
+
const hasKind = (r: unknown, k: Row["kind"]): boolean =>
|
|
96
|
+
typeof r === "object" && r !== null && (r as { kind?: unknown }).kind === k;
|
|
97
|
+
|
|
98
|
+
export const isHeader = (r: unknown): r is RunHeader =>
|
|
99
|
+
hasKind(r, "header") && typeof (r as RunHeader).runId === "string" && typeof (r as RunHeader).rootPrompt === "string"
|
|
100
|
+
&& typeof (r as RunHeader).meta?.maxIterations === "number";
|
|
101
|
+
|
|
102
|
+
export const isTurn = (r: unknown): r is TurnRow =>
|
|
103
|
+
hasKind(r, "turn") && typeof (r as TurnRow).turn === "number" && typeof (r as TurnRow).response === "string";
|
|
104
|
+
|
|
105
|
+
export const isCompaction = (r: unknown): r is CompactionRow =>
|
|
106
|
+
hasKind(r, "compaction") && Array.isArray((r as CompactionRow).history);
|
|
107
|
+
|
|
108
|
+
export const isTodo = (r: unknown): r is TodoRow =>
|
|
109
|
+
hasKind(r, "todo") && typeof (r as TodoRow).action === "string" && typeof (r as TodoRow).result === "string";
|
|
110
|
+
|
|
111
|
+
export const isTerminal = (r: unknown): r is TerminalRow => hasKind(r, "terminal");
|
|
112
|
+
|
|
113
|
+
export const isPhase = (r: unknown): r is PhaseRow =>
|
|
114
|
+
hasKind(r, "phase") && typeof (r as PhaseRow).phase === "string" && typeof (r as PhaseRow).turn === "number";
|
|
115
|
+
|
|
116
|
+
export const isRow = (r: unknown): r is Row =>
|
|
117
|
+
isHeader(r) || isTurn(r) || isCompaction(r) || isTodo(r) || isTerminal(r) || isPhase(r);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-soft JSONL writes for the RLM run-state module.
|
|
3
|
+
*
|
|
4
|
+
* Every writer returns `boolean` and warns on failure — never throws into
|
|
5
|
+
* the engine loop. A failed `appendRow` disables persistence for the rest
|
|
6
|
+
* of the run without aborting the answer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { appendFile, mkdir, open, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { contextPath, runDir, runsDir, trailPath } from "./paths.ts";
|
|
11
|
+
import type { Row, TodoRow } from "./rows.ts";
|
|
12
|
+
import { errorMessage, failSoft, listDirectoriesSorted, warn } from "./internal.ts";
|
|
13
|
+
|
|
14
|
+
/** mkdir + append one JSON line. Returns true on success; warns + false on throw. Never throws. */
|
|
15
|
+
export async function appendRow(cwd: string, dir: string, runId: string, row: Row): Promise<boolean> {
|
|
16
|
+
return await failSoft(async () => {
|
|
17
|
+
await mkdir(runDir(cwd, dir, runId), { recursive: true });
|
|
18
|
+
const path = trailPath(cwd, dir, runId);
|
|
19
|
+
await appendFile(path, `${JSON.stringify(row)}\n`, "utf-8");
|
|
20
|
+
// QC: fsync to flush kernel buffers — crash between write and sync would lose the last row
|
|
21
|
+
const file = await open(path, "r+");
|
|
22
|
+
try {
|
|
23
|
+
await file.sync();
|
|
24
|
+
} finally {
|
|
25
|
+
await file.close();
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}, false);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function appendTodoRow(cwd: string, dir: string, runId: string, row: Omit<TodoRow, "kind">): Promise<boolean> {
|
|
32
|
+
return await appendRow(cwd, dir, runId, { kind: "todo", ...row });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Persist the original context ONCE at run start so resume can reload it. */
|
|
36
|
+
export async function writeContextSidecar(cwd: string, dir: string, runId: string, context: unknown, json: boolean): Promise<boolean> {
|
|
37
|
+
return await failSoft(async () => {
|
|
38
|
+
await mkdir(runDir(cwd, dir, runId), { recursive: true });
|
|
39
|
+
await writeFile(contextPath(cwd, dir, runId, json), json ? JSON.stringify(context) : String(context), "utf-8");
|
|
40
|
+
return true;
|
|
41
|
+
}, false);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Prune oldest run directories beyond maxRuns. Best-effort; never throws. */
|
|
45
|
+
export async function pruneRuns(cwd: string, dir: string, maxRuns: number): Promise<void> {
|
|
46
|
+
try {
|
|
47
|
+
const ids = await listDirectoriesSorted(runsDir(cwd, dir)); // newest first (slug sorts chronologically)
|
|
48
|
+
const pruned = ids.slice(maxRuns);
|
|
49
|
+
if (pruned.length > 0) console.log(`[rlm-state] pruning ${pruned.length} runs (maxRuns=${maxRuns})`);
|
|
50
|
+
for (const id of pruned) {
|
|
51
|
+
await rm(runDir(cwd, dir, id), { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
warn(`pruneRuns failed: ${errorMessage(e)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export interface DispatcherSink<E> {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
handle(event: E): Promise<void>;
|
|
4
|
+
flush(): Promise<void>;
|
|
5
|
+
shutdown(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DispatcherOptions {
|
|
9
|
+
readonly maxQueueSize: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Bounded FIFO async dispatcher with drop-oldest backpressure. */
|
|
13
|
+
export class Dispatcher<E> {
|
|
14
|
+
private readonly sinks: DispatcherSink<E>[] = [];
|
|
15
|
+
private queue: E[] = [];
|
|
16
|
+
private flushing = false;
|
|
17
|
+
private inFlight: Promise<void> = Promise.resolve();
|
|
18
|
+
private shuttingDown = false;
|
|
19
|
+
private backpressureActive = false;
|
|
20
|
+
private readonly failed = new Set<string>();
|
|
21
|
+
|
|
22
|
+
constructor(private readonly options: DispatcherOptions) {}
|
|
23
|
+
|
|
24
|
+
registerSink(sink: DispatcherSink<E>): () => void {
|
|
25
|
+
this.sinks.push(sink);
|
|
26
|
+
return () => {
|
|
27
|
+
const idx = this.sinks.indexOf(sink);
|
|
28
|
+
if (idx >= 0) this.sinks.splice(idx, 1);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
dispatch(event: E): void {
|
|
33
|
+
if (this.shuttingDown || this.sinks.length === 0) return;
|
|
34
|
+
const cap = this.options.maxQueueSize;
|
|
35
|
+
if (this.queue.length >= cap) {
|
|
36
|
+
this.queue.shift();
|
|
37
|
+
if (!this.backpressureActive) {
|
|
38
|
+
this.backpressureActive = true;
|
|
39
|
+
console.warn(`[rlm-telemetry] backpressure: queue saturated at ${cap}; dropping oldest events`);
|
|
40
|
+
}
|
|
41
|
+
} else if (this.backpressureActive && this.queue.length < cap - 1) {
|
|
42
|
+
this.backpressureActive = false;
|
|
43
|
+
console.warn("[rlm-telemetry] backpressure recovered: queue back under capacity");
|
|
44
|
+
}
|
|
45
|
+
this.queue.push(event);
|
|
46
|
+
this.scheduleFlush();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async shutdown(): Promise<void> {
|
|
50
|
+
this.shuttingDown = true;
|
|
51
|
+
const remaining = this.queue;
|
|
52
|
+
this.queue = [];
|
|
53
|
+
this.flushing = false;
|
|
54
|
+
|
|
55
|
+
await this.inFlight;
|
|
56
|
+
|
|
57
|
+
for (const event of remaining) await this.broadcast(event);
|
|
58
|
+
const sinks = [...this.sinks];
|
|
59
|
+
await Promise.allSettled(sinks.map((sink) => sink.flush()));
|
|
60
|
+
await Promise.allSettled(sinks.map((sink) => sink.shutdown()));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
reset(): void {
|
|
64
|
+
this.sinks.length = 0;
|
|
65
|
+
this.queue = [];
|
|
66
|
+
this.flushing = false;
|
|
67
|
+
this.inFlight = Promise.resolve();
|
|
68
|
+
this.shuttingDown = false;
|
|
69
|
+
this.backpressureActive = false;
|
|
70
|
+
this.failed.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private scheduleFlush(): void {
|
|
74
|
+
if (this.flushing) return;
|
|
75
|
+
this.flushing = true;
|
|
76
|
+
this.drain();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private drain(): void {
|
|
80
|
+
if (this.queue.length === 0) {
|
|
81
|
+
this.flushing = false;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const batch = this.queue;
|
|
85
|
+
this.queue = [];
|
|
86
|
+
|
|
87
|
+
this.inFlight = this.inFlight.then(async () => {
|
|
88
|
+
for (const event of batch) await this.broadcast(event);
|
|
89
|
+
if (this.queue.length > 0) {
|
|
90
|
+
const handle = setImmediate(() => this.drain());
|
|
91
|
+
handle.unref?.();
|
|
92
|
+
} else {
|
|
93
|
+
this.flushing = false;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async broadcast(event: E): Promise<void> {
|
|
99
|
+
const sinks = [...this.sinks];
|
|
100
|
+
const results = await Promise.allSettled(sinks.map((sink) => sink.handle(event)));
|
|
101
|
+
results.forEach((result, idx) => {
|
|
102
|
+
const name = sinks[idx]?.name;
|
|
103
|
+
if (!name) return;
|
|
104
|
+
if (result.status === "rejected") {
|
|
105
|
+
if (!this.failed.has(name)) {
|
|
106
|
+
this.failed.add(name);
|
|
107
|
+
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
108
|
+
console.warn(`[rlm-telemetry] sink ${name} rejected event: ${reason}`);
|
|
109
|
+
}
|
|
110
|
+
} else if (this.failed.has(name)) {
|
|
111
|
+
this.failed.delete(name);
|
|
112
|
+
console.warn(`[rlm-telemetry] sink ${name} recovered`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TelemetryConfig } from "../core/types.ts";
|
|
2
|
+
import { resolveMlflowConfig } from "./mlflow-config.ts";
|
|
3
|
+
import type { TelemetrySink } from "./sink.ts";
|
|
4
|
+
|
|
5
|
+
export type { TelemetrySink };
|
|
6
|
+
|
|
7
|
+
export async function createTelemetrySink(config: TelemetryConfig | undefined): Promise<TelemetrySink | undefined> {
|
|
8
|
+
if (!config) return undefined;
|
|
9
|
+
const resolved = resolveMlflowConfig({ trackingUri: config.trackingUri, experimentId: config.experimentId });
|
|
10
|
+
const enabled = config.enabled ?? Boolean(resolved.trackingUri);
|
|
11
|
+
if (!enabled || !resolved.trackingUri) return undefined;
|
|
12
|
+
const { MlflowSink } = await import("./mlflow-sink.ts");
|
|
13
|
+
return new MlflowSink(resolved, config.maxQueueSize);
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface MlflowConfig {
|
|
2
|
+
readonly trackingUri?: string;
|
|
3
|
+
readonly experimentId?: string;
|
|
4
|
+
readonly trackingToken?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const readEnv = (key: string): string | undefined => process.env[key]?.trim() || undefined;
|
|
8
|
+
|
|
9
|
+
export function resolveMlflowConfig(config: Pick<MlflowConfig, "trackingUri" | "experimentId">): MlflowConfig {
|
|
10
|
+
return {
|
|
11
|
+
trackingUri: readEnv("MLFLOW_TRACKING_URI") || config.trackingUri,
|
|
12
|
+
experimentId: readEnv("MLFLOW_EXPERIMENT_ID") || config.experimentId,
|
|
13
|
+
trackingToken: readEnv("MLFLOW_TRACKING_TOKEN"),
|
|
14
|
+
};
|
|
15
|
+
}
|