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
2 changes: 1 addition & 1 deletion docs/plans/2026-08-14-standalone-pr2-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ interface ConversationDirectoryIdentity {
`ConversationWorkspace` 新增窄方法:

- `prepareStandaloneDirectory(sessionId)`:返回 `{ identity, created }`;valid existing empty child 可复用,existing non-empty 返回 conflict。
- `ensureStandaloneDirectory(sessionId, expected?)`:load/repair 使用;与expected同identity的existing返回`ready`,missing创建后返回`recreated`existing replacement返回compromised。
- `ensureStandaloneDirectory(sessionId, expected?)`:load/repair 使用;与expected同identity的existing返回`ready`;missing后首次创建(无expected)返回`created`,已捕获identity后重建(有expected)返回`recreated`existing replacement返回compromised。
- `inspectStandaloneDirectory(sessionId, expected?)`:区分 `ready`、`missing`、`compromised`;给 prompt preflight 使用。
- 现有`discardEmptyConversationDirectory(sessionId)`保持Live-only兼容实现;standalone路径不调用它。路径式删除无法原子绑定到前一次`lstat`得到的identity,PR2A不增加一个名为exact但仍有replacement race的overload。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,29 +345,29 @@ describe('Live conversation workspace root', () => {
}
});

it('inspects, recreates, and rejects replaced standalone child identities', async () => {
it('inspects, creates, and rejects replaced standalone child identities', async () => {
const home = await tempHome();
const workspace = new ConversationWorkspace({ homeDir: home });

await expect(
workspace.inspectStandaloneDirectory('standalone'),
).resolves.toEqual({ status: 'missing' });
const recreated = await workspace.ensureStandaloneDirectory('standalone');
expect(recreated.status).toBe('recreated');
if (recreated.status !== 'recreated') throw new Error('expected recreate');
const created = await workspace.ensureStandaloneDirectory('standalone');
expect(created.status).toBe('created');
if (created.status !== 'created') throw new Error('expected creation');

await expect(
workspace.inspectStandaloneDirectory('standalone', recreated.identity),
workspace.inspectStandaloneDirectory('standalone', created.identity),
).resolves.toMatchObject({ status: 'ready' });

// Keep the original inode alive under a sibling name so the replacement
// cannot reuse it (ext4/overlayfs recycle freed inodes immediately).
const preserved = `${recreated.identity.canonicalPath}.preserved`;
await rename(recreated.identity.canonicalPath, preserved);
await mkdir(recreated.identity.canonicalPath, { mode: 0o700 });
const preserved = `${created.identity.canonicalPath}.preserved`;
await rename(created.identity.canonicalPath, preserved);
await mkdir(created.identity.canonicalPath, { mode: 0o700 });
const compromised = await workspace.inspectStandaloneDirectory(
'standalone',
recreated.identity,
created.identity,
);
expect(compromised.status).toBe('compromised');
if (compromised.status !== 'compromised') {
Expand All @@ -379,6 +379,29 @@ describe('Live conversation workspace root', () => {
expect(compromised.error.reason).toBe('unexpected_identity');
});

it('reports recreated only when a known identity vanished', async () => {
const home = await tempHome();
const workspace = new ConversationWorkspace({ homeDir: home });
const prepared = await workspace.prepareStandaloneDirectory('standalone');
// Keep the original inode alive under a sibling name so the replacement
// cannot reuse it and accidentally satisfy the expected identity.
await rename(
prepared.identity.canonicalPath,
`${prepared.identity.canonicalPath}.preserved`,
);

const ensured = await workspace.ensureStandaloneDirectory(
'standalone',
prepared.identity,
);
expect(ensured.status).toBe('recreated');
if (ensured.status !== 'recreated') throw new Error('expected recreate');
expect(ensured.identity.canonicalPath).toBe(
prepared.identity.canonicalPath,
);
expect(ensured.identity.inode).not.toBe(prepared.identity.inode);
});

it('returns the raced inspection when a concurrent creator wins the ensure race', async () => {
const home = await tempHome();
const workspace = new ConversationWorkspace({ homeDir: home });
Expand Down Expand Up @@ -445,4 +468,42 @@ describe('Live conversation workspace root', () => {
// surface a fresh identity_changed here; the raced reason must survive.
expect(ensured.error).toBe(racedError);
});

it('re-inspects a raced standalone directory even without an expected identity', async () => {
const home = await tempHome();
const workspace = new ConversationWorkspace({ homeDir: home });
const prepared = await workspace.prepareStandaloneDirectory('standalone');

const inspect = vi.spyOn(workspace, 'inspectStandaloneDirectory');
inspect.mockResolvedValueOnce({ status: 'missing' });

const ensured = await workspace.ensureStandaloneDirectory('standalone');
expect(ensured).toMatchObject({
status: 'ready',
identity: prepared.identity,
});
expect(inspect).toHaveBeenCalledTimes(2);
});

it('propagates a raced compromised verdict when no identity is expected', async () => {
const home = await tempHome();
const workspace = new ConversationWorkspace({ homeDir: home });
await workspace.prepareStandaloneDirectory('standalone');

const racedError = new ConversationDirectoryIdentityError(
'child',
'wrong_mode',
);
const inspect = vi.spyOn(workspace, 'inspectStandaloneDirectory');
inspect
.mockResolvedValueOnce({ status: 'missing' })
.mockResolvedValueOnce({ status: 'compromised', error: racedError });

const ensured = await workspace.ensureStandaloneDirectory('standalone');
expect(ensured.status).toBe('compromised');
if (ensured.status !== 'compromised') {
throw new Error('expected compromised');
}
expect(ensured.error).toBe(racedError);
});
});
11 changes: 9 additions & 2 deletions packages/cli/src/serve/conversations/conversation-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type StandaloneDirectoryInspection =

export type StandaloneDirectoryEnsureResult =
| { status: 'ready'; identity: ConversationDirectoryIdentity }
| { status: 'created'; identity: ConversationDirectoryIdentity }
| { status: 'recreated'; identity: ConversationDirectoryIdentity }
| {
status: 'compromised';
Expand Down Expand Up @@ -297,7 +298,11 @@ export class ConversationWorkspace {
root,
storageSessionId,
);
if (!materialized.created && expected) {
if (!materialized.created) {
// The directory appeared inside the race window, so it is adopted
// only through the same inspection verdict as one found up front —
// never as a blind 'ready' carrying whatever the racing creator put
// there.
const raced = await this.inspectStandaloneDirectory(
storageSessionId,
expected,
Expand All @@ -308,8 +313,10 @@ export class ConversationWorkspace {
'identity_changed',
);
}
// 'recreated' is reserved for a directory that vanished after its
// identity was captured; a first-ever creation reports 'created'.
return {
status: materialized.created ? 'recreated' : 'ready',
status: expected ? 'recreated' : 'created',
identity: materialized.identity,
};
} catch (error) {
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/utils/conversation-directory-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ describe('conversation directory identity', () => {
);
});

it('exposes an error cause only when one was provided', () => {
const bare = new ConversationDirectoryIdentityError('child', 'not_empty');
expect('cause' in bare).toBe(false);
expect(Object.keys(bare)).not.toContain('cause');

const cause = new Error('boom');
const wrapped = new ConversationDirectoryIdentityError(
'child',
'io_error',
cause,
);
expect(wrapped.cause).toBe(cause);
expect('cause' in wrapped).toBe(true);
expect(Object.keys(wrapped)).not.toContain('cause');
});

it('pins root and direct-child device and inode identity', async () => {
const { root } = await tempRoot();
const result = await materializeConversationDirectoryIdentity(
Expand Down
17 changes: 8 additions & 9 deletions packages/cli/src/utils/conversation-directory-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,20 @@ export type ConversationDirectoryIdentityFailureReason =

export class ConversationDirectoryIdentityError extends Error {
override readonly name = 'ConversationDirectoryIdentityError';
override readonly cause?: unknown;

constructor(
readonly scope: ConversationDirectoryIdentityScope,
readonly reason: ConversationDirectoryIdentityFailureReason,
cause?: unknown,
) {
super(`Conversation ${scope} identity validation failed: ${reason}`);
if (cause !== undefined) {
Object.defineProperty(this, 'cause', {
configurable: true,
enumerable: false,
value: cause,
});
}
// Passing the options bag through to Error keeps `cause` a non-enumerable
// own property that exists only when one was provided; a class field
// declaration would define an enumerable `cause: undefined` on every
// instance instead.
super(
`Conversation ${scope} identity validation failed: ${reason}`,
cause !== undefined ? { cause } : undefined,
);
}
}

Expand Down
29 changes: 28 additions & 1 deletion packages/core/src/utils/jsonl-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,33 @@ describe('read() / readLines() with malformed lines', () => {
).resolves.toMatchObject({ complete: false });
});

it('measures completeness against a line budget, not a record budget', async () => {
// Line 1 alone satisfies a 2-record budget; the corrupt line 2 must still
// be scanned because the budget counts physical lines.
const file = tmpFile('{"i":1}{"i":2}\n{"i":\n');

await expect(
readLinesWithIntegrity<{ i: number }>(file, 2),
).resolves.toMatchObject({ complete: false });
});

it('returns every record recovered from the scanned lines', async () => {
const file = tmpFile('{"i":1}{"i":2}\n{"i":3}\n');

await expect(
readLinesWithIntegrity<{ i: number }>(file, 1),
).resolves.toEqual({ records: [{ i: 1 }, { i: 2 }], complete: true });
});

it('keeps the plain reader on a record budget after zero-record lines', async () => {
const file = tmpFile('{"i":\nnull\n{"i":1}\n{"i":2}\n');

await expect(readLines<{ i: number }>(file, 2)).resolves.toEqual([
{ i: 1 },
{ i: 2 },
]);
});

it('skips blank lines', async () => {
const file = tmpFile('{"a":1}\n\n{"a":2}\n');
expect(await read<{ a: number }>(file)).toEqual([{ a: 1 }, { a: 2 }]);
Expand Down Expand Up @@ -357,7 +384,7 @@ describe('reader resource cleanup', () => {
readLinesWithIntegrity<{ i: number }>(file, 1),
);

expect(result).toEqual({ records: [{ i: 1 }], complete: true });
expect(result).toEqual({ records: [{ i: 1 }, { i: 2 }], complete: true });
});

it('closes the file stream after read consumes all lines', async () => {
Expand Down
36 changes: 24 additions & 12 deletions packages/core/src/utils/jsonl-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* Efficient JSONL (JSON Lines) file utilities.
*
* Reading operations:
* - readLines() - Reads the first N lines efficiently using buffered I/O
* - readLines() - Reads the first N records efficiently using buffered I/O
* - read() - Reads entire file into memory as array
*
* Writing operations:
Expand Down Expand Up @@ -194,14 +194,11 @@ async function closeLineReader(
await closed;
}

/**
* Reads the first N lines from a JSONL file efficiently.
* Returns an array of parsed objects.
*/
async function readLinesWithIntegrityInternal<T = unknown>(
filePath: string,
count: number,
options: JsonlReadLinesOptions = {},
budget: 'records' | 'lines' = 'records',
): Promise<{ records: T[]; complete: boolean }> {
let fileStream: fs.ReadStream | undefined;
let rl: readline.Interface | undefined;
Expand All @@ -217,14 +214,21 @@ async function readLinesWithIntegrityInternal<T = unknown>(

const results: T[] = [];
let complete = true;
let scannedLines = 0;
for await (const line of rl) {
if (results.length >= count) break;
if (
(budget === 'records' && results.length >= count) ||
(budget === 'lines' && scannedLines >= count)
) {
break;
}
const trimmed = line.trim();
if (trimmed.length === 0) continue;
scannedLines++;
const parsed = parseLineTolerantWithIntegrity<T>(trimmed, filePath);
complete &&= parsed.complete;
for (const obj of parsed.records) {
if (results.length >= count) break;
if (budget === 'records' && results.length >= count) break;
results.push(obj);
}
}
Expand All @@ -235,7 +239,7 @@ async function readLinesWithIntegrityInternal<T = unknown>(
options.signal?.throwIfAborted();
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
debugLogger.error(
`Error reading first ${count} lines from ${filePath}:`,
`Error reading up to ${count} ${budget} from ${filePath}:`,
error,
);
}
Expand All @@ -251,17 +255,25 @@ export async function readLines<T = unknown>(
count: number,
options: JsonlReadLinesOptions = {},
): Promise<T[]> {
return (await readLinesWithIntegrityInternal<T>(filePath, count, options))
.records;
// The slice preserves this reader's record-budget contract: at most `count`
// records even when a glued line recovers several.
return (
await readLinesWithIntegrityInternal<T>(filePath, count, options)
).records.slice(0, count);
}

/** Reports whether every scanned non-empty line was fully recoverable. */
/**
* Reads every record from the first `count` non-empty lines. `complete`
* reports whether each of those lines was fully recoverable, so fail-closed
* callers get a deterministic line-prefix coverage rather than one that
* shrinks when early lines are `}{`-glued.
*/
export async function readLinesWithIntegrity<T = unknown>(
filePath: string,
count: number,
options: JsonlReadLinesOptions = {},
): Promise<{ records: T[]; complete: boolean }> {
return readLinesWithIntegrityInternal<T>(filePath, count, options);
return readLinesWithIntegrityInternal<T>(filePath, count, options, 'lines');
}

/**
Expand Down
Loading