@@ -18,11 +18,16 @@ if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
|
|||||||
cd "$MOSAIC_WORKSPACE"
|
cd "$MOSAIC_WORKSPACE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Session (M6): persistent named session directory; resume the most recent
|
# Session (M6/M11): default ephemeral (--no-session). With a declared
|
||||||
# session in that directory when one exists (pi documented flags).
|
# session dir: persist there and resume the most recent session. With a
|
||||||
# Default remains ephemeral (--no-session) when no session is declared.
|
# fork source: branch the source session file into the target dir
|
||||||
|
# (pi --fork) - the ancestor session is never modified.
|
||||||
SESSION_FLAGS="--no-session"
|
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"
|
mkdir -p "$MOSAIC_SESSION_DIR"
|
||||||
SESSION_FLAGS="--session-dir $MOSAIC_SESSION_DIR"
|
SESSION_FLAGS="--session-dir $MOSAIC_SESSION_DIR"
|
||||||
if [ -n "$(ls -A "$MOSAIC_SESSION_DIR" 2>/dev/null)" ]; then
|
if [ -n "$(ls -A "$MOSAIC_SESSION_DIR" 2>/dev/null)" ]; then
|
||||||
|
|||||||
+2
-1
@@ -18,8 +18,9 @@ services:
|
|||||||
# Workspace + capabilities (set by the task runner; M5)
|
# Workspace + capabilities (set by the task runner; M5)
|
||||||
MOSAIC_WORKSPACE: ${MOSAIC_WORKSPACE:-}
|
MOSAIC_WORKSPACE: ${MOSAIC_WORKSPACE:-}
|
||||||
MOSAIC_TOOLS: ${MOSAIC_TOOLS:-}
|
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_DIR: ${MOSAIC_SESSION_DIR:-}
|
||||||
|
MOSAIC_SESSION_FORK: ${MOSAIC_SESSION_FORK:-}
|
||||||
# mock adapter only: verbatim response for deterministic seam tests
|
# mock adapter only: verbatim response for deterministic seam tests
|
||||||
MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-}
|
MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-}
|
||||||
# Documented container auth alternative: provider API key via
|
# Documented container auth alternative: provider API key via
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ update this file to the next action). No ambiguity, no re-planning.
|
|||||||
|
|
||||||
## Next action
|
## 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)
|
## Queue (ordered, not started)
|
||||||
|
|
||||||
1. Second real adapter (parked — owner focused on Pi)
|
1. Second real adapter (parked — owner focused on Pi)
|
||||||
2. Auto-apply policy for worker patches (substrate now exists)
|
2. Auto-apply policy for worker patches (substrate exists)
|
||||||
3. Session forking from a common ancestor (pi JSONL trees make this native)
|
3. UX/DX backlog #31 (grep highlight vs status colors — likely closed by owner's unalias)
|
||||||
4. UX/DX backlog #31 (grep highlight vs status colors — likely closed by owner's unalias)
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@@ -28,6 +27,11 @@ Owner review of M10 (retention) — then name the next target.
|
|||||||
|
|
||||||
## Completed log
|
## 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 — 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 — 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 — M10 run-record retention (#32) — merged, prune keep-N/dry-run/receipt, 49/24/14 + verify green
|
||||||
|
|||||||
+40
-1
@@ -129,7 +129,7 @@ function validateMission(document, file) {
|
|||||||
|
|
||||||
function validateTask(document, file) {
|
function validateTask(document, file) {
|
||||||
if (!isPlainObject(document)) fail(2, "task must be a JSON object");
|
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) {
|
if (document.taskVersion !== 1) {
|
||||||
fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`);
|
fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`);
|
||||||
}
|
}
|
||||||
@@ -192,6 +192,23 @@ function validateTask(document, file) {
|
|||||||
session = document.session;
|
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
|
// Capabilities (M5): optional tools allowlist mapped by adapters to their
|
||||||
// native permission flags. Absent = no tools.
|
// native permission flags. Absent = no tools.
|
||||||
let tools = null;
|
let tools = null;
|
||||||
@@ -224,6 +241,7 @@ function validateTask(document, file) {
|
|||||||
workspace,
|
workspace,
|
||||||
tools,
|
tools,
|
||||||
session,
|
session,
|
||||||
|
sessionForkFrom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +356,26 @@ function runTask(taskFile, options = {}) {
|
|||||||
spawnEnv.MOSAIC_SESSION_DIR = `/var/lib/mosaic/sessions/${task.session}`;
|
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(
|
const proc = spawnSync(
|
||||||
"docker",
|
"docker",
|
||||||
["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt],
|
["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt],
|
||||||
@@ -388,6 +426,7 @@ function runTask(taskFile, options = {}) {
|
|||||||
workspace: task.workspace,
|
workspace: task.workspace,
|
||||||
tools: effectiveTools,
|
tools: effectiveTools,
|
||||||
session: task.session,
|
session: task.session,
|
||||||
|
sessionForkFrom: task.sessionForkFrom,
|
||||||
exitCode: proc.status,
|
exitCode: proc.status,
|
||||||
signal: proc.signal ?? null,
|
signal: proc.signal ?? null,
|
||||||
provider: resolved.execution.provider,
|
provider: resolved.execution.provider,
|
||||||
|
|||||||
@@ -191,6 +191,23 @@ EOF
|
|||||||
expect_exit "retry of missing run exits 4" 4 -- \
|
expect_exit "retry of missing run exits 4" 4 -- \
|
||||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" node scripts/mosaic-task.mjs retry r-missing
|
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
|
# capability policy (M9): least-privilege intersection
|
||||||
POL="$SANDBOX/data/workspaces"; mkdir -p "$POL"
|
POL="$SANDBOX/data/workspaces"; mkdir -p "$POL"
|
||||||
pol_run() { # missionTools(ABSENT|json) taskTools(ABSENT|json) -> stderr MOSAIC_TOOLS value
|
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=$($TASK list | wc -l)
|
||||||
[ "$COUNT" -ge 2 ] && check "list shows both runs" 0 || check "list shows both runs" 1
|
[ "$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
|
else
|
||||||
echo "skip live task cases (docker unavailable)"
|
echo "skip live task cases (docker unavailable)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user