@siuver/omp-debug-mode 0.1.4 → 0.1.6
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/CHANGELOG.md +73 -35
- package/README.md +98 -24
- package/package.json +41 -41
- package/src/debug-mode.ts +463 -409
- package/src/evidence.ts +325 -196
- package/src/gate.ts +138 -45
- package/src/log-files.ts +154 -115
- package/src/machine.ts +421 -0
- package/src/main.ts +24 -24
- package/src/methodology.ts +74 -17
- package/src/probes.ts +9 -9
- package/src/state.ts +392 -84
- package/src/tools.ts +232 -30
- package/src/ui.ts +120 -56
package/src/gate.ts
CHANGED
|
@@ -1,54 +1,147 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
/** How many times a single round may be nudged to close itself properly. */
|
|
4
|
-
export const MAX_GATE_NUDGES = 1;
|
|
5
|
-
|
|
6
|
-
export const GATE_NUDGE =
|
|
7
|
-
"This round did not close properly. It must end with BOTH a <evidence_plan> JSON block " +
|
|
8
|
-
'({"id","hypothesisIds","method","title","rationale","instructions", optional "artifactHint"}; methods: ' +
|
|
9
|
-
"agent_inspection, runtime_probe, user_report, user_artifact) AND a <reproduction_steps> numbered list " +
|
|
10
|
-
`followed by the exact sentence "${PROCEED_REMINDER}", then stop. ` +
|
|
11
|
-
MINIMIZE_USER_INTERVENTION +
|
|
12
|
-
" Every entry needs a concrete rationale; a user_report/user_artifact rationale must name why BOTH " +
|
|
13
|
-
"autonomous inspection and model-added probes cannot answer its hypotheses. " +
|
|
14
|
-
"A user_artifact request must state the file type, path/capture instructions, how you will inspect the file, " +
|
|
15
|
-
"and why inspection/probes are inadequate. Do not start new work.";
|
|
1
|
+
import { CLOSE_ROUND_RULES, HANDOFF_TOOL } from "./methodology";
|
|
2
|
+
import type { HandoffMode } from "./state";
|
|
16
3
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Per-reason nudge budgets: a probe nudge must not spend the closing one. Three
|
|
6
|
+
* matches the host's own cap for this exact failure (a turn that ended without
|
|
7
|
+
* its required tool call) and stays well under the host's continuation cap. The
|
|
8
|
+
* budget is only reachable by a model that keeps making tool calls between
|
|
9
|
+
* reminders — {@link GateFacts.awaitingProgress} stops a purely conversational
|
|
10
|
+
* turn from spending more than one.
|
|
11
|
+
*/
|
|
12
|
+
export const MAX_HANDOFF_NUDGES = 3;
|
|
13
|
+
export const MAX_PROBE_NUDGES = 1;
|
|
24
14
|
|
|
25
15
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* steps with no probes still gate (old sessions never emit plans). A turn
|
|
31
|
-
* with work but a missing tag gets one nudge, then gates with the missing
|
|
32
|
-
* flags set so the workflow can never stall.
|
|
16
|
+
* Reminders are shaped like the host's own `<system-reminder>` prompts: one
|
|
17
|
+
* instruction, numbered choices, and an explicit ban on answering in prose. The
|
|
18
|
+
* blackboard already carries the full methodology on every turn, so restating
|
|
19
|
+
* it here only buries the single action being asked for.
|
|
33
20
|
*/
|
|
34
|
-
|
|
21
|
+
function reminder(body: string): string {
|
|
22
|
+
return `<system-reminder>\n${body}\n\nYou NEVER answer this reminder with plain text.\n</system-reminder>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Sent back into a turn that stopped without the closing tool call. `wroteProse`
|
|
27
|
+
* marks the common near-miss: the round already describes the user's work, so
|
|
28
|
+
* the reminder asks for the same values as arguments instead of new content.
|
|
29
|
+
*/
|
|
30
|
+
export function handoffNudge(round: number, wroteProse: boolean): string {
|
|
31
|
+
const prose = wroteProse
|
|
32
|
+
? "You already described the steps and/or the plan in prose. Prose does not close a round: pass those same " +
|
|
33
|
+
"values as the `steps` and `plan` arguments, unchanged. "
|
|
34
|
+
: "";
|
|
35
|
+
return reminder(
|
|
36
|
+
`Debug round ${round} ended without the required ${HANDOFF_TOOL} call.\n\n` +
|
|
37
|
+
`${prose}Call ${HANDOFF_TOOL} now with exactly one mode:\n` +
|
|
38
|
+
'1. mode "reproduce" — the user must run the app so the probes record. Pass steps and plan.\n' +
|
|
39
|
+
'2. mode "capture" — the user only supplies a report or a file. Pass steps and plan.\n' +
|
|
40
|
+
'3. mode "question" — you need an answer before you can plan. steps and plan are optional.\n\n' +
|
|
41
|
+
"That call is the only thing that hands the session to the user: it renders the steps in their widget and " +
|
|
42
|
+
"makes /debug-proceed available. A rejected call names exactly what to fix and the round stays yours. Do not start new work.",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const PROBE_NUDGE = reminder(
|
|
47
|
+
"Your evidence plan selects runtime_probe, but the probe ledger is empty — no @omp-probe marker exists anywhere " +
|
|
48
|
+
"in the working tree. Instrument the code now with edit/write — that is required work for this round, not a " +
|
|
49
|
+
`product fix — and only then call ${HANDOFF_TOOL}.\n\n${CLOSE_ROUND_RULES}`,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export type GateDecision =
|
|
53
|
+
/**
|
|
54
|
+
* Hand the turn to the user. `mode` is guidance for wording only: every mode
|
|
55
|
+
* is the same user stage and allows the same commands, so a misjudged mode
|
|
56
|
+
* can never make a legitimate command illegal.
|
|
57
|
+
*/
|
|
58
|
+
| { kind: "handoff"; mode: HandoffMode; missingPlan: boolean }
|
|
59
|
+
/** Let the agent finish the round properly before handing it over. */
|
|
60
|
+
| { kind: "nudge"; budget: "handoff" | "probes"; context: string };
|
|
61
|
+
|
|
62
|
+
export interface GateFacts {
|
|
63
|
+
/** Round index, so a reminder can name the round it is about. */
|
|
64
|
+
round: number;
|
|
35
65
|
hasReproductionSteps: boolean;
|
|
36
66
|
hasEvidencePlan: boolean;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
67
|
+
/** The plan selects runtime_probe for at least one hypothesis. */
|
|
68
|
+
declaresRuntimeProbe: boolean;
|
|
69
|
+
/** The plan asks the user to report or capture something themselves. */
|
|
70
|
+
needsUserCapture: boolean;
|
|
71
|
+
/** Probes the ledger still finds on disk, session-wide. */
|
|
72
|
+
liveProbes: number;
|
|
73
|
+
/** Probes this round introduced that are still on disk. */
|
|
74
|
+
probesAddedThisRound: number;
|
|
75
|
+
nudges: { handoff: number; probes: number };
|
|
76
|
+
/** A reminder already went out and the agent has not used a tool since. */
|
|
77
|
+
awaitingProgress: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decide what a settled agent turn means.
|
|
82
|
+
*
|
|
83
|
+
* The gate only ever runs on a turn that did **not** call the handoff tool: an
|
|
84
|
+
* explicit call moves the stage itself and never settles through here. So every
|
|
85
|
+
* path below is a missing tool call, and it is answered with a reminder rather
|
|
86
|
+
* than with a silent closure — accepting the prose form as equivalent is what
|
|
87
|
+
* taught the model that skipping the call costs nothing.
|
|
88
|
+
*
|
|
89
|
+
* Every outcome is still either one reminder or a handoff, so the gate can never
|
|
90
|
+
* park a round in a state the user cannot act on.
|
|
91
|
+
*/
|
|
92
|
+
export function decideGate(facts: GateFacts): GateDecision {
|
|
93
|
+
// Declared instrumentation that never reached disk: the reproduction the
|
|
94
|
+
// user is about to be asked for could not record anything.
|
|
95
|
+
if (facts.declaresRuntimeProbe && facts.liveProbes === 0) {
|
|
96
|
+
if (facts.nudges.probes < MAX_PROBE_NUDGES) return { kind: "nudge", budget: "probes", context: PROBE_NUDGE };
|
|
97
|
+
return handoff("incomplete", facts);
|
|
98
|
+
}
|
|
99
|
+
if (facts.nudges.handoff < MAX_HANDOFF_NUDGES && !facts.awaitingProgress) {
|
|
100
|
+
return { kind: "nudge", budget: "handoff", context: handoffNudge(facts.round, wroteProse(facts)) };
|
|
101
|
+
}
|
|
102
|
+
return handoff(proseMode(facts), facts);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function wroteProse(facts: GateFacts): boolean {
|
|
106
|
+
return facts.hasReproductionSteps || facts.hasEvidencePlan;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What the round meant when the tool call never came. The prose is still the
|
|
111
|
+
* best available description of the user's work: reproduction steps are a work
|
|
112
|
+
* order the user has already read, so handing over as anything but `reproduce`
|
|
113
|
+
* would leave them acting on instructions the session claims do not exist.
|
|
114
|
+
*/
|
|
115
|
+
function proseMode(facts: GateFacts): HandoffMode {
|
|
116
|
+
if (facts.hasReproductionSteps) {
|
|
117
|
+
return facts.needsUserCapture && !facts.declaresRuntimeProbe ? "capture" : "reproduce";
|
|
118
|
+
}
|
|
119
|
+
// A user-assisted plan describes user work even when the steps tag is absent.
|
|
120
|
+
if (facts.hasEvidencePlan && facts.needsUserCapture) return "capture";
|
|
121
|
+
// Nothing declared and nothing instrumented this round: the agent was talking
|
|
122
|
+
// to the user, which is an ordinary question rather than a broken round.
|
|
123
|
+
if (!facts.hasEvidencePlan && facts.probesAddedThisRound === 0) return "question";
|
|
124
|
+
return "incomplete";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function handoff(mode: HandoffMode, facts: GateFacts): GateDecision {
|
|
128
|
+
const expectsPlan = mode === "reproduce" || mode === "capture";
|
|
129
|
+
return { kind: "handoff", mode, missingPlan: expectsPlan && !facts.hasEvidencePlan };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What the user should do now, and which command clears this mode. */
|
|
133
|
+
export function describeHandoff(mode: HandoffMode, round: number, missingPlan = false): string {
|
|
134
|
+
const planNote = missingPlan
|
|
135
|
+
? " The agent declared no valid evidence plan for this round, so nothing links what you capture to a hypothesis."
|
|
136
|
+
: "";
|
|
137
|
+
if (mode === "reproduce") {
|
|
138
|
+
return `Debug round ${round} is yours: reproduce the bug so the probes can record, then /debug-proceed.${planNote}`;
|
|
139
|
+
}
|
|
140
|
+
if (mode === "capture") {
|
|
141
|
+
return `Debug round ${round} is yours: supply the requested report or file, then /debug-proceed.${planNote}`;
|
|
42
142
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return { kind: "gate", missingSteps: false, missingEvidencePlan: true };
|
|
143
|
+
if (mode === "question") {
|
|
144
|
+
return `Debug round ${round} is yours: the agent is waiting on your reply, not on a reproduction. Run /debug-proceed only once you have something to analyze.`;
|
|
46
145
|
}
|
|
47
|
-
|
|
48
|
-
if (args.nudgesUsed < MAX_GATE_NUDGES) return { kind: "nudge", context: GATE_NUDGE };
|
|
49
|
-
return {
|
|
50
|
-
kind: "gate",
|
|
51
|
-
missingSteps: !args.hasReproductionSteps,
|
|
52
|
-
missingEvidencePlan: !args.hasEvidencePlan,
|
|
53
|
-
};
|
|
146
|
+
return `Debug round ${round} stopped without telling you what to reproduce or capture. Reply in the editor to get it finished, or /debug-proceed to continue with whatever evidence already exists.`;
|
|
54
147
|
}
|
package/src/log-files.ts
CHANGED
|
@@ -1,115 +1,154 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
|
|
4
|
-
export const ACTIVE_LOG_FILE = "current.jsonl";
|
|
5
|
-
|
|
6
|
-
export function resolveRunLogFile(
|
|
7
|
-
debugDir: string | null,
|
|
8
|
-
run: string | null,
|
|
9
|
-
activeRun: string | null,
|
|
10
|
-
): string | null {
|
|
11
|
-
if (!debugDir || !run) return null;
|
|
12
|
-
return path.join(debugDir, run === activeRun ? ACTIVE_LOG_FILE : `${run}.jsonl`);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function readJsonlLines(file: string | null): string[] {
|
|
16
|
-
if (!file) return [];
|
|
17
|
-
try {
|
|
18
|
-
return fs
|
|
19
|
-
.readFileSync(file, "utf8")
|
|
20
|
-
.split(/\r?\n/)
|
|
21
|
-
.filter(line => line.trim().length > 0);
|
|
22
|
-
} catch {
|
|
23
|
-
return [];
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface CountEntry {
|
|
28
|
-
size: number;
|
|
29
|
-
mtimeMs: number;
|
|
30
|
-
count: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Line counts keyed by file identity. The reproduction gate polls the active
|
|
35
|
-
* log once a second, so re-reading every archived run each time would scale
|
|
36
|
-
* with the length of the debugging session for no new information.
|
|
37
|
-
*/
|
|
38
|
-
export class JsonlLineCounter {
|
|
39
|
-
#cache = new Map<string, CountEntry>();
|
|
40
|
-
|
|
41
|
-
count(file: string | null): number {
|
|
42
|
-
if (!file) return 0;
|
|
43
|
-
const stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
44
|
-
if (!stat) {
|
|
45
|
-
this.#cache.delete(file);
|
|
46
|
-
return 0;
|
|
47
|
-
}
|
|
48
|
-
const hit = this.#cache.get(file);
|
|
49
|
-
if (hit && hit.size === stat.size && hit.mtimeMs === stat.mtimeMs) return hit.count;
|
|
50
|
-
const count = readJsonlLines(file).length;
|
|
51
|
-
this.#cache.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, count });
|
|
52
|
-
return count;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
clear(): void {
|
|
56
|
-
this.#cache.clear();
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface HypothesisTally {
|
|
61
|
-
id: string;
|
|
62
|
-
count: number;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const UNATTRIBUTED = "(unattributed)";
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Group observations by the hypothesis each probe was meant to settle, so the
|
|
69
|
-
* user and the agent can see which hypotheses actually produced evidence
|
|
70
|
-
* before anyone reads the raw log.
|
|
71
|
-
*/
|
|
72
|
-
export function summarizeHypotheses(lines: readonly string[]): HypothesisTally[] {
|
|
73
|
-
const counts = new Map<string, number>();
|
|
74
|
-
for (const line of lines) {
|
|
75
|
-
let id = UNATTRIBUTED;
|
|
76
|
-
try {
|
|
77
|
-
const entry = JSON.parse(line) as { hypothesisId?: unknown };
|
|
78
|
-
if (typeof entry.hypothesisId === "string" && entry.hypothesisId.trim().length > 0) {
|
|
79
|
-
id = entry.hypothesisId.trim();
|
|
80
|
-
}
|
|
81
|
-
} catch {
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
85
|
-
}
|
|
86
|
-
return [...counts]
|
|
87
|
-
.map(([id, count]) => ({ id, count }))
|
|
88
|
-
.sort((a, b) => (b.count !== a.count ? b.count - a.count : a.id < b.id ? -1 : 1));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function describeHypotheses(tallies: readonly HypothesisTally[]): string {
|
|
92
|
-
if (tallies.length === 0) return "no hypothesis-attributed observations";
|
|
93
|
-
return tallies.map(t => `${t.id}=${t.count}`).join(", ");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const ACTIVE_LOG_FILE = "current.jsonl";
|
|
5
|
+
|
|
6
|
+
export function resolveRunLogFile(
|
|
7
|
+
debugDir: string | null,
|
|
8
|
+
run: string | null,
|
|
9
|
+
activeRun: string | null,
|
|
10
|
+
): string | null {
|
|
11
|
+
if (!debugDir || !run) return null;
|
|
12
|
+
return path.join(debugDir, run === activeRun ? ACTIVE_LOG_FILE : `${run}.jsonl`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readJsonlLines(file: string | null): string[] {
|
|
16
|
+
if (!file) return [];
|
|
17
|
+
try {
|
|
18
|
+
return fs
|
|
19
|
+
.readFileSync(file, "utf8")
|
|
20
|
+
.split(/\r?\n/)
|
|
21
|
+
.filter(line => line.trim().length > 0);
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CountEntry {
|
|
28
|
+
size: number;
|
|
29
|
+
mtimeMs: number;
|
|
30
|
+
count: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Line counts keyed by file identity. The reproduction gate polls the active
|
|
35
|
+
* log once a second, so re-reading every archived run each time would scale
|
|
36
|
+
* with the length of the debugging session for no new information.
|
|
37
|
+
*/
|
|
38
|
+
export class JsonlLineCounter {
|
|
39
|
+
#cache = new Map<string, CountEntry>();
|
|
40
|
+
|
|
41
|
+
count(file: string | null): number {
|
|
42
|
+
if (!file) return 0;
|
|
43
|
+
const stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
44
|
+
if (!stat) {
|
|
45
|
+
this.#cache.delete(file);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
const hit = this.#cache.get(file);
|
|
49
|
+
if (hit && hit.size === stat.size && hit.mtimeMs === stat.mtimeMs) return hit.count;
|
|
50
|
+
const count = readJsonlLines(file).length;
|
|
51
|
+
this.#cache.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, count });
|
|
52
|
+
return count;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
clear(): void {
|
|
56
|
+
this.#cache.clear();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface HypothesisTally {
|
|
61
|
+
id: string;
|
|
62
|
+
count: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const UNATTRIBUTED = "(unattributed)";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Group observations by the hypothesis each probe was meant to settle, so the
|
|
69
|
+
* user and the agent can see which hypotheses actually produced evidence
|
|
70
|
+
* before anyone reads the raw log.
|
|
71
|
+
*/
|
|
72
|
+
export function summarizeHypotheses(lines: readonly string[]): HypothesisTally[] {
|
|
73
|
+
const counts = new Map<string, number>();
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
let id = UNATTRIBUTED;
|
|
76
|
+
try {
|
|
77
|
+
const entry = JSON.parse(line) as { hypothesisId?: unknown };
|
|
78
|
+
if (typeof entry.hypothesisId === "string" && entry.hypothesisId.trim().length > 0) {
|
|
79
|
+
id = entry.hypothesisId.trim();
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
85
|
+
}
|
|
86
|
+
return [...counts]
|
|
87
|
+
.map(([id, count]) => ({ id, count }))
|
|
88
|
+
.sort((a, b) => (b.count !== a.count ? b.count - a.count : a.id < b.id ? -1 : 1));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function describeHypotheses(tallies: readonly HypothesisTally[]): string {
|
|
92
|
+
if (tallies.length === 0) return "no hypothesis-attributed observations";
|
|
93
|
+
return tallies.map(t => `${t.id}=${t.count}`).join(", ");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const BUSY_CODES = new Set(["EBUSY", "EACCES", "EAGAIN"]);
|
|
97
|
+
const TRUNCATE_ATTEMPTS = 6;
|
|
98
|
+
const TRUNCATE_RETRY_MS = 50;
|
|
99
|
+
|
|
100
|
+
function sleepSync(ms: number): void {
|
|
101
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function errorCode(error: unknown): string | undefined {
|
|
105
|
+
if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
|
|
106
|
+
return error.code;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isBusyError(error: unknown): boolean {
|
|
111
|
+
const code = errorCode(error);
|
|
112
|
+
return code !== undefined && BUSY_CODES.has(code);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Empty the stable probe path for the next round. Windows fails CREATE_ALWAYS
|
|
117
|
+
* while another process (or this process's own `watchFile`) still has the
|
|
118
|
+
* file open, so busy errors are retried; a successful archive is never
|
|
119
|
+
* rolled back if truncate then fails.
|
|
120
|
+
*/
|
|
121
|
+
function truncateActiveLog(activeFile: string): void {
|
|
122
|
+
let last: unknown;
|
|
123
|
+
for (let attempt = 0; attempt < TRUNCATE_ATTEMPTS; attempt++) {
|
|
124
|
+
if (attempt > 0) sleepSync(TRUNCATE_RETRY_MS);
|
|
125
|
+
try {
|
|
126
|
+
fs.writeFileSync(activeFile, "", { flag: "w" });
|
|
127
|
+
return;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
last = error;
|
|
130
|
+
if (!isBusyError(error)) throw error;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
fs.truncateSync(activeFile, 0);
|
|
134
|
+
return;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
last = error;
|
|
137
|
+
if (!isBusyError(error)) throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw last;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function prepareRunLog(debugDir: string, previousRun: string | null): string {
|
|
144
|
+
fs.mkdirSync(debugDir, { recursive: true });
|
|
145
|
+
const activeFile = path.join(debugDir, ACTIVE_LOG_FILE);
|
|
146
|
+
const archivedFile = previousRun ? path.join(debugDir, `${previousRun}.jsonl`) : null;
|
|
147
|
+
|
|
148
|
+
if (archivedFile) {
|
|
149
|
+
if (fs.existsSync(activeFile)) fs.copyFileSync(activeFile, archivedFile);
|
|
150
|
+
else fs.writeFileSync(archivedFile, "", { flag: "a" });
|
|
151
|
+
}
|
|
152
|
+
truncateActiveLog(activeFile);
|
|
153
|
+
return activeFile;
|
|
154
|
+
}
|