feat(sessions): sessionForkFrom - branch conversations from a common ancestor (#33)

- task schema: optional sessionForkFrom (source session name); requires
  session target; self-fork rejected
- runner: resolves source newest .jsonl (fail 4 if none/outside dataRoot);
  passes MOSAIC_SESSION_FORK + MOSAIC_SESSION_DIR; result records lineage
- pi adapter: --fork <source> --session-dir <target> when forking;
  ephemeral default unchanged; plain session resume unchanged
- compose passthrough; RELEASE -> 0.0.7 (adapter changed)
- suite +9 cases (58 total): plumbing via mock stderr, validation
  negatives, live fork - child recalls ancestor code word, ancestor
  session file untouched

Closes #33
This commit is contained in:
2026-09-03 06:43:02 -05:00
parent c038706eed
commit 88d9cf750f
6 changed files with 92 additions and 11 deletions
+40 -1
View File
@@ -129,7 +129,7 @@ function validateMission(document, file) {
function validateTask(document, file) {
if (!isPlainObject(document)) fail(2, "task must be a JSON object");
rejectUnknownKeys(document, ["taskVersion", "id", "prompt", "mission", "expectExact", "timeoutSeconds", "workspace", "capabilities", "session"], "task");
rejectUnknownKeys(document, ["taskVersion", "id", "prompt", "mission", "expectExact", "timeoutSeconds", "workspace", "capabilities", "session", "sessionForkFrom"], "task");
if (document.taskVersion !== 1) {
fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`);
}
@@ -192,6 +192,23 @@ function validateTask(document, file) {
session = document.session;
}
// Session fork (M11): optional source session whose newest session file
// is branched (pi --fork) into the target session dir. Requires session.
let sessionForkFrom = null;
if (document.sessionForkFrom !== undefined && document.sessionForkFrom !== null) {
if (typeof document.sessionForkFrom !== "string" || document.sessionForkFrom.length === 0) {
fail(2, 'task "sessionForkFrom" must be a non-empty string when present');
}
validateId(document.sessionForkFrom, "task sessionForkFrom");
if (!session) {
fail(2, 'task "sessionForkFrom" requires "session" (the fork target) to be set');
}
if (document.sessionForkFrom === session) {
fail(2, 'task "sessionForkFrom" must differ from "session" (cannot fork onto itself)');
}
sessionForkFrom = document.sessionForkFrom;
}
// Capabilities (M5): optional tools allowlist mapped by adapters to their
// native permission flags. Absent = no tools.
let tools = null;
@@ -224,6 +241,7 @@ function validateTask(document, file) {
workspace,
tools,
session,
sessionForkFrom,
};
}
@@ -338,6 +356,26 @@ function runTask(taskFile, options = {}) {
spawnEnv.MOSAIC_SESSION_DIR = `/var/lib/mosaic/sessions/${task.session}`;
}
// Session fork (M11): resolve the source session's newest file; pi --fork
// branches it into the target dir without modifying the ancestor.
if (task.sessionForkFrom) {
const sourceDir = path.join(resolved.dataRoot, "sessions", task.sessionForkFrom);
let sources = [];
try {
sources = fs.readdirSync(sourceDir).filter((f) => f.endsWith(".jsonl")).sort();
} catch {
fail(4, `cannot fork: source session dir not found: ${sourceDir}`);
}
if (sources.length === 0) {
fail(4, `cannot fork: source session '${task.sessionForkFrom}' has no session files`);
}
const relative = path.relative(resolved.dataRoot, path.join(sourceDir, sources[sources.length - 1]));
if (relative.startsWith("..") || path.isAbsolute(relative)) {
fail(4, `source session is outside the configured dataRoot: ${sourceDir}`);
}
spawnEnv.MOSAIC_SESSION_FORK = `/var/lib/mosaic/${relative.split(path.sep).join("/")}`;
}
const proc = spawnSync(
"docker",
["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt],
@@ -388,6 +426,7 @@ function runTask(taskFile, options = {}) {
workspace: task.workspace,
tools: effectiveTools,
session: task.session,
sessionForkFrom: task.sessionForkFrom,
exitCode: proc.status,
signal: proc.signal ?? null,
provider: resolved.execution.provider,