diff --git a/RELEASE b/RELEASE index 1750564f..5a5831ab 100644 --- a/RELEASE +++ b/RELEASE @@ -1 +1 @@ -0.0.6 +0.0.7 diff --git a/adapters/pi/adapter.sh b/adapters/pi/adapter.sh index 2515b38b..dedc5a58 100644 --- a/adapters/pi/adapter.sh +++ b/adapters/pi/adapter.sh @@ -18,11 +18,16 @@ if [ -n "${MOSAIC_WORKSPACE:-}" ]; then cd "$MOSAIC_WORKSPACE" fi -# Session (M6): persistent named session directory; resume the most recent -# session in that directory when one exists (pi documented flags). -# Default remains ephemeral (--no-session) when no session is declared. +# Session (M6/M11): default ephemeral (--no-session). With a declared +# session dir: persist there and resume the most recent session. With a +# fork source: branch the source session file into the target dir +# (pi --fork) - the ancestor session is never modified. SESSION_FLAGS="--no-session" -if [ -n "${MOSAIC_SESSION_DIR:-}" ]; then +if [ -n "${MOSAIC_SESSION_FORK:-}" ]; then + [ -n "${MOSAIC_SESSION_DIR:-}" ] || { echo "pi adapter: session fork requires MOSAIC_SESSION_DIR" >&2; exit 2; } + mkdir -p "$MOSAIC_SESSION_DIR" + SESSION_FLAGS="--fork $MOSAIC_SESSION_FORK --session-dir $MOSAIC_SESSION_DIR" +elif [ -n "${MOSAIC_SESSION_DIR:-}" ]; then mkdir -p "$MOSAIC_SESSION_DIR" SESSION_FLAGS="--session-dir $MOSAIC_SESSION_DIR" if [ -n "$(ls -A "$MOSAIC_SESSION_DIR" 2>/dev/null)" ]; then diff --git a/compose.yaml b/compose.yaml index e10c2f8f..ee7a0950 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,8 +18,9 @@ services: # Workspace + capabilities (set by the task runner; M5) MOSAIC_WORKSPACE: ${MOSAIC_WORKSPACE:-} MOSAIC_TOOLS: ${MOSAIC_TOOLS:-} - # Persistent named session dir (set by the task runner; M6) + # Persistent named session dir + optional fork source (M6/M11) MOSAIC_SESSION_DIR: ${MOSAIC_SESSION_DIR:-} + MOSAIC_SESSION_FORK: ${MOSAIC_SESSION_FORK:-} # mock adapter only: verbatim response for deterministic seam tests MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-} # Documented container auth alternative: provider API key via diff --git a/docs/plans/CURRENT.md b/docs/plans/CURRENT.md index 649857d3..3836db8f 100644 --- a/docs/plans/CURRENT.md +++ b/docs/plans/CURRENT.md @@ -7,14 +7,13 @@ update this file to the next action). No ambiguity, no re-planning. ## Next action -Owner review of M10 (retention) — then name the next target. +Owner review of M11 (session forking) — then name the next target. ## Queue (ordered, not started) 1. Second real adapter (parked — owner focused on Pi) -2. Auto-apply policy for worker patches (substrate now exists) -3. Session forking from a common ancestor (pi JSONL trees make this native) -4. UX/DX backlog #31 (grep highlight vs status colors — likely closed by owner's unalias) +2. Auto-apply policy for worker patches (substrate exists) +3. UX/DX backlog #31 (grep highlight vs status colors — likely closed by owner's unalias) ## Rules @@ -28,6 +27,11 @@ Owner review of M10 (retention) — then name the next target. ## Completed log +- 2026-09-03 — M9 mission capability policy (#30) — merged, least-privilege intersection +- 2026-09-03 — test UX: green OK/red FAIL status colors (#31 adjacent) — terminal-only, pipe-safe +- 2026-09-03 — M10 run-record retention (#32) — merged, prune keep-N/dry-run/receipt, 49/24/14 + verify green +- 2026-09-03 — M11 session forking (#33) — merged, child recalls ancestor context, ancestor untouched, 58/24/14 + verify green; release 0.0.7 activated + - 2026-09-03 — M9 mission capability policy (#30) — merged, least-privilege intersection - 2026-09-03 — test UX: green OK/red FAIL status colors (#31 adjacent) — terminal-only, pipe-safe - 2026-09-03 — M10 run-record retention (#32) — merged, prune keep-N/dry-run/receipt, 49/24/14 + verify green diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs index 86b71dcb..ea85360f 100755 --- a/scripts/mosaic-task.mjs +++ b/scripts/mosaic-task.mjs @@ -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, diff --git a/scripts/test-task.sh b/scripts/test-task.sh index a7bb72a2..0c15c10e 100755 --- a/scripts/test-task.sh +++ b/scripts/test-task.sh @@ -191,6 +191,23 @@ EOF expect_exit "retry of missing run exits 4" 4 -- \ env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" node scripts/mosaic-task.mjs retry r-missing + # session fork plumbing (M11): fork source + target dir delivered + printf '{"taskVersion":1,"id":"t-forkplumb","prompt":"x","session":"fork-child","sessionForkFrom":"base"}' > "$SANDBOX/forkplumb.json" + mkdir -p "$SANDBOX/data/sessions/base" + printf '{}' > "$SANDBOX/data/sessions/base/20260903T000000-plumb.jsonl" + expect_exit "fork task runs via mock" 0 -- \ + env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOCKED \ + scripts/run-task.sh run "$SANDBOX/forkplumb.json" + FL="$(ls -dt "$SANDBOX/data/runs"/r-* | head -1)" + grep -q '^MOSAIC_SESSION_FORK=/var/lib/mosaic/sessions/base/20260903T000000-plumb.jsonl$' "$FL/stderr.txt" 2>/dev/null \ + && grep -q '^MOSAIC_SESSION_DIR=/var/lib/mosaic/sessions/fork-child$' "$FL/stderr.txt" 2>/dev/null \ + && check "fork source + target delivered to adapter" 0 \ + || check "fork source + target delivered to adapter" 1 + printf '{"taskVersion":1,"id":"t-f2","prompt":"x","sessionForkFrom":"base"}' > "$SANDBOX/noforktarget.json" + expect_exit "fork without session target exits 2" 2 -- $TASK validate "$SANDBOX/noforktarget.json" + printf '{"taskVersion":1,"id":"t-f3","prompt":"x","session":"base","sessionForkFrom":"base"}' > "$SANDBOX/selfork.json" + expect_exit "self-fork exits 2" 2 -- $TASK validate "$SANDBOX/selfork.json" + # capability policy (M9): least-privilege intersection POL="$SANDBOX/data/workspaces"; mkdir -p "$POL" pol_run() { # missionTools(ABSENT|json) taskTools(ABSENT|json) -> stderr MOSAIC_TOOLS value @@ -305,6 +322,21 @@ process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.e COUNT=$($TASK list | wc -l) [ "$COUNT" -ge 2 ] && check "list shows both runs" 0 || check "list shows both runs" 1 + + # session fork (M11): teach in base, fork into child, child recalls; + # ancestor file count must be unchanged by the fork + printf '{"taskVersion":1,"id":"t-fork-base","prompt":"Remember this code word for later: mosaico. Reply with exactly: REMEMBERED","session":"suite-base","expectExact":"REMEMBERED","timeoutSeconds":180}' > "$SANDBOX/fb.json" + expect_exit "fork base: teach succeeds" 0 -- scripts/run-task.sh run "$SANDBOX/fb.json" + BASECOUNT=$(ls "$SANDBOX/data/sessions/suite-base" | wc -l) + printf '{"taskVersion":1,"id":"t-fork-child","prompt":"What code word did I ask you to remember? Reply with only the code word.","session":"suite-child","sessionForkFrom":"suite-base","timeoutSeconds":180}' > "$SANDBOX/fc.json" + expect_exit "fork child recalls ancestor context" 0 -- scripts/run-task.sh run "$SANDBOX/fc.json" + FR="$(ls -dt "$DATA_ROOT/runs"/r-* | head -1)" + node -e 'const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.exit(String(r.response).toLowerCase().includes("mosaico")?0:1)' "$FR/result.json" \ + && check "forked child recalled ancestor code word" 0 || check "forked child recalled ancestor code word" 1 + [ "$(ls "$SANDBOX/data/sessions/suite-base" | wc -l)" -eq "$BASECOUNT" ] \ + && check "ancestor session untouched by fork" 0 || check "ancestor session untouched by fork" 1 + [ "$(ls "$SANDBOX/data/sessions/suite-child" | wc -l)" -ge 1 ] \ + && check "child session dir has its own branch file" 0 || check "child session dir has its own branch file" 1 else echo "skip live task cases (docker unavailable)" fi