Skip to content

Commit 5eb71fe

Browse files
committed
Add write-lock reentrancy protection, formatEntryPreview, and ledger tool TUI renderers
1 parent 6bf6f1c commit 5eb71fe

2 files changed

Lines changed: 112 additions & 23 deletions

File tree

ledger/store.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,40 +10,65 @@ import {
1010
DEFAULT_MAX_LINES,
1111
truncateHead,
1212
} from "@earendil-works/pi-coding-agent";
13+
import { AsyncLocalStorage } from "node:async_hooks";
1314
import type { AgenticodingState } from "../state.js";
1415

16+
/**
17+
* Module-level write lock state.
18+
*
19+
* Concurrent callers serialize by chaining on the prior promise. Reentrancy is
20+
* tracked per async call chain so a nested saveLedgerEntry fails explicitly
21+
* without rejecting unrelated concurrent writers that happen to overlap.
22+
*/
1523
let writeLock: Promise<void> = Promise.resolve();
24+
const writeContext = new AsyncLocalStorage<true>();
25+
26+
/** Reset write lock state. Only for test cleanup after concurrent runs. */
27+
export function resetLedgerWriteLock(): void {
28+
writeLock = Promise.resolve();
29+
}
1630

17-
async function acquireWriteLock(): Promise<() => void> {
31+
async function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
32+
if (writeContext.getStore()) {
33+
throw new Error(
34+
"Ledger write lock is not reentrant — saveLedgerEntry called from within its own critical section.",
35+
);
36+
}
1837
let release: () => void;
1938
const prev = writeLock;
2039
writeLock = new Promise<void>((resolve) => {
2140
release = resolve;
2241
});
2342
await prev;
24-
return release!;
43+
try {
44+
return await writeContext.run(true, fn);
45+
} finally {
46+
release!();
47+
}
2548
}
2649

2750
export function getEntryNames(state: AgenticodingState): string[] {
2851
return Array.from(state.ledger.keys()).sort();
2952
}
3053

31-
const PREVIEW_MAX_CHARS = 80;
54+
export const PREVIEW_MAX_CHARS = 80;
3255
const ELLIPSIS_LENGTH = 3;
3356

57+
export function formatEntryPreview(content: string): string {
58+
const firstLine = content.split("\n")[0] ?? "";
59+
return firstLine.length > PREVIEW_MAX_CHARS
60+
? firstLine.slice(0, PREVIEW_MAX_CHARS - ELLIPSIS_LENGTH) + "..."
61+
: firstLine;
62+
}
63+
3464
export function formatEntryList(state: AgenticodingState): string {
3565
const names = getEntryNames(state);
3666
if (names.length === 0) return "";
3767

3868
return names
3969
.map((name) => {
4070
const content = state.ledger.get(name)!;
41-
const firstLine = content.split("\n")[0] ?? "";
42-
const preview =
43-
firstLine.length > PREVIEW_MAX_CHARS
44-
? firstLine.slice(0, PREVIEW_MAX_CHARS - ELLIPSIS_LENGTH) + "..."
45-
: firstLine;
46-
return ` ${name}: ${preview}`;
71+
return ` ${name}: ${formatEntryPreview(content)}`;
4772
})
4873
.join("\n");
4974
}
@@ -53,11 +78,10 @@ export async function saveLedgerEntry(
5378
state: AgenticodingState,
5479
name: string,
5580
content: string,
56-
assertWritable?: () => void,
57-
): Promise<string[]> {
58-
const release = await acquireWriteLock();
59-
try {
60-
assertWritable?.();
81+
assertWritable?: () => void | Promise<void>,
82+
): Promise<{ entries: string[]; preview: string }> {
83+
return withWriteLock(async () => {
84+
await assertWritable?.();
6185
const truncated = truncateHead(content, {
6286
maxLines: DEFAULT_MAX_LINES,
6387
maxBytes: DEFAULT_MAX_BYTES,
@@ -75,8 +99,9 @@ export async function saveLedgerEntry(
7599
content: truncated.content,
76100
});
77101

78-
return getEntryNames(state);
79-
} finally {
80-
release();
81-
}
102+
return {
103+
entries: getEntryNames(state),
104+
preview: formatEntryPreview(truncated.content),
105+
};
106+
});
82107
}

ledger/tools.ts

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
*/
88

99
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
10+
import { Text } from "@earendil-works/pi-tui";
1011
import { Type } from "typebox";
1112
import type { AgenticodingState } from "../state.js";
12-
import { formatEntryList, getEntryNames, saveLedgerEntry } from "./store.js";
13+
import { updateIndicators } from "../tui.js";
14+
import { formatEntryList, formatEntryPreview, getEntryNames, saveLedgerEntry } from "./store.js";
1315

1416
// ── Factory ───────────────────────────────────────────────────────────
1517

@@ -62,18 +64,52 @@ export function createLedgerToolDefinitions(
6264
"Truncated at 50KB / 2000 lines.",
6365
}),
6466
}),
65-
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
67+
renderCall(args, theme, _context) {
68+
const preview = formatEntryPreview(args.content).trim();
69+
70+
let text = theme.fg("toolTitle", theme.bold("ledger_add ")) +
71+
theme.fg("accent", `"${args.name}"`);
72+
if (preview) {
73+
text += ": " + theme.fg("dim", preview);
74+
}
75+
return new Text(text, 0, 0);
76+
},
77+
78+
renderResult(result, { expanded }, theme, context) {
79+
const details = result.details as { entries: string[]; preview: string };
80+
81+
let text = theme.fg("success", "\u2713 Saved ") + theme.fg("accent", `"${context.args.name}"`);
82+
if (details.preview) {
83+
text += ": " + theme.fg("dim", details.preview);
84+
}
85+
if (expanded) {
86+
text += "\n" + theme.fg("dim", details.entries.join("\n"));
87+
}
88+
return new Text(text, 0, 0);
89+
},
90+
91+
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
6692
assertFresh();
67-
const names = await saveLedgerEntry(pi, state, params.name, params.content, assertFresh);
93+
const saved = await saveLedgerEntry(pi, state, params.name, params.content, assertFresh);
94+
updateIndicators(ctx, state);
95+
96+
onUpdate?.({
97+
content: [{
98+
type: "text",
99+
text: `Saved "${params.name}"` + (saved.preview ? `: ${saved.preview}` : ""),
100+
}],
101+
details: { entries: saved.entries, preview: saved.preview },
102+
});
68103
return {
69104
content: [
70105
{
71106
type: "text",
72107
text: `Saved ledger entry "${params.name}".` +
108+
(saved.preview ? `\n${saved.preview}` : "") +
73109
`\n\nEntries:\n${formatEntryList(state) || "(empty)"}`,
74110
},
75111
],
76-
details: { entries: names },
112+
details: { entries: saved.entries, preview: saved.preview },
77113
};
78114
},
79115
};
@@ -92,6 +128,22 @@ export function createLedgerToolDefinitions(
92128
description: "Entry name to retrieve.",
93129
}),
94130
}),
131+
renderResult(result, { expanded }, theme, context) {
132+
const details = result.details as { entries: string[]; found: boolean; body?: string };
133+
if (!details.found) {
134+
return new Text(
135+
theme.fg("error", "\u2717 ") + theme.fg("muted", `"${context.args.name}" not found`),
136+
0,
137+
0,
138+
);
139+
}
140+
let text = theme.fg("success", "\u2713 ") + theme.fg("accent", `"${context.args.name}"`);
141+
if (expanded && details.body) {
142+
text += "\n" + theme.fg("toolOutput", details.body.trim());
143+
}
144+
return new Text(text, 0, 0);
145+
},
146+
95147
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
96148
assertFresh();
97149
const content = state.ledger.get(params.name);
@@ -120,7 +172,7 @@ export function createLedgerToolDefinitions(
120172
`---\nEntries:\n${formatEntryList(state) || "(empty)"}`,
121173
},
122174
],
123-
details: { entries: names, found: true },
175+
details: { entries: names, found: true, body: content },
124176
};
125177
},
126178
};
@@ -135,6 +187,18 @@ export function createLedgerToolDefinitions(
135187
? { promptSnippet: "List all ledger entries" }
136188
: {}),
137189
parameters: Type.Object({}),
190+
renderResult(result, { expanded }, theme, _context) {
191+
const entries = (result.details as { entries: string[] }).entries;
192+
if (entries.length === 0) {
193+
return new Text(theme.fg("dim", "\u{1F4D2} (empty)"), 0, 0);
194+
}
195+
let text = theme.fg("muted", `\u{1F4D2} ${entries.length} entr${entries.length === 1 ? "y" : "ies"}`);
196+
if (expanded) {
197+
text += "\n" + theme.fg("dim", entries.join("\n"));
198+
}
199+
return new Text(text, 0, 0);
200+
},
201+
138202
async execute() {
139203
assertFresh();
140204
const names = getEntryNames(state);

0 commit comments

Comments
 (0)