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:
+40
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user