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
7 changes: 6 additions & 1 deletion apps/server/src/orchestration-v2/runtimeLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,14 @@ const layerOrchestratorProvided = Orchestrator.layer.pipe(
),
);

// One scanner serves the wizard's scan and imports, so imports can reuse its candidates.
const layerAgentSessionScannerProvided = AgentSessionScanner.layer.pipe(
Layer.provide(ProviderSessionRuntime.layer),
);
const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe(
Layer.provide(
Layer.mergeAll(
AgentSessionScanner.layer,
layerAgentSessionScannerProvided,
layerProjectService,
layerOrchestratorProvided,
layerEventSink,
Expand Down Expand Up @@ -395,6 +399,7 @@ export const layerProduction = Layer.mergeAll(
),
),
layerProviderContinuationWorkerProvided,
layerAgentSessionScannerProvided,
layerAgentSessionImporterProvided,
EffectOutbox.layerPruneWorker.pipe(Layer.provide(EffectOutbox.layer)),
).pipe(
Expand Down
9 changes: 1 addition & 8 deletions apps/server/src/project/AgentSessionImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
isRuntimeModeAbove,
AgentSessionImportProjectChangedError,
AgentSessionImportProjectNotFoundError,
AgentSessionImportSource,
AgentSessionScanError,
AgentSessionSource,
EventId,
Expand Down Expand Up @@ -46,12 +45,6 @@ import * as ProjectService from "./ProjectService.ts";
const IMPORT_EVENT_PREFIX = "agent-session-import:v2";
const CLAUDE_SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const decodeImportedTranscriptPayload = Schema.decodeUnknownOption(
Schema.Struct({
cwd: Schema.optional(Schema.String),
importedTranscripts: Schema.optional(Schema.Array(AgentSessionImportSource)),
}),
);

class AgentSessionUnresumableSessionError extends Schema.TaggedError<AgentSessionUnresumableSessionError>()(
"AgentSessionUnresumableSessionError",
Expand Down Expand Up @@ -250,7 +243,7 @@ const make = Effect.gen(function* () {
),
);
const completedSources = runtimeRows.flatMap((runtime) => {
const payload = decodeImportedTranscriptPayload(runtime.runtimePayload);
const payload = AgentSessionScanner.decodeImportedTranscriptPayload(runtime.runtimePayload);
if (
Option.isNone(payload) ||
payload.value.cwd === undefined ||
Expand Down
189 changes: 180 additions & 9 deletions apps/server/src/project/AgentSessionScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ProviderDriverKind,
ProviderInstanceId,
type ServerSettings as ContractServerSettings,
ThreadId,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
Expand All @@ -31,6 +32,7 @@ import * as TestClock from "effect/testing/TestClock";
import * as ServerConfig from "../config.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as AgentSessionScanner from "./AgentSessionScanner.ts";

Expand Down Expand Up @@ -59,9 +61,33 @@ interface ScannerTestInput {
readonly importedWorkspaceRoots?: ReadonlyArray<string>;
readonly configBaseDir?: string;
readonly providerInstances?: ContractServerSettings["providerInstances"];
readonly importedTranscripts?: {
readonly cwd: string;
readonly sources: ReadonlyArray<AgentSessionImportSource>;
};
readonly processRunner?: Layer.Layer<ProcessRunner.ProcessRunner>;
}

const layerRuntimeRepository = (imported: ScannerTestInput["importedTranscripts"]) =>
Layer.mock(ProviderSessionRuntime.ProviderSessionRuntimeRepository)({
list: () =>
Effect.sync(() =>
(imported?.sources ?? []).map((source): ProviderSessionRuntime.ProviderSessionRuntime => ({
threadId: ThreadId.make(
`import:${source.providerInstanceId}:${source.providerSessionId}`,
),
providerName: source.provider,
providerInstanceId: source.providerInstanceId,
adapterKey: source.provider,
runtimeMode: "full-access",
status: "stopped",
lastSeenAt: "2026-01-01T00:00:00.000Z",
resumeCursor: null,
runtimePayload: { cwd: imported?.cwd, importedTranscripts: [source] },
})),
),
});

const layerScannerTest = (input: ScannerTestInput) =>
(input.processRunner === undefined
? AgentSessionScanner.layer
Expand Down Expand Up @@ -103,6 +129,7 @@ const layerScannerTest = (input: ScannerTestInput) =>
input.configBaseDir ?? { prefix: "t3code-scanner-config-" },
),
layerProjectStore(input.importedWorkspaceRoots ?? []),
layerRuntimeRepository(input.importedTranscripts),
),
),
);
Expand Down Expand Up @@ -1619,16 +1646,20 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
null,
{ custom: "launcher" },
];
const fileSystem = yield* FileSystem.FileSystem;
// Rollouts imported before subagents were excluded still have import records.
const legacyImports: Array<AgentSessionImportSource> = [];
for (const [index, source] of sources.entries()) {
const filePath = path.join(
codexHomePath,
"sessions",
"2026",
"08",
"24",
`rollout-${index}.jsonl`,
);
yield* writeTranscript({
filePath: path.join(
codexHomePath,
"sessions",
"2026",
"08",
"24",
`rollout-${index}.jsonl`,
),
filePath,
contents: [
{ type: "session_meta", payload: { id: `session-${index}`, cwd: workspace, source } },
// A later cwd must not make an excluded rollout eligible again.
Expand All @@ -1642,6 +1673,21 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
.join("\n"),
mtimeMs: nowMs - index * 60_000,
});
if (index >= 100) continue;
const stats = yield* fileSystem.stat(filePath);
legacyImports.push(
decodeImportSource({
provider: "codex",
providerInstanceId: "codex",
providerSessionId: `session-${index}`,
filePath,
size: Number(stats.size),
mtimeMs: Option.getOrNull(Option.map(stats.mtime, (date) => date.getTime())),
device: Number(stats.dev),
inode: Option.getOrNull(Option.map(stats.ino, String)),
birthtimeMs: Option.getOrNull(Option.map(stats.birthtime, (date) => date.getTime())),
}),
);
}

yield* Effect.gen(function* () {
Expand All @@ -1654,7 +1700,15 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
outcome._tag === "Importable" ? outcome.thread.providerSessionId : outcome._tag,
),
).toEqual(["session-100", "session-101", "session-102", "session-103"]);
}).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })));
}).pipe(
Effect.provide(
layerScannerTest({
claudeHomePath,
codexHomePath,
importedTranscripts: { cwd: workspace, sources: legacyImports },
}),
),
);
}),
);

Expand Down Expand Up @@ -1790,6 +1844,123 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
}),
);

it.effect("repeated imports reach older transcripts behind already-imported ones", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
yield* TestClock.setTime(nowMs);
const claudeHomePath = yield* makeTempDir("t3code-repeat-import-claude-");
const codexHomePath = yield* makeTempDir("t3code-repeat-import-codex-");
const workspace = yield* makeTempDir("t3code-repeat-import-workspace-");
const sessionIds = Array.from(
{ length: 102 },
(_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
);
for (const [index, sessionId] of sessionIds.entries()) {
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", "p", `${sessionId}.jsonl`),
// 999 cwd-less records cost 1,000 metadata records, so 100 newer transcripts
// exhaust MAX_METADATA_RECORDS_PER_SOURCE.
contents:
"{}\n".repeat(999) +
`${encodeTranscriptRecord({
type: "user",
cwd: workspace,
sessionId,
timestamp: "2026-08-23T12:00:00.000Z",
message: { role: "user", content: `Prompt ${index}` },
})}\n`,
mtimeMs: nowMs - (index + 1) * 60_000,
});
}

const completed: Array<AgentSessionImportSource> = [];
const imported = yield* Effect.gen(function* () {
const scanner = yield* AgentSessionScanner.AgentSessionScanner;
while (true) {
const outcomes = yield* scanner
.recentThreads(workspace, completed)
.pipe(Stream.runCollect);
const added = Array.from(outcomes).flatMap((outcome) =>
outcome._tag === "Importable" ? [outcome.source] : [],
);
if (added.length === 0) return completed;
completed.push(...added);
}
}).pipe(
Effect.provide(
layerScannerTest({
claudeHomePath,
codexHomePath,
importedTranscripts: { cwd: workspace, sources: completed },
}),
),
);

expect(imported.map((source) => source.providerSessionId).toSorted()).toEqual(sessionIds);
}),
);

it.effect("lists projects whose transcripts sit behind already-imported ones", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
yield* TestClock.setTime(nowMs);
const claudeHomePath = yield* makeTempDir("t3code-hidden-project-claude-");
const codexHomePath = yield* makeTempDir("t3code-hidden-project-codex-");
const imported = yield* makeTempDir("t3code-hidden-project-imported-");
const hidden = yield* makeTempDir("t3code-hidden-project-hidden-");
const hiddenSessionId = "00000000-0000-4000-8000-000000000100";
for (const index of Array.from({ length: 101 }, (_, value) => value)) {
const sessionId = `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", "p", `${sessionId}.jsonl`),
contents:
"{}\n".repeat(999) +
`${encodeTranscriptRecord({
type: "user",
cwd: index === 100 ? hidden : imported,
sessionId,
message: { role: "user", content: `Prompt ${index}` },
})}\n`,
mtimeMs: nowMs - (index + 1) * 60_000,
});
}

const completed: Array<AgentSessionImportSource> = [];
const scans = yield* Effect.gen(function* () {
const scanner = yield* AgentSessionScanner.AgentSessionScanner;
const before = yield* scanner.scan;
const outcomes = yield* scanner.recentThreads(imported).pipe(Stream.runCollect);
const sources = Array.from(outcomes).flatMap((outcome) =>
outcome._tag === "Importable" ? [outcome.source] : [],
);
// A record whose file identity no longer matches must not lend its cwd.
const newest = sources[0]!;
completed.push(...sources, {
...newest,
providerSessionId: hiddenSessionId,
filePath: path.join(claudeHomePath, "projects", "p", `${hiddenSessionId}.jsonl`),
});
return { before, after: yield* scanner.scan };
}).pipe(
Effect.provide(
layerScannerTest({
claudeHomePath,
codexHomePath,
importedTranscripts: { cwd: imported, sources: completed },
}),
),
);

expect(scans.before.candidates.map((candidate) => candidate.path)).toEqual([imported]);
expect(scans.after.candidates.map((candidate) => candidate.path)).toEqual([
imported,
hidden,
]);
}),
);

for (const source of ["claudeAgent", "codex"] as const) {
for (const replacement of [
"same root",
Expand Down
Loading
Loading