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/GJC-WORKER-PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ scope is fixed by this specification, and a frame that gets it wrong is rejected
| `turn.start` | scoped | Run a turn in an existing conversation. |
| `turn.abort` | scoped | Stop the running turn. |
| `turn.steer` | scoped | Inject input into the running turn without ending it. |
| `goal.inspect` | scoped | Read the exact persisted provider session's goal for an authenticated owner and resolved project cwd; refuses an active run. |
| `goal.inspect` | scoped | Read the exact persisted provider session's goal for an authenticated owner and resolved project cwd; refuses an app-owned active run. Inspection uses read-only inventory and an SDK-validated immutable transcript snapshot, projecting only the current branch without constructing a session manager: it never recovers backups, rewrites the transcript, persists replay sanitation, or changes source sidecars or image blobs, including when an external CLI is still writing. |
| `goal.control` | scoped | Read or control a goal on the exact worker `runId`; requires matching app session, owner, cwd, and expected goal ID. The trusted server may pass literal `stopAfterMutation: false` for pause/drop so the enclosing native worktree job owns cancellation. |
| `ask.reply` | scoped | Answer a question the worker raised with `ask.presented`. |

Expand Down
42 changes: 37 additions & 5 deletions server/gjc-bun-sdk-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { resolveModelRoleValue } from '@gajae-code/coding-agent/config/model-res
import { Settings } from '@gajae-code/coding-agent/config/settings';
import { AuthStorage } from '@gajae-code/coding-agent/session/auth-storage';
import { SessionDisposalIncompleteError } from '@gajae-code/coding-agent/session/agent-session';
import { SessionManager } from '@gajae-code/coding-agent/session/session-manager';
import { parseSessionEntries, SessionManager, type SessionEntry } from '@gajae-code/coding-agent/session/session-manager';
import { MemorySessionStorage } from '@gajae-code/coding-agent/session/session-storage';
import { executeAcpBuiltinSlashCommand } from '@gajae-code/coding-agent/slash-commands/acp-builtins';
import { initTheme, theme } from '@gajae-code/coding-agent/modes/theme/theme';
import { generateSessionTitle } from '@gajae-code/coding-agent/utils/title-generator';
Expand Down Expand Up @@ -791,17 +792,48 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime {
}

async #inspectGjcGoal(scope: GjcGoalScope, providerSessionId: string, sessionRoot: string): Promise<GjcGoalSnapshot> {
const manager = await resumeManager(providerSessionId, sessionRoot);
// list/open can recover backups and persist replay sanitation. A goal read must
// never acquire write ownership of a transcript an external CLI may be using.
const matches = (await SessionManager.listForResumePickerReadOnly('', sessionRoot))
.filter((session) => session.id === providerSessionId);
if (matches.length !== 1) throw new Error(FAILURE);
const captured = SessionManager.captureTranscriptStrict(matches[0].path);
if (captured.kind !== 'captured') throw new Error(FAILURE);
const { snapshot } = captured;
try {
const { state, scope: owner } = readPersistedGjcGoal(manager);
const actualCwd = await realpath(manager.getCwd());
if (snapshot.identity.sessionId !== providerSessionId) throw new Error(FAILURE);
// Validate an immutable copy with the SDK. Never construct a manager: even
// recovery hydration can rebuild sidecars and write shared image blobs.
const bytes = Buffer.from(snapshot.materialize());
const storage = new MemorySessionStorage();
storage.writeBytesOwnedSync(snapshot.identity.canonicalPath, bytes);
const inspected = await SessionManager.inspectSessionTailReadOnly(snapshot.identity.canonicalPath, storage);
if (inspected.kind === 'error' || inspected.identity.sessionId !== providerSessionId
|| inspected.identity.sha256 !== snapshot.identity.sha256) throw new Error(FAILURE);
const entries = parseSessionEntries(bytes.toString('utf8'));
const header = entries[0];
if (header?.type !== 'session') throw new Error(FAILURE);
const sessionEntries = entries.filter((entry): entry is SessionEntry => entry.type !== 'session');
const byId = new Map(sessionEntries.map((entry) => [entry.id, entry]));
const branch: SessionEntry[] = [];
const visited = new Set<string>();
// Match getBranch(): the last entry is the leaf, not every chronological mode change.
for (let entry = sessionEntries.at(-1); entry && !visited.has(entry.id);
entry = entry.parentId ? byId.get(entry.parentId) : undefined) {
visited.add(entry.id);
branch.push(entry);
}
branch.reverse();
const { state, scope: owner } = readPersistedGjcGoal({ getBranch: () => branch });
const actualCwd = await realpath(header.cwd);
if (actualCwd !== (owner?.cwd ?? scope.cwd)) throw new Error('Goal working directory does not match the persisted session.');
if (snapshot.revalidate().kind !== 'valid') throw new Error(FAILURE);
return {
supported: true, goal: state?.goal ?? null, runId: null,
canControl: owner ? matchesGjcGoalOwner(owner, scope) : !state,
resumeRequired: state?.goal.status === 'active',
};
} finally { await manager.close(); }
} finally { snapshot.close(); }
}

async controlGjcGoal(runId: string, scope: GjcGoalScope, command?: GjcGoalCommand, stopAfterMutation = true): Promise<GjcGoalSnapshot> {
Expand Down
2 changes: 1 addition & 1 deletion server/gjc-goal-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type GoalManager = {
const OWNER_ENTRY = 'gajae-goal-owner-v1';
export const GJC_GOAL_MODEL_OPERATIONS = ['create', 'get', 'pause', 'resume', 'complete'] as const;

export function readPersistedGjcGoal(manager: GoalManager): { state?: GjcGoalState; scope?: GjcGoalScope } {
export function readPersistedGjcGoal(manager: Pick<GoalManager, 'getBranch'>): { state?: GjcGoalState; scope?: GjcGoalScope } {
let state: GjcGoalState | undefined;
let scope: GjcGoalScope | undefined;
for (const entry of manager.getBranch()) {
Expand Down
192 changes: 190 additions & 2 deletions server/gjc-sdk-contract.bun.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import net from 'node:net';
import { isAbsolute, join, relative } from 'node:path';
import { test } from 'node:test';

import { BlobStore } from '@gajae-code/coding-agent/session/blob-store';
import { ACP_BUILTIN_SLASH_COMMANDS } from '@gajae-code/coding-agent/slash-commands/acp-builtins';
import { createAgentSession, discoverAuthStorage, type AutomationTools } from '@gajae-code/coding-agent/sdk/session';
import { ModelRegistry } from '@gajae-code/coding-agent/config/model-registry';
import { Settings } from '@gajae-code/coding-agent/config/settings';
import { SessionManager } from '@gajae-code/coding-agent/session/session-manager';
import { CURRENT_SESSION_VERSION, SessionManager } from '@gajae-code/coding-agent/session/session-manager';
import { SessionDisposalIncompleteError } from '@gajae-code/coding-agent/session/agent-session';
import { AsyncJobManager } from '@gajae-code/coding-agent/async/job-manager';
import { registerCustomApi, unregisterCustomApis } from '@gajae-code/ai/api-registry';
Expand Down Expand Up @@ -1316,6 +1317,193 @@ test('resume opens the sole exact session file and never re-emits session.create
} finally { await f.close(); }
});

async function goalTranscriptSnapshot(path: string) {
const bytes = await readFile(path);
const { ino, size, mtimeNs, ctimeNs } = await stat(path, { bigint: true });
return { bytes, identity: { ino, size, mtimeNs, ctimeNs } };
}

for (const status of [null, 'active', 'paused', 'complete'] as const) {
test(`goal inspection is read-only for a live managed transcript: ${status ?? 'no goal on current branch'}`, async () => {
const f = await fixture();
let manager: SessionManager | undefined;
try {
await mkdir(join(f.root, 'project'));
const cwd = await realpath(join(f.root, 'project'));
const scope = { appSessionId: 'goal-inspection', owner: 'number:1', cwd };
manager = SessionManager.create(cwd, SessionManager.managedDestination(cwd, join(f.root, 'agent')));
assert.equal(manager.isManagedDestination(), true);
manager.appendMessage({ role: 'user', content: 'Keep the external CLI session active', timestamp: Date.now() });
const answer: AssistantMessage = {
...identityAnswer('Offline response'), api: 'openai-responses', provider: 'openai',
content: [{ type: 'thinking', thinking: 'Offline reasoning', thinkingSignature: 'live-provider-signature' },
{ type: 'text', text: 'Offline response' }],
providerPayload: { type: 'openaiResponsesHistory', provider: 'openai',
items: [{ type: 'reasoning', id: 'rs_live', encrypted_content: 'live-provider-payload', summary: [] }] },
};
const branchPoint = manager.appendMessage(answer);
const goal = { id: 'persisted-goal', objective: 'Preserve the live CLI transcript', status: status ?? 'active',
tokensUsed: 17, timeUsedSeconds: 3, createdAt: 100, updatedAt: 200 };
manager.appendCustomEntry('gajae-goal-owner-v1', status ? scope : { ...scope, owner: 'abandoned-owner' });
manager.appendModeChange(status === 'paused' ? 'goal_paused' : 'goal', { goal });
if (status === null) {
// A goal on an abandoned branch must not leak into the current leaf's projection.
manager.branch(branchPoint);
manager.appendMessage({ role: 'user', content: 'Continue without the abandoned goal', timestamp: Date.now() });
}
await manager.flush();
const path = manager.getSessionFile()!;
const before = await goalTranscriptSnapshot(path);
assert.match(before.bytes.toString(), /live-provider-signature/);
assert.match(before.bytes.toString(), /live-provider-payload/);
const inspect = (owner = scope) => f.adapter.inspectGjcGoal(owner, manager!.getSessionId(), manager!.getSessionDir());
const snapshot = await inspect();
const after = await goalTranscriptSnapshot(path);
assert.deepEqual(after.bytes, before.bytes, 'goal inspection must not persist replay sanitation');
assert.deepEqual(after.identity, before.identity, 'goal inspection must not touch the transcript');
assert.deepEqual(snapshot, {
supported: true, goal: status ? goal : null, runId: null, canControl: true, resumeRequired: status === 'active',
});
if (status) {
assert.deepEqual(await inspect({ ...scope, owner: 'number:2' }), { ...snapshot, canControl: false });
} else {
await assert.rejects(inspect({ ...scope, cwd: f.root }), /working directory does not match/);
}
assert.deepEqual(await goalTranscriptSnapshot(path), before);
assert.deepEqual(f.sessions, [], 'goal inspection must not construct a provider session');
const message = { role: 'user' as const, content: 'The original CLI owner can still append', timestamp: Date.now() };
const id = manager.appendMessage(message);
await manager.flush();
const continued = await readFile(path);
assert.deepEqual(continued.subarray(0, before.bytes.length), before.bytes);
const last = JSON.parse(continued.toString().trimEnd().split('\n').at(-1)!);
assert.equal(last.id, id);
assert.deepEqual(last.message, message, 'the original managed writer must persist its next append');
} finally {
try { if (manager) assert.equal((await manager.closeStrict()).kind, 'closed'); }
finally { await f.close(); }
}
});
}

for (const destination of ['managed', 'explicit'] as const) {
test(`goal inspection preserves compacted ${destination} session sidecars on success and rejection`, async () => {
const f = await fixture();
const cwd = await realpath(f.root);
const manager = SessionManager.create(cwd, destination === 'managed'
? SessionManager.managedDestination(cwd, join(f.root, 'agent')) : f.root);
try {
const first = manager.appendMessage({ role: 'user', content: 'Before compaction', timestamp: Date.now() });
manager.appendMessage(identityAnswer('A compacted answer'));
manager.appendCompaction('Retain the current goal', undefined, first, 100);
const goal = { id: 'compacted-goal', objective: 'Preserve sidecars', status: 'active',
tokensUsed: 1, timeUsedSeconds: 1, createdAt: 100, updatedAt: 200 };
manager.appendModeChange('goal', { goal });
await manager.flush();
const path = manager.getSessionFile()!;
const sidecarDir = path.slice(0, -6);
await mkdir(sidecarDir, { recursive: true });
const sidecars = [`${path}.spill.idx`, ...['idx', 'tail', 'commit', 'capture-probe.tmp']
.map((suffix) => join(sidecarDir, `.session-memory.spill.${suffix}`))];
for (const file of sidecars) await writeFile(file, `External writer owns ${file}`);
const originals = await Promise.all([path, ...sidecars].map(goalTranscriptSnapshot));
const inventory = (await readdir(sidecarDir)).sort();
const scope = { appSessionId: 'compacted-inspection', owner: 'number:1', cwd };
const snapshot = await f.adapter.inspectGjcGoal(scope, manager.getSessionId(), manager.getSessionDir());
assert.deepEqual(snapshot.goal, goal);
assert.equal(snapshot.resumeRequired, true);
await assert.rejects(f.adapter.inspectGjcGoal({ ...scope, cwd: join(cwd, 'other') },
manager.getSessionId(), manager.getSessionDir()), /working directory does not match/);
assert.deepEqual(await Promise.all([path, ...sidecars].map(goalTranscriptSnapshot)), originals);
assert.deepEqual((await readdir(sidecarDir)).sort(), inventory);
const id = manager.appendMessage({ role: 'user', content: 'Continue after inspection', timestamp: Date.now() });
await manager.flush();
assert.equal(JSON.parse((await readFile(path, 'utf8')).trimEnd().split('\n').at(-1)!).id, id);
assert.deepEqual(f.sessions, []);
} finally {
try { assert.equal((await manager.closeStrict()).kind, 'closed'); }
finally { await f.close(); }
}
});
}

test('goal inspection never materializes image blobs or rewrites their shared store', async () => {
const f = await fixture();
const originalPut = BlobStore.prototype.putSync;
let writes = 0;
BlobStore.prototype.putSync = () => {
writes++;
throw new Error('Goal inspection attempted a shared blob write');
};
try {
const cwd = await realpath(f.root);
const id = 'image-goal';
const path = join(cwd, 'image.jsonl');
const records = [
{ type: 'session', version: CURRENT_SESSION_VERSION, starredPatchVersion: 1, id, cwd, timestamp: new Date().toISOString() },
{ type: 'message', id: 'image-message', parentId: null, timestamp: new Date().toISOString(),
message: { role: 'user', timestamp: Date.now(), content: [
{ type: 'image', mimeType: 'image/png', data: Buffer.alloc(128 * 1024, 1).toString('base64') },
] } },
];
await writeFile(path, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`);
const before = await goalTranscriptSnapshot(path);
const scope = { appSessionId: id, owner: 'number:1', cwd };
const result = await f.adapter.inspectGjcGoal(scope, id, cwd);
assert.equal(result.goal, null);
await assert.rejects(f.adapter.inspectGjcGoal({ ...scope, cwd: join(cwd, 'wrong') }, id, cwd), /working directory does not match/);
assert.equal(writes, 0, 'goal reads must not hydrate images into the filesystem BlobStore');
assert.deepEqual(await goalTranscriptSnapshot(path), before);
} finally { BlobStore.prototype.putSync = originalPut; await f.close(); }
});

test('goal inspection inventory leaves orphan backups and missing targets untouched', async () => {
const f = await fixture();
try {
const scope = { appSessionId: 'goal-inventory', owner: 'number:1', cwd: await realpath(f.root) };
const header = { type: 'session', version: CURRENT_SESSION_VERSION, id: 'orphan-goal', cwd: scope.cwd,
timestamp: new Date().toISOString() };
const target = join(f.root, 'orphan.jsonl');
const backup = `${target}.123.bak`;
await writeFile(backup, `${JSON.stringify(header)}\n`);
const before = await goalTranscriptSnapshot(backup);
const inventory = await readdir(f.root);
await assert.rejects(f.adapter.inspectGjcGoal(scope, 'missing-goal', f.root));
assert.deepEqual(await readdir(f.root), inventory, 'read-only inventory must not recover orphan backups');
assert.deepEqual(await goalTranscriptSnapshot(backup), before);
await assert.rejects(f.adapter.inspectGjcGoal(scope, header.id, f.root));
await assert.rejects(stat(target), { code: 'ENOENT' });
const missingRoot = join(f.root, 'missing-sessions');
await assert.rejects(f.adapter.inspectGjcGoal(scope, 'missing-goal', missingRoot));
await assert.rejects(stat(missingRoot), { code: 'ENOENT' });
assert.deepEqual(await goalTranscriptSnapshot(backup), before);
assert.deepEqual(f.sessions, []);
} finally { await f.close(); }
});

test('goal inspection rejects malformed and ambiguous transcripts without mutation', async () => {
const f = await fixture();
try {
const scope = { appSessionId: 'goal-invalid', owner: 'number:1', cwd: await realpath(f.root) };
const header = { type: 'session', version: CURRENT_SESSION_VERSION, id: 'malformed-goal', cwd: scope.cwd,
timestamp: new Date().toISOString() };
const malformed = join(f.root, 'malformed.jsonl');
await writeFile(malformed, `${JSON.stringify(header)}\n{"type":\n`);
const before = await goalTranscriptSnapshot(malformed);
await assert.rejects(f.adapter.inspectGjcGoal(scope, header.id, f.root));
assert.deepEqual(await goalTranscriptSnapshot(malformed), before);
const first = join(f.root, 'first.jsonl');
const duplicate = join(f.root, 'duplicate.jsonl');
await writeFile(first, `${JSON.stringify({ ...header, id: 'duplicate-goal' })}\n`);
await copyFile(first, duplicate);
const originals = await Promise.all([first, duplicate].map(goalTranscriptSnapshot));
await assert.rejects(f.adapter.inspectGjcGoal(scope, 'duplicate-goal', f.root));
assert.deepEqual(await Promise.all([first, duplicate].map(goalTranscriptSnapshot)), originals);
assert.deepEqual(await goalTranscriptSnapshot(malformed), before);
assert.deepEqual(f.sessions, []);
} finally { await f.close(); }
});

/** Real SDK construction; prompts are intercepted before any model transport can run. */
async function identityFixture(behavior: {
realPrompts?: boolean;
Expand Down