Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 6 additions & 3 deletions packages/coding-agent/docs/invocation-traces.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Invocation Traces

Diffex creates one invocation trace for each CLI process whose initial session is persisted.
Diffex creates immutable invocation trace intervals for each CLI process whose initial session is persisted.
An interval closes as soon as its operations settle, so a running peer can discover completed evidence without reading a trace that is still changing.
Concurrent operations in one process share an interval until every operation in that interval settles.
Ephemeral commands such as `--no-session`, `--help`, and `--list-models` do not create traces.

Trace creation is passive.
Expand All @@ -10,6 +12,7 @@ If the trace file cannot be created, Diffex prints one warning and continues wit

Trace files are stored in `~/.diffex/agent/traces/` by default, or beneath the directory selected by `DIFFEX_CODING_AGENT_DIR`.
Each file is named `<UTC timestamp>_<trace ID>.jsonl`.
A long-running process can create multiple files as successive operations settle.
The trace directory is restricted to the current user with mode `0700`, and each trace file uses mode `0600` on POSIX systems.

Tools can discover traces by scanning this directory for `.jsonl` files and validating them with the harness trace reader.
Expand Down Expand Up @@ -37,8 +40,8 @@ Each fact has one authoritative source so a trace remains compact and materializ
| AgentHarness session JSONL | Durable entries, operation admission and settlement, abort requests, retry attempts, tool admission, queue changes, deferred writes, usage, lane pointers, and session facts | `session_item` evidence selected by durable session sequence |
| Agent and AI telemetry spans | Agent runs, turns, logical provider requests, provider outcomes, tool execution outcomes, usage, and causal invocation, turn, request, and tool-call identifiers | Consolidated `span` evidence |
| Coding-agent and extension telemetry spans | User-visible operations, retries and sleeps, extension hook outcomes, subagent coordination, terminal commands, external artifact references, and runtime diagnostics | Consolidated `span` evidence |
| Trace lifecycle records | Producer identity, session bindings, harness activation, session segments, trace writer degradation, and completed, failed, or aborted process termination | `trace_record` evidence and document diagnostics |
| Missing trace footer | A process that did not close its trace orderly | A `crashed` materialized termination status with a missing-footer diagnostic |
| Trace lifecycle records | Producer identity, session bindings, harness activation, session segments, trace writer degradation, and completed, failed, or aborted interval termination | `trace_record` evidence and document diagnostics |
| Missing trace footer | A process that did not close its active interval orderly | A `crashed` materialized termination status with a missing-footer diagnostic |

Agent and extension events remain live notification APIs.
Their durable facts are represented by the owning session entry, session record, or telemetry span listed above rather than by copying event payloads into the trace.
Expand Down
101 changes: 101 additions & 0 deletions packages/coding-agent/src/core/evolution-failure-reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import lockfile from "proper-lockfile";
import type { EvolutionFailureRecord, EvolutionFailureReporter } from "#evolution";

interface EvolutionFailureFile {
readonly schemaVersion: 1;
readonly jobId: string;
readonly failures: readonly EvolutionFailureRecord[];
}

function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isFailureRecord(value: unknown, jobId: string): value is EvolutionFailureRecord {
return (
isRecord(value) &&
value.schemaVersion === 1 &&
value.jobId === jobId &&
typeof value.trigger === "string" &&
typeof value.stage === "string" &&
typeof value.outcome === "string" &&
typeof value.reasonCode === "string" &&
typeof value.failedAt === "string" &&
typeof value.attempt === "number" &&
isRecord(value.error)
);
}

export class FileEvolutionFailureReporter implements EvolutionFailureReporter {
readonly #directory: string;

constructor(directory: string) {
this.#directory = directory;
}

async #load(path: string, jobId: string): Promise<EvolutionFailureFile> {
try {
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
if (
!isRecord(parsed) ||
parsed.schemaVersion !== 1 ||
parsed.jobId !== jobId ||
!Array.isArray(parsed.failures) ||
!parsed.failures.every((failure) => isFailureRecord(failure, jobId))
) {
throw new Error(`Evolution failure report at "${path}" is invalid`);
}
return { schemaVersion: 1, jobId, failures: parsed.failures };
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return { schemaVersion: 1, jobId, failures: [] };
}
throw error;
}
}

async record(failure: EvolutionFailureRecord): Promise<void> {
if (basename(failure.jobId) !== failure.jobId || !/^job-[a-zA-Z0-9][a-zA-Z0-9._-]*$/u.test(failure.jobId)) {
throw new Error(`Invalid evolution job ID "${failure.jobId}"`);
}
await mkdir(this.#directory, { recursive: true, mode: 0o700 });
const lockPath = join(this.#directory, ".write");
const release = await lockfile.lock(lockPath, {
lockfilePath: `${lockPath}.lock`,
realpath: false,
retries: { retries: 200, factor: 1, minTimeout: 5, maxTimeout: 25, randomize: true },
stale: 30_000,
update: 10_000,
});
try {
const path = join(this.#directory, failure.jobId);
const current = await this.#load(path, failure.jobId);
const next: EvolutionFailureFile = {
schemaVersion: 1,
jobId: failure.jobId,
failures: [...current.failures, failure],
};
const temporaryPath = join(this.#directory, `.${failure.jobId}.${randomUUID()}.tmp`);
try {
await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o600,
});
await rename(temporaryPath, path);
} catch (error) {
await unlink(temporaryPath).catch((cleanupError: unknown) => {
if (!(cleanupError instanceof Error && "code" in cleanupError && cleanupError.code === "ENOENT")) {
throw cleanupError;
}
});
throw error;
}
} finally {
await release();
}
}
}
Loading