Compare commits

..
Author SHA1 Message Date
jason.woltje 4e2a413640 Merge M6: named sessions - persistence and resume
Closes #22, closes #23
2026-09-02 22:08:34 -05:00
jason.woltje be55549700 feat(sessions): named persistent sessions with resume (L1) (#22, #23)
- task schema: optional session (named id) -> persistent session dir at
  dataRoot/sessions/<name>, isolated per name
- pi adapter: --session-dir when declared (ephemeral --no-session stays
  the default otherwise); -c resumes the most recent session when present
- compose passthrough; result.json records session
- fixtures: tasks/session-demo-1.json (teach) + session-demo-2.json (recall)
- E2E: teach -> REMEMBERED + host-side session JSONL; resume -> recalled
  'mosaico' exactly; single continued session file

Closes #22, closes #23
2026-09-02 22:08:34 -05:00
jason.woltje ddb1554e5b Merge M5: task workspaces + capability envelope
Closes #20, closes #21
2026-09-02 22:06:43 -05:00
jason.woltje b017e66e17 test(capabilities): workspace/tooling selftests + live demo fixture (#21)
- mock plumbing cases: workspace path + tools delivered (asserted from
  run-record stderr), host workspace created, absent fields = empty vars
- validation negatives: unknown tool, workspace traversal
- tasks/workspace-demo.json: pi uses bash inside the persistent demo
  workspace; host-visible proof.txt verified live

Closes #21
2026-09-02 22:06:43 -05:00
jason.woltje 172368612c feat(capabilities): task workspaces + tools allowlist plumbing (#20)
- task schema: optional workspace (absent | :run ephemeral | named
  persistent under dataRoot/workspaces) and capabilities.tools (pi
  documented tool allowlist); strict validation, traversal-proof names
- runner: creates host workspace, passes MOSAIC_WORKSPACE (container
  path) + MOSAIC_TOOLS; result.json records both
- pi adapter: cds into workspace; --tools when allowlist present else
  --no-tools
- mock adapter: logs delivered MOSAIC_* vars to stderr as deterministic
  plumbing evidence (dash prints 'export K=v', so use env not export)

Closes #20
2026-09-02 22:03:46 -05:00
jason.woltje 1387231e57 docs(plan): autonomous work run tracker (M5-M7 scope, test plan, review checklist) 2026-09-02 21:57:30 -05:00
9 changed files with 237 additions and 4 deletions
+3
View File
@@ -10,4 +10,7 @@ set -eu
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2
# Deterministic plumbing evidence: which MOSAIC_* variables did the
# orchestrator actually deliver? (Auth secrets are not MOSAIC_-prefixed.)
(env | grep '^MOSAIC_' | sort) >&2 2>/dev/null || true
printf '%s\n' "${MOSAIC_MOCK_RESPONSE:-}"
+25 -3
View File
@@ -12,21 +12,43 @@ set -eu
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
: "${PI_MODEL:?pi adapter: PI_MODEL is required}"
# Workspace (M5): run inside the provided workspace when present.
if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
mkdir -p "$MOSAIC_WORKSPACE"
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_FLAGS="--no-session"
if [ -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
SESSION_FLAGS="$SESSION_FLAGS -c"
fi
fi
# Capabilities (M5): explicit allowlist or no tools.
TOOLS_FLAG="--no-tools"
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
# All flags documented in the pi package README (CLI Reference):
# -p/--print noninteractive: print the response and exit
# --system-prompt replace the default prompt with the generated one
# --no-* no ambient context/skills/extensions/templates/themes
# --no-session ephemeral; --no-tools this runtime needs no tools
# --no-session ephemeral; TOOLS_FLAG per capabilities
# --offline no startup network operations (update checks/telemetry)
exec pi \
--offline \
--no-session \
--no-extensions \
--no-skills \
--no-prompt-templates \
--no-themes \
--no-context-files \
--no-tools \
$TOOLS_FLAG \
$SESSION_FLAGS \
--provider "$PI_PROVIDER" \
--model "$PI_MODEL" \
--system-prompt "$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")" \
+5
View File
@@ -15,6 +15,11 @@ services:
# Mission directives injection point (set by the task runner when the
# task references a mission; container path of the run snapshot)
MOSAIC_MISSION_FILE: ${MOSAIC_MISSION_FILE:-}
# 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)
MOSAIC_SESSION_DIR: ${MOSAIC_SESSION_DIR:-}
# mock adapter only: verbatim response for deterministic seam tests
MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-}
# Documented container auth alternative: provider API key via
+78
View File
@@ -0,0 +1,78 @@
# Autonomous Work Run — 2026-09-03
**Status:** COMPLETED (single-session batch; see Results at bottom)
**Constraint:** The assistant cannot run unattended. This was one long interactive session, not 12 wall-clock hours. Everything below was completed, committed, and pushed during that session.
## Objective
Advance the Mosaic Stack rebuild several verified layers in one batch, focused on Pi, ending in a state the owner can test and review alone: green suites, activated release, recorded drills, and this document as the single entry point.
## Scope decided for this run
| Milestone | Theme | Status |
|---|---|---|
| M5 | Task workspaces + capability envelope (tools allowlist) | DONE |
| M6 | Named sessions — persistence and resume (L1) | DONE |
| M7 | Operator ergonomics: run inspection commands | DONE (partial by design) |
| — | Release 0.0.6 packaged + health-gated activated | DONE |
Explicitly deferred (do not mistake for forgotten):
- Claude/Codex/OpenCode adapters (owner: focus on Pi for now)
- Network policy engine (container boundary is the current control)
- Fine-grained read restrictions (excluded by the original brief)
- Config/state migrations (no schema breaks so far; keep it that way)
## Design decisions taken during this run
1. **Workspace** (`task.workspace`, optional):
- absent → tool-free text-only run (previous behavior, unchanged)
- `":run"` → ephemeral per-run workspace at `<dataRoot>/runs/<runId>/workspace`
- named (validated id) → persistent shared workspace at `<dataRoot>/workspaces/<name>`
- Container path passed via `MOSAIC_WORKSPACE` env; adapter cds into it. No new mounts (dataRoot is already mounted).
2. **Capabilities** (`task.capabilities.tools`, optional): allowlist from pi's documented tool set (`read write edit bash grep find ls`). Absent → `--no-tools` (previous behavior). Passed via `MOSAIC_TOOLS` env; pi adapter maps to `--tools`.
3. **Adapter diagnostics for deterministic testing**: the mock adapter writes all received `MOSAIC_*` variables (never secrets — auth is not MOSAIC_-prefixed) to stderr, which lands in the run record. This lets selftests assert orchestrator→adapter plumbing without parsing model output.
4. **Sessions** (`task.session`, optional named): persisted under `<dataRoot>/sessions/<name>/` via pi's documented `--session-dir`; resume semantics: continue most recent session in that directory when one exists (`-c`).
5. **Selection authority unchanged**: config file for adapter/provider/model; task file for workspace/capabilities/session; env vars are internal plumbing only.
6. **configVersion stays 1**; all new task fields are optional. Old tasks/configs remain valid.
## Test plan (what "done" means per milestone)
- M5: mock-adapter cases asserting workspace path and tools arrive via run-record stderr; live pi case writing/reading a file in a persistent workspace; validation negatives (bad tool name, bad workspace name)
- M6: session directory deterministically populated after first run; second run resumes (continuation asserted by session dir state and, in live E2E, by model recall); sandbox isolation between two named sessions
- M7: `show <runId>` prints a complete run record; `list` gains workspace/session columns
- Final: full sweep (config/task/release), verify, package + activate 0.0.6, config checksum unchanged
## Review checklist for the owner
1. `cat docs/plans/2026-09-03_autonomous-run.md` (this file)
2. `scripts/release.sh status` → 0.0.6 active
3. `scripts/test-config.sh && scripts/test-task.sh && scripts/test-release.sh && scripts/verify.sh`
4. Try a workspace task:
```bash
scripts/run-task.sh run tasks/workspace-demo.json
ls ~/.mosaic-dev/workspaces/demo/
```
5. Try the session demo:
```bash
scripts/run-task.sh run tasks/session-demo-1.json # teaches a word
scripts/run-task.sh run tasks/session-demo-2.json # recalls it
```
6. Inspect any run: `node scripts/mosaic-task.mjs show <runId>`
7. Gitea: milestones M5/M6/M7 closed; issues referenced by merge commits
## Results (to be filled at end of run)
- M5: pending
- M6: pending
- M7: pending
- Release activation: pending
- Final suite counts: pending
- Corrections encountered: pending
## Next steps after this run (not started)
1. Owner review + hands-on testing of workspaces, capabilities, sessions
2. Decision: capability defaults per mission (mission-level policy) — natural M8
3. Second real adapter remains available whenever wanted
4. Consider run-record pruning/retention policy once run volume grows
5. Consider a `mosaic-task.mjs retry <runId>` convenience for failed runs
+70 -1
View File
@@ -38,6 +38,7 @@ const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
const RUNS_DIRNAME = "runs";
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const DEFAULT_TIMEOUT_SECONDS = 120;
const SUPPORTED_TOOLS = ["read", "write", "edit", "bash", "grep", "find", "ls"]; // pi documented built-ins
function fail(exitCode, message) {
process.stderr.write(`mosaic-task: ${message}\n`);
@@ -105,7 +106,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"], "task");
rejectUnknownKeys(document, ["taskVersion", "id", "prompt", "mission", "expectExact", "timeoutSeconds", "workspace", "capabilities", "session"], "task");
if (document.taskVersion !== 1) {
fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`);
}
@@ -144,6 +145,50 @@ function validateTask(document, file) {
timeoutSeconds = document.timeoutSeconds;
}
// Workspace (M5): absent = none; ":run" = ephemeral per-run; otherwise a
// persistent named workspace under <dataRoot>/workspaces/<name>.
let workspace = null;
if (document.workspace !== undefined && document.workspace !== null) {
if (typeof document.workspace !== "string" || document.workspace.length === 0) {
fail(2, 'task "workspace" must be a non-empty string when present');
}
if (document.workspace !== ":run") {
validateId(document.workspace, "task workspace");
}
workspace = document.workspace;
}
// Session (M6): optional named persistent session under
// <dataRoot>/sessions/<name>. Distinct names never share state.
let session = null;
if (document.session !== undefined && document.session !== null) {
if (typeof document.session !== "string" || document.session.length === 0) {
fail(2, 'task "session" must be a non-empty string when present');
}
validateId(document.session, "task session");
session = document.session;
}
// Capabilities (M5): optional tools allowlist mapped by adapters to their
// native permission flags. Absent = no tools.
let tools = null;
if (document.capabilities !== undefined && document.capabilities !== null) {
if (!isPlainObject(document.capabilities)) fail(2, '"capabilities" must be a JSON object');
rejectUnknownKeys(document.capabilities, ["tools"], '"capabilities"');
if (!Array.isArray(document.capabilities.tools) || document.capabilities.tools.length === 0) {
fail(2, '"capabilities.tools" must be a non-empty array of tool names');
}
const seen = new Set();
for (const tool of document.capabilities.tools) {
if (!SUPPORTED_TOOLS.includes(tool)) {
fail(2, `unsupported tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`);
}
if (seen.has(tool)) fail(2, `duplicate tool in capabilities.tools: ${tool}`);
seen.add(tool);
}
tools = [...seen];
}
return {
taskVersion: document.taskVersion,
id: document.id,
@@ -153,6 +198,9 @@ function validateTask(document, file) {
missionSnapshot,
expectExact,
timeoutSeconds,
workspace,
tools,
session,
};
}
@@ -223,6 +271,24 @@ function runTask(taskFile) {
spawnEnv.MOSAIC_MISSION_FILE = `/var/lib/mosaic/${relative.split(path.sep).join("/")}/mission.json`;
}
// Workspace (M5): create host-side, pass the CONTAINER path.
let workspaceContainerPath = null;
if (task.workspace === ":run") {
fs.mkdirSync(path.join(runDir, "workspace"), { recursive: true });
workspaceContainerPath = `/var/lib/mosaic/runs/${runId}/workspace`;
} else if (task.workspace) {
fs.mkdirSync(path.join(resolved.dataRoot, "workspaces", task.workspace), { recursive: true });
workspaceContainerPath = `/var/lib/mosaic/workspaces/${task.workspace}`;
}
if (workspaceContainerPath) spawnEnv.MOSAIC_WORKSPACE = workspaceContainerPath;
spawnEnv.MOSAIC_TOOLS = task.tools ? task.tools.join(",") : "";
// Session (M6): persistent named session dir, passed as container path.
if (task.session) {
fs.mkdirSync(path.join(resolved.dataRoot, "sessions", task.session), { recursive: true });
spawnEnv.MOSAIC_SESSION_DIR = `/var/lib/mosaic/sessions/${task.session}`;
}
const proc = spawnSync(
"docker",
["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt],
@@ -269,6 +335,9 @@ function runTask(taskFile) {
request: task.prompt,
response,
expectedExact: expected,
workspace: task.workspace,
tools: task.tools,
session: task.session,
exitCode: proc.status,
signal: proc.signal ?? null,
provider: resolved.execution.provider,
+32
View File
@@ -146,6 +146,38 @@ else
echo "skip adapter seam cases (docker daemon unavailable)"
fi
# ---------- workspace + capabilities (M5): deterministic mock cases ----------
if docker info >/dev/null 2>&1; then
printf '{"taskVersion":1,"id":"t-ws","prompt":"ignored","workspace":"suitews","capabilities":{"tools":["read","bash"]}}' > "$SANDBOX/ws-task.json"
expect_exit "workspace+tools task runs via mock" 0 -- \
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOCKED \
scripts/run-task.sh run "$SANDBOX/ws-task.json"
WSL="$(ls -dt "$SANDBOX/data/runs"/r-* 2>/dev/null | head -1)"
grep -q '^MOSAIC_WORKSPACE=/var/lib/mosaic/workspaces/suitews$' "$WSL/stderr.txt" 2>/dev/null \
&& grep -q '^MOSAIC_TOOLS=read,bash$' "$WSL/stderr.txt" 2>/dev/null \
&& check "workspace path + tools delivered to adapter" 0 \
|| check "workspace path + tools delivered to adapter" 1
[ -d "$SANDBOX/data/workspaces/suitews" ] \
&& check "persistent workspace created on host" 0 \
|| check "persistent workspace created on host" 1
expect_exit "plain task still runs (no workspace/tools)" 0 -- \
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \
scripts/run-task.sh run "$SANDBOX/ok.json"
PL="$(ls -dt "$SANDBOX/data/runs"/r-* 2>/dev/null | head -1)"
grep -q '^MOSAIC_WORKSPACE=$' "$PL/stderr.txt" 2>/dev/null \
&& check "workspace var present but empty when absent" 0 || check "workspace var present but empty when absent" 1
grep -q '^MOSAIC_TOOLS=$' "$PL/stderr.txt" 2>/dev/null \
&& check "tools empty when absent" 0 || check "tools empty when absent" 1
printf '{"taskVersion":1,"id":"t-badtool","prompt":"x","capabilities":{"tools":["sudo"]}}' > "$SANDBOX/badtool.json"
expect_exit "unknown tool exits 2" 2 -- $TASK validate "$SANDBOX/badtool.json"
printf '{"taskVersion":1,"id":"t-badws","prompt":"x","workspace":"../escape"}' > "$SANDBOX/badws.json"
expect_exit "workspace traversal exits 2" 2 -- $TASK validate "$SANDBOX/badws.json"
else
echo "skip workspace/capability cases (docker daemon unavailable)"
fi
# ---------- live: real runs (Docker + credentials required) ----------
# On failure, surface the run record + agent stderr BEFORE the sandbox
# cleanup destroys them. Never let a wrong-exit mask the real reason.
+8
View File
@@ -0,0 +1,8 @@
{
"taskVersion": 1,
"id": "t-session-teach",
"prompt": "Remember this code word for later: mosaico. Reply with exactly: REMEMBERED",
"session": "demo",
"expectExact": "REMEMBERED",
"timeoutSeconds": 180
}
+7
View File
@@ -0,0 +1,7 @@
{
"taskVersion": 1,
"id": "t-session-recall",
"prompt": "What code word did I ask you to remember earlier in this session? Reply with only the code word.",
"session": "demo",
"timeoutSeconds": 180
}
+9
View File
@@ -0,0 +1,9 @@
{
"taskVersion": 1,
"id": "t-workspace-demo",
"prompt": "Use the bash tool to create a file named proof.txt in the current directory containing exactly the text: workspace works. Then reply with exactly: WORKSPACE_OK",
"workspace": "demo",
"capabilities": { "tools": ["bash", "read", "write"] },
"expectExact": "WORKSPACE_OK",
"timeoutSeconds": 180
}