Skip to content

Commit 79c2fb0

Browse files
committed
Inject home into session dual-read and test isolation
Legacy sessions are discovered under cwd and the shared git project root so worktree resumes find main-repo .agent-state. Optional home on store, workflow state, and seeded approvals keeps unit tests out of the real ~/.corbits tree.
1 parent c68bd50 commit 79c2fb0

9 files changed

Lines changed: 100 additions & 54 deletions

File tree

docs/IMPLEMENTATION.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,8 @@ Session runtime state lives under the global projects tree (not in the repo):
309309

310310
- `~/.corbits/projects/<project-key>/<session-id>/run.json``RunState`
311311
- `~/.corbits/projects/<project-key>/<session-id>/context/` — git-backed conversation context (`@intx/storage-isogit`)
312-
- Project key: slug + short hash of the git toplevel realpath (or workspace realpath when not a git tree), so main + worktrees share resume history
312+
- Project key: slug + short hash of the shared git root (from `--git-common-dir`, so main + linked worktrees share one key; workspace realpath when not a git tree)
313+
313314
- Migration: if a session exists only under in-repo `.agent-state/<session-id>/`, it is moved into the global tree on open/list
314315
- Atomic JSON writes with schema validation on load
315316

src/permission/store.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import { sessionDir } from "../session/index.js";
1010
import { SETTINGS_DIR_NAME } from "../branding.js";
1111

1212
// Approvals are remembered per session, alongside the run state.
13-
function storePath(cwd: string, sessionId: string): string {
14-
return join(sessionDir(cwd, sessionId), "permissions.json");
13+
function storePath(cwd: string, sessionId: string, home?: string): string {
14+
return join(sessionDir(cwd, sessionId, home), "permissions.json");
1515
}
1616

17+
1718
// Persistent project grants live next to the project's settings. The file is
1819
// gitignored (machine-local), so a teammate who pulls the repo never silently
1920
// inherits another machine's auto-approvals.
@@ -104,10 +105,15 @@ function chainObjectWrite(
104105
return chained;
105106
}
106107

107-
export async function loadApprovals(cwd: string, sessionId: string): Promise<Approval[]> {
108-
return readApprovalsField(storePath(cwd, sessionId), "approvals");
108+
export async function loadApprovals(
109+
cwd: string,
110+
sessionId: string,
111+
home?: string,
112+
): Promise<Approval[]> {
113+
return readApprovalsField(storePath(cwd, sessionId, home), "approvals");
109114
}
110115

116+
111117
export async function loadProjectApprovals(cwd: string): Promise<Approval[]> {
112118
return readApprovalsField(projectStorePath(cwd), "approvals");
113119
}

src/session/index.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ function legacySessionCandidates(cwd: string, sessionId: string): string[] {
7676
return [underCwd, join(projectRoot, LEGACY_SESSION_BASE, sessionId)];
7777
}
7878

79+
/** Legacy roots that may still hold unmigrated sessions (cwd + project root). */
80+
function legacySessionRoots(cwd: string): string[] {
81+
const underCwd = join(cwd, LEGACY_SESSION_BASE);
82+
const projectRoot = projectRootFor(cwd);
83+
if (realpathSafe(projectRoot) === realpathSafe(cwd)) return [underCwd];
84+
return [underCwd, join(projectRoot, LEGACY_SESSION_BASE)];
85+
}
86+
87+
function legacyLatestCandidates(cwd: string): string[] {
88+
return legacySessionRoots(cwd).map((base) => join(base, "latest"));
89+
}
90+
91+
7992
function realpathSafe(path: string): string {
8093
try {
8194
return realpathSync(path);
@@ -165,22 +178,26 @@ export async function resolveLatestSession(
165178
contextDir: sessionContextDir(cwd, sessionId, home),
166179
};
167180
} catch {
168-
// Fall back: legacy latest symlink under .agent-state, then migrate.
169-
try {
170-
const legacyLink = join(cwd, LEGACY_SESSION_BASE, "latest");
171-
const sessionId = await readlink(legacyLink);
172-
const dir = await migrateLegacySessionIfNeeded(cwd, sessionId, home);
173-
return {
174-
sessionId,
175-
dir,
176-
contextDir: sessionContextDir(cwd, sessionId, home),
177-
};
178-
} catch {
179-
return null;
181+
// Fall back: legacy latest under cwd, then under the git project root
182+
// (worktree cwd may not have its own .agent-state/latest).
183+
for (const legacyLink of legacyLatestCandidates(cwd)) {
184+
try {
185+
const sessionId = await readlink(legacyLink);
186+
const dir = await migrateLegacySessionIfNeeded(cwd, sessionId, home);
187+
return {
188+
sessionId,
189+
dir,
190+
contextDir: sessionContextDir(cwd, sessionId, home),
191+
};
192+
} catch {
193+
// try next candidate
194+
}
180195
}
196+
return null;
181197
}
182198
}
183199

200+
184201
export type SessionSummary = {
185202
sessionId: string;
186203
task: string;
@@ -193,7 +210,7 @@ const SESSION_ID_RE =
193210

194211
async function collectSessionIds(cwd: string, home: string): Promise<string[]> {
195212
const ids = new Set<string>();
196-
const roots = [projectSessionsRoot(cwd, home), join(cwd, LEGACY_SESSION_BASE)];
213+
const roots = [projectSessionsRoot(cwd, home), ...legacySessionRoots(cwd)];
197214
for (const base of roots) {
198215
let entries: string[];
199216
try {
@@ -209,6 +226,7 @@ async function collectSessionIds(cwd: string, home: string): Promise<string[]> {
209226
return [...ids];
210227
}
211228

229+
212230
/** List on-disk sessions for a project, newest first. */
213231
export async function listSessions(cwd: string, home: string = homedir()): Promise<SessionSummary[]> {
214232
const entries = await collectSessionIds(cwd, home);

src/session/runtime-assembly.test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,25 +53,26 @@ describe("buildSubAgentProvider", () => {
5353

5454
describe("loadSeededApprovals merge order", () => {
5555
let cwd = "";
56+
let home = "";
5657
let sessionId = "";
5758

5859
afterEach(async () => {
59-
if (cwd !== "" && sessionId !== "") {
60-
await rm(sessionDir(cwd, sessionId), { recursive: true, force: true }).catch(() => undefined);
61-
}
6260
if (cwd !== "") await rm(cwd, { recursive: true, force: true });
61+
if (home !== "") await rm(home, { recursive: true, force: true });
6362
cwd = "";
63+
home = "";
6464
sessionId = "";
6565
});
6666

6767
test("orders session, then project, before empty global/provider-model layers", async () => {
6868
cwd = await mkdtemp(join(tmpdir(), "runtime-assembly-"));
69+
home = await mkdtemp(join(tmpdir(), "runtime-assembly-home-"));
6970
sessionId = generateSessionId();
70-
await initSessionDir(cwd, sessionId);
71+
await initSessionDir(cwd, sessionId, home);
7172

72-
await mkdir(sessionDir(cwd, sessionId), { recursive: true });
73+
await mkdir(sessionDir(cwd, sessionId, home), { recursive: true });
7374
await writeFile(
74-
join(sessionDir(cwd, sessionId), "permissions.json"),
75+
join(sessionDir(cwd, sessionId, home), "permissions.json"),
7576
JSON.stringify({
7677
approvals: [{ tool: "run_shell", pattern: "session npm *" }],
7778
}),
@@ -81,13 +82,14 @@ describe("loadSeededApprovals merge order", () => {
8182
pattern: "project npm *",
8283
});
8384

84-
const seeded = await loadSeededApprovals(cwd, sessionId);
85+
const seeded = await loadSeededApprovals(cwd, sessionId, home);
8586

8687
// Session must lead so gate first-match prefers the tighter session grant.
8788
// Global / provider-model layers may contain real-home entries; assert prefix only.
8889
expect(seeded[0]).toEqual({ tool: "run_shell", pattern: "session npm *" });
8990
expect(seeded[1]).toEqual({ tool: "run_shell", pattern: "project npm *" });
9091
});
92+
9193
});
9294

9395
describe("createApprovalPersist", () => {

src/session/runtime-assembly.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,12 @@ export function buildSubAgentProvider(config: SubAgentProviderConfig): SubAgentP
7171
// ---------------------------------------------------------------------------
7272

7373
/** Session → project → global → provider-model merge order (first match wins in gate). */
74-
export async function loadSeededApprovals(cwd: string, sessionId: string): Promise<Approval[]> {
75-
const sessionApprovals = await loadApprovals(cwd, sessionId);
74+
export async function loadSeededApprovals(
75+
cwd: string,
76+
sessionId: string,
77+
home?: string,
78+
): Promise<Approval[]> {
79+
const sessionApprovals = await loadApprovals(cwd, sessionId, home);
7680
const [projectApprovals, globalApprovals, providerModelApprovals] = await Promise.all([
7781
loadProjectApprovals(cwd),
7882
loadGlobalApprovals(),
@@ -86,6 +90,7 @@ export async function loadSeededApprovals(cwd: string, sessionId: string): Promi
8690
];
8791
}
8892

93+
8994
/**
9095
* Route a gate-persisted grant to the store its scope selects.
9196
* Session grants never reach here — the gate keeps those in memory only.

src/workflows/state.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ export async function saveWorkflowState(
4646
cwd: string,
4747
sessionId: string,
4848
state: WorkflowState,
49+
home?: string,
4950
): Promise<void> {
50-
const path = workflowStatePath(cwd, sessionId);
51+
const path = workflowStatePath(cwd, sessionId, home);
5152
const payload = JSON.stringify(state, null, 2);
5253
const run = (): Promise<void> => atomicWrite(path, payload);
5354
const chained = (writeChains.get(path) ?? Promise.resolve()).then(run, run);
@@ -56,16 +57,22 @@ export async function saveWorkflowState(
5657
}
5758

5859
/** Await any in-flight save for this session (used by tests and shutdown paths). */
59-
export async function flushWorkflowStateWrites(cwd: string, sessionId: string): Promise<void> {
60-
const path = workflowStatePath(cwd, sessionId);
60+
export async function flushWorkflowStateWrites(
61+
cwd: string,
62+
sessionId: string,
63+
home?: string,
64+
): Promise<void> {
65+
const path = workflowStatePath(cwd, sessionId, home);
6166
await (writeChains.get(path) ?? Promise.resolve());
6267
}
6368

6469
export async function loadWorkflowState(
6570
cwd: string,
6671
sessionId: string,
72+
home?: string,
6773
): Promise<WorkflowState | null> {
68-
const path = workflowStatePath(cwd, sessionId);
74+
const path = workflowStatePath(cwd, sessionId, home);
75+
6976
try {
7077
const raw = await readFile(path, "utf8");
7178
const parsed = JSON.parse(raw);

tests/unit/workflow-controller.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ async function withController(
2525
) => void | Promise<void>,
2626
): Promise<void> {
2727
const cwd = await mkdtemp(join(tmpdir(), "wf-controller-"));
28-
await initSessionDir(cwd, "session-1");
28+
const home = await mkdtemp(join(tmpdir(), "wf-controller-home-"));
29+
await initSessionDir(cwd, "session-1", home);
2930
const director = { coordinator: undefined as WorkflowCoordinator | undefined };
3031
const controller = new WorkflowController({
3132
cwd,
@@ -41,11 +42,13 @@ async function withController(
4142
try {
4243
await fn(controller, director, cwd);
4344
} finally {
44-
await flushWorkflowStateWrites(cwd, "session-1");
45+
await flushWorkflowStateWrites(cwd, "session-1", home);
4546
await rm(cwd, { recursive: true, force: true });
47+
await rm(home, { recursive: true, force: true });
4648
}
4749
}
4850

51+
4952
test("starting a workflow attaches a coordinator to the director", async () => {
5053
await withController([], async (controller, director, _cwd) => {
5154
const msg = controller.start("review");

tests/unit/workflows-runtime-persistence.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { loadWorkflowState, saveWorkflowState } from "../../src/workflows/state.
1010

1111
test("WorkflowRuntime resumes from workflow.json written mid sub-workflow chain", async () => {
1212
const cwd = await mkdtemp(join(tmpdir(), "wf-runtime-persist-"));
13+
const home = await mkdtemp(join(tmpdir(), "wf-runtime-persist-home-"));
1314
try {
1415
const build = findWorkflow("build");
1516
expect(build).toBeDefined();
@@ -23,8 +24,8 @@ test("WorkflowRuntime resumes from workflow.json written mid sub-workflow chain"
2324
expect(mid).toBeDefined();
2425
expect(mid).not.toBe(first);
2526

26-
await saveWorkflowState(cwd, "session-1", runtime.state());
27-
const loaded = await loadWorkflowState(cwd, "session-1");
27+
await saveWorkflowState(cwd, "session-1", runtime.state(), home);
28+
const loaded = await loadWorkflowState(cwd, "session-1", home);
2829
expect(loaded).toEqual(runtime.state());
2930

3031
const resumed = new WorkflowRuntime(new Map());
@@ -34,5 +35,6 @@ test("WorkflowRuntime resumes from workflow.json written mid sub-workflow chain"
3435
expect(resumed.isActive()).toBe(true);
3536
} finally {
3637
await rm(cwd, { recursive: true, force: true });
38+
await rm(home, { recursive: true, force: true });
3739
}
3840
});

tests/unit/workflows-state.test.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,36 +18,38 @@ const sampleState: WorkflowState = {
1818

1919
describe("workflow state persistence", () => {
2020
let cwd: string;
21+
let home: string;
2122

2223
beforeEach(async () => {
2324
cwd = await mkdtemp(join(tmpdir(), "wf-state-"));
25+
home = await mkdtemp(join(tmpdir(), "wf-state-home-"));
2426
});
2527

2628
afterEach(async () => {
27-
await rm(sessionDir(cwd, SESSION_ID), { recursive: true, force: true }).catch(() => undefined);
2829
await rm(cwd, { recursive: true, force: true });
30+
await rm(home, { recursive: true, force: true });
2931
});
3032

3133
test("saveWorkflowState then loadWorkflowState returns an equal object", async () => {
32-
await saveWorkflowState(cwd, SESSION_ID, sampleState);
33-
const loaded = await loadWorkflowState(cwd, SESSION_ID);
34+
await saveWorkflowState(cwd, SESSION_ID, sampleState, home);
35+
const loaded = await loadWorkflowState(cwd, SESSION_ID, home);
3436
expect(loaded).toEqual(sampleState);
3537
});
3638

3739
test("loadWorkflowState on missing file returns null", async () => {
38-
expect(await loadWorkflowState(cwd, "nope")).toBeNull();
40+
expect(await loadWorkflowState(cwd, "nope", home)).toBeNull();
3941
});
4042

4143
test("loadWorkflowState with truncated JSON returns null instead of throwing", async () => {
42-
const dir = sessionDir(cwd, SESSION_ID);
44+
const dir = sessionDir(cwd, SESSION_ID, home);
4345
await mkdir(dir, { recursive: true });
4446
await writeFile(join(dir, "workflow.json"), '{ "completed": false, "stack": [');
4547

46-
expect(await loadWorkflowState(cwd, SESSION_ID)).toBeNull();
48+
expect(await loadWorkflowState(cwd, SESSION_ID, home)).toBeNull();
4749
});
4850

4951
test("loadWorkflowState rejects invalid stepIndex values", async () => {
50-
const dir = sessionDir(cwd, SESSION_ID);
52+
const dir = sessionDir(cwd, SESSION_ID, home);
5153
await mkdir(dir, { recursive: true });
5254
await writeFile(
5355
join(dir, "workflow.json"),
@@ -57,11 +59,11 @@ describe("workflow state persistence", () => {
5759
}),
5860
);
5961

60-
expect(await loadWorkflowState(cwd, SESSION_ID)).toBeNull();
62+
expect(await loadWorkflowState(cwd, SESSION_ID, home)).toBeNull();
6163
});
6264

6365
test("loadWorkflowState rejects unknown step statuses", async () => {
64-
const dir = sessionDir(cwd, SESSION_ID);
66+
const dir = sessionDir(cwd, SESSION_ID, home);
6567
await mkdir(dir, { recursive: true });
6668
await writeFile(
6769
join(dir, "workflow.json"),
@@ -71,30 +73,30 @@ describe("workflow state persistence", () => {
7173
}),
7274
);
7375

74-
expect(await loadWorkflowState(cwd, SESSION_ID)).toBeNull();
76+
expect(await loadWorkflowState(cwd, SESSION_ID, home)).toBeNull();
7577
});
7678

7779
test("saveWorkflowState leaves no .tmp file after successful write", async () => {
78-
await saveWorkflowState(cwd, SESSION_ID, sampleState);
79-
const files = await readdir(sessionDir(cwd, SESSION_ID));
80+
await saveWorkflowState(cwd, SESSION_ID, sampleState, home);
81+
const files = await readdir(sessionDir(cwd, SESSION_ID, home));
8082
expect(files.filter((f) => f.includes(".tmp"))).toHaveLength(0);
8183
});
8284

8385
test("saveWorkflowState overwrites a pre-existing file with well-formed JSON", async () => {
84-
await saveWorkflowState(cwd, SESSION_ID, sampleState);
86+
await saveWorkflowState(cwd, SESSION_ID, sampleState, home);
8587
const updated: WorkflowState = { ...sampleState, completed: true, stack: [] };
86-
await saveWorkflowState(cwd, SESSION_ID, updated);
87-
const raw = await readFile(join(sessionDir(cwd, SESSION_ID), "workflow.json"), "utf8");
88+
await saveWorkflowState(cwd, SESSION_ID, updated, home);
89+
const raw = await readFile(join(sessionDir(cwd, SESSION_ID, home), "workflow.json"), "utf8");
8890
expect(JSON.parse(raw)).toEqual(updated);
8991
});
9092

9193
test("concurrent saveWorkflowState calls serialize and leave valid JSON", async () => {
9294
await Promise.all([
93-
saveWorkflowState(cwd, SESSION_ID, sampleState),
94-
saveWorkflowState(cwd, SESSION_ID, { ...sampleState, completed: true }),
95-
saveWorkflowState(cwd, SESSION_ID, sampleState),
95+
saveWorkflowState(cwd, SESSION_ID, sampleState, home),
96+
saveWorkflowState(cwd, SESSION_ID, { ...sampleState, completed: true }, home),
97+
saveWorkflowState(cwd, SESSION_ID, sampleState, home),
9698
]);
97-
const loaded = await loadWorkflowState(cwd, SESSION_ID);
99+
const loaded = await loadWorkflowState(cwd, SESSION_ID, home);
98100
expect(loaded).not.toBeNull();
99101
expect(loaded?.stack).toEqual(sampleState.stack);
100102
});

0 commit comments

Comments
 (0)