Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d661a30
refactor(coding-agent): move the semantic-edge ledger onto the event-…
snimu Sep 3, 2026
02f1cdd
fix(coding-agent): make the explicit ledger reader's ENOENT contract …
snimu Sep 4, 2026
9e6f959
docs(coding-agent): state the event-log tail rule once
snimu Sep 4, 2026
4904fa0
Merge remote-tracking branch 'origin/main' into refactor/semantic-edg…
snimu Sep 4, 2026
ebc1a94
fix(coding-agent): write event-log appends fully and gate appends on …
snimu Sep 4, 2026
e88fc98
fix(coding-agent): reclaim short event-log writes instead of completi…
snimu Sep 4, 2026
d760284
fix(coding-agent): leave the torn tail on a short write instead of re…
snimu Sep 4, 2026
d749cf9
refactor(coding-agent): compress event-log comments
snimu Sep 4, 2026
04e456d
Merge commit '915c78f42c248b08238dd27fcd4bcab32c60beab' into consolid…
sethkarten Sep 6, 2026
0fda5e4
fix(ai): omit the default service tier, reprice cache writes from mes…
sethkarten Sep 6, 2026
536b8fb
fix(tui,coding-agent): survive lone surrogates in table cells and ter…
sethkarten Sep 7, 2026
57b2406
fix(coding-agent): restart dead kernels on ensure() and read mcp>=2 t…
sethkarten Sep 7, 2026
aad009f
fix: one crash-safe owner for durable state writes
sethkarten Sep 7, 2026
dd95bad
fix(coding-agent): one zombie-aware process-liveness probe
sethkarten Sep 7, 2026
387e109
fix(coding-agent): snapshot transfer ids from the materialized cursor…
sethkarten Sep 7, 2026
f0f129b
fix(coding-agent): failed workers recover on touch; roster gaps answe…
sethkarten Sep 7, 2026
5e6b81f
fix(coding-agent): seven session and IO correctness defects
sethkarten Sep 7, 2026
d2efb41
fix(coding-agent): coalesce child-usage attribution and gate agent-st…
sethkarten Sep 7, 2026
2497b28
fix(coding-agent): incremental single-flight session metadata scans
sethkarten Sep 7, 2026
be1fac8
fix(coding-agent): memoize the passive RLM topology derivation
sethkarten Sep 7, 2026
5ccfad6
fix(coding-agent): preserve accounting and metadata across deferred u…
sethkarten Sep 7, 2026
1c2c9f2
fix: preserve session accounting and read-only persistence boundaries
sethkarten Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Moved the semantic-edge ledger's append and replay IO onto the shared event-log substrate. One behavior unified across both ledgers: an unterminated final line is an uncommitted append — skipped on read and truncated before the next append, never newline-completed.
38 changes: 17 additions & 21 deletions packages/coding-agent/src/core/event-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ import { dirname } from "node:path";
*
* Appends are single O_APPEND writes (PIPE_BUF-scale sizes, whose atomicity
* multi-writer consumers rely on for interleaving), fsynced only when the
* caller needs durability. Replay tolerates exactly one torn FINAL line
* (rejected by the consumer's parser AND unterminated: a crashed writer's
* in-progress append) and fails closed on any malformed interior line.
* Repair happens only on append, never on read — a viewer may replay a live
* writer's log. EVERY unterminated tail is truncated at its byte offset,
* even one that parses as JSON: completing it with a newline would turn a
* line a strict consumer parser rejects into permanent fail-closed interior
* poison. Unifying consumers keeps the union of their safety behaviors.
* caller needs durability. An unterminated final line is a crashed writer's
* uncommitted append: replay never surfaces it (even when it parses — data
* the next append truncates must never be acted on) and fails closed on any
* malformed interior line. Repair happens only on append, never on read — a
* viewer may replay a live writer's log. EVERY unterminated tail is
* truncated at its byte offset, even one that parses as JSON: completing it
* with a newline would turn a line a strict consumer parser rejects into
* permanent fail-closed interior poison. Unifying consumers keeps the union
* of their safety behaviors.
*/

export interface EventLogOptions {
Expand Down Expand Up @@ -66,9 +67,9 @@ export class EventLog {
) {}

/**
* Replay every line through `parse`. `parse` throws for a line it rejects
* (fail-closed for interior lines, tolerated for a torn final line) and
* returns undefined for a line it deliberately skips.
* Replay every terminated line through `parse`. `parse` throws for a line
* it rejects (fail-closed) and returns undefined for a line it deliberately
* skips; an unterminated final line never reaches it.
*/
replaySync<T>(parse: (line: string, index: number) => T | undefined): T[] {
const { maxBytes, maxRecords } = this.options;
Expand All @@ -92,19 +93,14 @@ export class EventLog {
for (let index = 0; index < rawLines.length; index++) {
const line = rawLines[index].trim();
if (!line) continue;
if (index === rawLines.length - 1 && !endsWithNewline) {
this.options.log?.("ignored torn final line");
continue;
}
if (maxRecords !== undefined && ++recordCount > maxRecords) {
throw new Error(`event log ${this.path} exceeds ${maxRecords} records; refusing to read`);
}
let event: T | undefined;
try {
event = parse(line, index);
} catch (error) {
if (index === rawLines.length - 1 && !endsWithNewline) {
this.options.log?.(`ignored torn final line: ${error instanceof Error ? error.message : String(error)}`);
continue;
}
throw error;
}
const event = parse(line, index);
if (event !== undefined) events.push(event);
}
return events;
Expand Down
77 changes: 16 additions & 61 deletions packages/coding-agent/src/core/semantic-edges.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createHash, randomUUID } from "node:crypto";
import { appendFileSync, existsSync, mkdirSync, readFileSync, truncateSync } from "node:fs";
import { dirname, join } from "node:path";
import { statSync } from "node:fs";
import { join } from "node:path";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { EventLog } from "./event-log.js";

/**
* ACP semantic-edges-v1 producer: a durable per-agent ledger of model-request
Expand Down Expand Up @@ -158,7 +159,7 @@ export function hashTurnBody(
export class SemanticEdgeRecorder {
readonly sessionId: string;
private readonly _ledgerPath?: string;
private _pendingRepair?: { truncateToBytes: number } | { terminateLine: true };
private readonly _eventLog?: EventLog;
private _disabled = false;
private _epoch = 0;
private _lastTurn?: { requestId: string; epoch: number; bodyHash?: string };
Expand All @@ -174,10 +175,11 @@ export class SemanticEdgeRecorder {
}) {
this.sessionId = options.sessionId;
this._ledgerPath = options.ledgerPath;
this._eventLog = options.ledgerPath ? new EventLog(options.ledgerPath) : undefined;

let existing: SemanticEdgeLedgerEvent[] = [];
try {
existing = this._loadExisting();
existing = this._eventLog?.replaySync(parseSemanticEdgeLine) ?? [];
} catch (error) {
this._disable(error);
return;
Expand Down Expand Up @@ -330,41 +332,15 @@ export class SemanticEdgeRecorder {
}
}

// Construction never mutates the file: a viewer may be reading a live
// writer's ledger. Torn-tail repair is deferred to this recorder's first append.
private _loadExisting(): SemanticEdgeLedgerEvent[] {
if (!this._ledgerPath || !existsSync(this._ledgerPath)) {
return [];
}
const raw = readFileSync(this._ledgerPath, "utf8");
const parsed = parseLedgerContent(raw);
if (parsed.validLength < raw.length) {
this._pendingRepair = { truncateToBytes: Buffer.byteLength(raw.slice(0, parsed.validLength)) };
} else if (raw.length > 0 && !raw.endsWith("\n")) {
this._pendingRepair = { terminateLine: true };
}
return parsed.events;
}

// Durable append first, in-memory state second: a failed write must not leave
// commit state pointing at events that never reached the ledger.
private _append(event: SemanticEdgeLedgerEvent): boolean {
if (this._disabled) {
return false;
}
if (this._ledgerPath) {
if (this._eventLog) {
try {
mkdirSync(dirname(this._ledgerPath), { recursive: true });
if (this._pendingRepair) {
if ("truncateToBytes" in this._pendingRepair) {
// Discard the torn tail line so it never becomes mid-file corruption.
truncateSync(this._ledgerPath, this._pendingRepair.truncateToBytes);
} else {
appendFileSync(this._ledgerPath, "\n");
}
this._pendingRepair = undefined;
}
appendFileSync(this._ledgerPath, `${JSON.stringify(event)}\n`);
this._eventLog.appendSync([event]);
} catch (error) {
this._disable(error);
return false;
Expand All @@ -375,39 +351,18 @@ export class SemanticEdgeRecorder {
}
}

/**
* Parse a ledger, tolerating only a torn final line: malformed AND
* unterminated (a killed mid-append). A newline-terminated malformed line is
* real corruption anywhere in the file and throws.
*/
function parseLedgerContent(raw: string): { events: SemanticEdgeLedgerEvent[]; validLength: number } {
const events: SemanticEdgeLedgerEvent[] = [];
let offset = 0;
let validLength = 0;
let lineNumber = 0;
while (offset < raw.length) {
const newlineIndex = raw.indexOf("\n", offset);
const end = newlineIndex === -1 ? raw.length : newlineIndex + 1;
const line = raw.slice(offset, end);
lineNumber += 1;
if (line.trim().length > 0) {
try {
events.push(JSON.parse(line) as SemanticEdgeLedgerEvent);
} catch (error) {
if (newlineIndex === -1) {
return { events, validLength };
}
throw new Error(`corrupt semantic-edge ledger line ${lineNumber}: ${String(error)}`);
}
}
offset = end;
validLength = end;
function parseSemanticEdgeLine(line: string, index: number): SemanticEdgeLedgerEvent {
try {
return JSON.parse(line) as SemanticEdgeLedgerEvent;
} catch (error) {
throw new Error(`corrupt semantic-edge ledger line ${index + 1}: ${String(error)}`);
}
return { events, validLength };
}

export function readSemanticEdgeLedger(path: string): SemanticEdgeLedgerEvent[] {
return parseLedgerContent(readFileSync(path, "utf8")).events;
// A missing ledger stays loud for explicit readers; the recorder treats absence as empty.
statSync(path);
return new EventLog(path).replaySync(parseSemanticEdgeLine);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
}

interface FoldSession {
Expand Down
6 changes: 5 additions & 1 deletion packages/coding-agent/test/event-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ describe("event log substrate", () => {
const log = new EventLog(path);
log.appendSync([{ v: 1, keep: true }]);
// A newline-completion here would hand this line to strict parsers as
// permanent fail-closed interior poison; truncation must win.
// permanent fail-closed interior poison; truncation must win, and replay
// must never surface bytes the next append destroys.
writeFileSync(path, `${readFileSync(path, "utf8")}{"not":"a valid record"}`);
expect(new EventLog(path).replaySync((line) => JSON.parse(line) as { v?: number })).toEqual([
{ v: 1, keep: true },
]);
log.appendSync([{ v: 1, second: true }]);
const strict = new EventLog(path).replaySync((line, index) => {
const value = JSON.parse(line) as { v?: number };
Expand Down
9 changes: 6 additions & 3 deletions packages/coding-agent/test/semantic-edges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,20 +347,23 @@ describe("SemanticEdgeRecorder", () => {
expect(requestIds).toEqual([originalId, firstId, secondId]);
});

it("newline-terminates a valid unterminated final line before appending", () => {
it("treats a valid unterminated final line as uncommitted: skipped on read, truncated on append", () => {
const recorder = createRecorder();
const firstId = recorder.startTurnRequest();
recorder.startTurnRequest();
const path = join(tempDir, "semantic-edges.jsonl");
const raw = readFileSync(path, "utf8");
rmSync(path);
appendFileSync(path, raw.slice(0, -1));

// Never surfaced even though it parses: the next append destroys these bytes,
// so acting on them would derive edges from a request the ledger disowns.
expect(readSemanticEdgeLedger(path).filter((event) => event.type === "request_started")).toEqual([]);
const resumed = createRecorder();
const secondId = resumed.startTurnRequest();
const requestIds = readSemanticEdgeLedger(path)
.filter((event) => event.type === "request_started")
.map((event) => (event.type === "request_started" ? event.request_id : ""));
expect(requestIds).toEqual([firstId, secondId]);
expect(requestIds).toEqual([secondId]);
});

it("treats a newline-terminated malformed final line as corruption, not a torn append", () => {
Expand Down
Loading