Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
314b1a7695 | ||
|
|
2c29349a3f | ||
|
|
c70b217a5c | ||
|
|
d481a74a86 |
No files matched your search
+150
-20
@@ -1,24 +1,154 @@
|
||||
# Non-secret runtime settings for the mosaic-poc-agent container.
|
||||
# Copy to .env if you want to override the defaults in compose.yaml.
|
||||
#
|
||||
# NEVER put credentials in this file. Authentication is supplied at
|
||||
# runtime only, via one of the two documented paths:
|
||||
# 1. read-only mounted pi auth file (default: ~/.pi/agent/auth.json,
|
||||
# override the host path with PI_AUTH_FILE)
|
||||
# 2. provider API key environment variable (ZAI_API_KEY or
|
||||
# ANTHROPIC_API_KEY), passed through by compose.yaml when set
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mosaic — Environment Variables Reference
|
||||
# Copy this file to .env and fill in the values for your deployment.
|
||||
# Lines beginning with # are comments; optional vars are commented out.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Model provider (built-in pi provider name)
|
||||
PI_PROVIDER=zai
|
||||
|
||||
# Model ID within the provider
|
||||
PI_MODEL=glm-5.3-flash
|
||||
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
|
||||
# Full connection string used by the gateway, ORM, and migration runner.
|
||||
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
|
||||
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||
|
||||
# Optional: alternative host path of the pi credential file mounted
|
||||
# read-only at /home/node/.pi/agent/auth.json in the container
|
||||
#PI_AUTH_FILE=/home/jwoltje/.pi/agent/auth.json
|
||||
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
|
||||
# PG_HOST_PORT=5433
|
||||
|
||||
# Optional: documented env-var auth alternative (secret! set in your
|
||||
# shell or a gitignored .env, never commit)
|
||||
#ZAI_API_KEY=
|
||||
#ANTHROPIC_API_KEY=
|
||||
|
||||
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
|
||||
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
|
||||
VALKEY_URL=redis://localhost:6380
|
||||
|
||||
# Docker Compose host-port override for the Valkey container (default: 6380)
|
||||
# VALKEY_HOST_PORT=6380
|
||||
|
||||
|
||||
# ─── Gateway ─────────────────────────────────────────────────────────────────
|
||||
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
|
||||
GATEWAY_PORT=14242
|
||||
|
||||
# Comma-separated list of allowed CORS origins.
|
||||
# Must include the web app origin in production.
|
||||
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
|
||||
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
|
||||
# REQUIRED — random secret used to sign sessions and tokens.
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||
|
||||
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
||||
BETTER_AUTH_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
||||
# Public gateway URL — accessible from the browser, not just the server.
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
|
||||
# Service name shown in traces
|
||||
OTEL_SERVICE_NAME=mosaic-gateway
|
||||
|
||||
|
||||
# ─── AI Providers ────────────────────────────────────────────────────────────
|
||||
|
||||
# Ollama (local models — set OLLAMA_BASE_URL to enable)
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
|
||||
# OLLAMA_HOST=http://localhost:11434
|
||||
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
||||
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
||||
|
||||
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
|
||||
# OPENAI_API_KEY=sk-...
|
||||
|
||||
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
|
||||
# ZAI_API_KEY=...
|
||||
|
||||
# Custom providers — JSON array of provider configs
|
||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
||||
# MOSAIC_CUSTOM_PROVIDERS=
|
||||
|
||||
|
||||
# ─── Embedding Service ───────────────────────────────────────────────────────
|
||||
# OpenAI-compatible embeddings endpoint (default: OpenAI)
|
||||
# EMBEDDING_API_URL=https://api.openai.com/v1
|
||||
# EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
|
||||
# ─── Log Summarization Service ───────────────────────────────────────────────
|
||||
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
|
||||
# SUMMARIZATION_API_URL=https://api.openai.com/v1
|
||||
# SUMMARIZATION_MODEL=gpt-4o-mini
|
||||
|
||||
# Cron schedule for summarization job (default: every 6 hours)
|
||||
# SUMMARIZATION_CRON=0 */6 * * *
|
||||
|
||||
# Cron schedule for log tier management (default: daily at 03:00)
|
||||
# TIER_MANAGEMENT_CRON=0 3 * * *
|
||||
|
||||
|
||||
# ─── Agent ───────────────────────────────────────────────────────────────────
|
||||
# Filesystem sandbox root for agent file tools (default: process.cwd())
|
||||
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
|
||||
|
||||
# Comma-separated list of tool names available to non-admin users.
|
||||
# Leave unset to allow all tools for all authenticated users.
|
||||
# AGENT_USER_TOOLS=read_file,list_directory,search_files
|
||||
|
||||
# System prompt injected into every agent session (optional)
|
||||
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
|
||||
|
||||
|
||||
# ─── MCP Servers ─────────────────────────────────────────────────────────────
|
||||
# JSON array of MCP server configs — set to enable MCP tool integration.
|
||||
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
|
||||
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
|
||||
|
||||
|
||||
# ─── Coordinator ─────────────────────────────────────────────────────────────
|
||||
# Root directory used to scope coordinator (worktree/repo) operations.
|
||||
# Defaults to the monorepo root auto-detected from process.cwd().
|
||||
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
|
||||
|
||||
|
||||
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
|
||||
# DISCORD_BOT_TOKEN=
|
||||
# DISCORD_GUILD_ID=
|
||||
# DISCORD_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
||||
|
||||
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
|
||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||
# AUTHENTIK_CLIENT_ID=
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
||||
# WORKOS_ISSUER=https://your-company.authkit.app
|
||||
# WORKOS_CLIENT_ID=client_...
|
||||
# WORKOS_CLIENT_SECRET=sk_live_...
|
||||
|
||||
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
|
||||
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
||||
# Legacy alternative if you prefer to compose the issuer from separate vars:
|
||||
# KEYCLOAK_URL=https://auth.example.com
|
||||
# KEYCLOAK_REALM=master
|
||||
# KEYCLOAK_CLIENT_ID=mosaic
|
||||
# KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
|
||||
# NEXT_PUBLIC_WORKOS_ENABLED=true
|
||||
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||
+12
-6
@@ -1,8 +1,14 @@
|
||||
# build/deps
|
||||
node_modules/
|
||||
|
||||
# runtime credentials — never commit, never copy into the image
|
||||
logs/
|
||||
node_modules
|
||||
dist
|
||||
.turbo
|
||||
.next
|
||||
coverage
|
||||
.env
|
||||
secrets/
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
docs/reports/
|
||||
|
||||
# generated runtime state lives in /home/jwoltje/.mosaic-dev (outside this project)
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
infra/step-ca/dev-password
|
||||
File renamed without changes.
Executable
+1
@@ -0,0 +1 @@
|
||||
pnpm typecheck && pnpm lint && pnpm format:check
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"projectVersion": 1,
|
||||
"id": "stack",
|
||||
"vars": {
|
||||
"tracker.project": 19,
|
||||
"git.workingBranch": "refactor",
|
||||
"git.protectedBranches": [
|
||||
"main",
|
||||
"next"
|
||||
]
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
@@ -1,6 +0,0 @@
|
||||
extensions/
|
||||
extensions.installed.sha256
|
||||
.extensions-*
|
||||
state/
|
||||
evidence/
|
||||
native-test-*.log
|
||||
@@ -1,34 +0,0 @@
|
||||
# Native goal development copy
|
||||
|
||||
From this repository, start a fresh native Pi session:
|
||||
|
||||
```sh
|
||||
bash scripts/goal-dev.sh
|
||||
```
|
||||
|
||||
Canonical source lives under `extensions/`. The launcher first runs `scripts/sync-dev-extensions.sh`, which installs verified ordinary-file copies under `.pi/extensions/`, then loads only the generated goal extension. Global extensions remain unloaded. The launcher keeps your usual native Pi provider authentication; it copies no credentials. Goal state and new conversation files live under `.pi/state/`, which is ignored by Git. Each process gets a fresh incarnation; `/reload` and `/new` in the same process retain its goal. Restarting Pi does not adopt an earlier process's active goal.
|
||||
|
||||
Plain `pi` also discovers `.pi/extensions/goal/index.ts` after project trust, but may load global extensions too. Use the launcher to avoid duplicate `/goal` registrations. This is a local development test, not a sandbox or the managed Mosaic runtime. Docker and `~/.mosaic` are unchanged.
|
||||
|
||||
## Try it
|
||||
|
||||
1. Set `/goal <a long goal with acceptance criteria>`. This starts work immediately.
|
||||
2. Look below the editor for `Goal: Active`. The old above-editor goal widget is gone.
|
||||
3. Run bare `/goal`, then press `Alt+G`. Both show the entire stored goal and its status. Tab remains autocomplete.
|
||||
4. Use `/goal stop` and `/goal resume`. Expect Paused and Active, or Waiting if an untimed wait remains recorded.
|
||||
5. A blocked `goal_report` displays Blocked. A satisfied report displays Complete and retains the full goal for recall without continuing work.
|
||||
6. `/goal clear` removes the retained goal. Try `NO_COLOR=1 bash scripts/goal-dev.sh` to check text-only labels.
|
||||
|
||||
Use terminal scrollback for recall longer than the screen. At narrow widths Pi may truncate its footer status row; bare `/goal` and Alt+G remain available.
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
node --test extensions/goal/test/*.test.ts
|
||||
bash scripts/test-extension-package.sh
|
||||
python3 scripts/test-goal-native.py
|
||||
```
|
||||
|
||||
Contract tests use ordinary read-only fixture copies in `test/fixtures/skills-local/`, not live brain files. The executive-update fixture SHA-256 matches the parser's pinned contract, `bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319`.
|
||||
|
||||
`SOURCE-SNAPSHOT.json` records the original external-source baseline, not the edited candidate. No symlinks are used. Never edit `.pi/extensions/`; the sync script refuses to overwrite installation drift. Make changes under `extensions/`, run the checks, and relaunch. To disable the test, stop its Pi process and remove `.pi/extensions/`. Keep `.pi/state/` only if you need local test state.
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"snapshotVersion": 1,
|
||||
"copiedAt": "2026-09-06T04:58:22Z",
|
||||
"source": "~/.mosaic/fleet/extensions",
|
||||
"goalTreeSha256": "8853f2b72dde3e87c4573648b9a931c1c75da87ccde995c3224e6d2e707a75f0",
|
||||
"mosaicCoreLibTreeSha256": "d1194dce31209e5773c6cc5ce571cbca3c39b29d943a79dea06665e05d29f319",
|
||||
"symlinks": false,
|
||||
"autoDiscoveredExtensions": ["goal"],
|
||||
"purpose": "Issue #54 native Pi NG development copy; never loaded by Docker"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compatibility entrypoint for the accepted native test command.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
exec scripts/goal-dev.sh "$@"
|
||||
@@ -0,0 +1,7 @@
|
||||
pnpm-lock.yaml
|
||||
**/next-env.d.ts
|
||||
**/dist
|
||||
**/node_modules
|
||||
**/drizzle
|
||||
**/.next
|
||||
.claude/
|
||||
File renamed without changes.
@@ -0,0 +1,84 @@
|
||||
variables:
|
||||
- &node_image 'node:22-alpine'
|
||||
- &enable_pnpm 'corepack enable'
|
||||
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
# This avoids from_secret which is blocked on pull_request events.
|
||||
# If the env vars aren't set, turbo falls back to local cache only.
|
||||
|
||||
steps:
|
||||
install:
|
||||
image: *node_image
|
||||
commands:
|
||||
- corepack enable
|
||||
- apk add --no-cache python3 make g++
|
||||
- pnpm install --frozen-lockfile
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm typecheck
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm lint
|
||||
depends_on:
|
||||
- typecheck
|
||||
|
||||
format:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm format:check
|
||||
depends_on:
|
||||
- typecheck
|
||||
|
||||
test:
|
||||
image: *node_image
|
||||
environment:
|
||||
# Avoid the namespace-level Woodpecker DB service named "postgres".
|
||||
# The Kubernetes backend exposes service containers by step name.
|
||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
# Install postgresql-client for pg_isready
|
||||
- apk add --no-cache postgresql-client
|
||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||
- |
|
||||
ready=0
|
||||
for i in $(seq 1 60); do
|
||||
if pg_isready -h ci-postgres -p 5432 -U mosaic; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
echo "Waiting for ci-postgres ($i/60)..."
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "ci-postgres did not become ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Run migrations (DATABASE_URL is set in environment above)
|
||||
- pnpm --filter @mosaicstack/db run db:migrate
|
||||
# Run all tests
|
||||
- pnpm test
|
||||
depends_on:
|
||||
- typecheck
|
||||
|
||||
services:
|
||||
ci-postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
environment:
|
||||
POSTGRES_USER: mosaic
|
||||
POSTGRES_PASSWORD: mosaic
|
||||
POSTGRES_DB: mosaic
|
||||
@@ -0,0 +1,165 @@
|
||||
# Build, publish npm packages, and push Docker images
|
||||
# Runs only on main branch push/tag
|
||||
|
||||
variables:
|
||||
- &node_image 'node:22-alpine'
|
||||
- &enable_pnpm 'corepack enable'
|
||||
|
||||
when:
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
|
||||
steps:
|
||||
install:
|
||||
image: *node_image
|
||||
commands:
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
publish-npm:
|
||||
image: *node_image
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
# Configure auth for Gitea npm registry
|
||||
- |
|
||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||
# Publish non-private packages to Gitea.
|
||||
#
|
||||
# The only publish failure we tolerate is "version already exists" —
|
||||
# that legitimately happens when only some packages were bumped in
|
||||
# the merge. Any other failure (registry 404, auth error, network
|
||||
# error) MUST fail the pipeline loudly: the previous
|
||||
# `|| echo "... continuing"` fallback silently hid a 404 from the
|
||||
# Gitea org rename and caused every @mosaicstack/* publish to fall
|
||||
# on the floor while CI still reported green.
|
||||
- |
|
||||
# Portable sh (Alpine ash) — avoid bashisms like PIPESTATUS.
|
||||
set +e
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public >/tmp/publish.log 2>&1
|
||||
EXIT=$?
|
||||
set -e
|
||||
cat /tmp/publish.log
|
||||
if [ "$EXIT" -eq 0 ]; then
|
||||
echo "[publish] all packages published successfully"
|
||||
exit 0
|
||||
fi
|
||||
# Hard registry / auth / network errors → fatal. Match npm's own
|
||||
# error lines specifically to avoid false positives on arbitrary
|
||||
# log text that happens to contain "E404" etc.
|
||||
if grep -qE "npm (error|ERR!) code (E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND)" /tmp/publish.log; then
|
||||
echo "[publish] FATAL: registry/auth/network error detected — failing pipeline" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Only tolerate the explicit "version already published" case.
|
||||
# npm returns this as E403 with body "You cannot publish over..."
|
||||
# or EPUBLISHCONFLICT depending on version.
|
||||
if grep -qE "EPUBLISHCONFLICT|You cannot publish over|previously published" /tmp/publish.log; then
|
||||
echo "[publish] some packages already at this version — continuing (non-fatal)"
|
||||
exit 0
|
||||
fi
|
||||
echo "[publish] FATAL: publish failed with unrecognized error — failing pipeline" >&2
|
||||
exit 1
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
# environment:
|
||||
# NPM_TOKEN:
|
||||
# from_secret: npmjs_token
|
||||
# commands:
|
||||
# - *enable_pnpm
|
||||
# - apk add --no-cache jq bash
|
||||
# - bash scripts/publish-npmjs.sh
|
||||
# depends_on:
|
||||
# - build
|
||||
# when:
|
||||
# - event: [tag]
|
||||
|
||||
build-gateway:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
REGISTRY_PASS:
|
||||
from_secret: gitea_password
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
REGISTRY_PASS:
|
||||
from_secret: gitea_password
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
REGISTRY_PASS:
|
||||
from_secret: gitea_password
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
@@ -1,222 +1,80 @@
|
||||
# AGENTS.md — Mosaic Stack rebuild (`mosaicstack/stack`, branch `refactor`)
|
||||
# Agent Guidelines — Mosaic Stack
|
||||
|
||||
Operational context for any agent session working in this repository.
|
||||
Read top to bottom; it is deliberately short — depth lives in the files it
|
||||
points to, not here.
|
||||
## Required Load Order
|
||||
|
||||
## What this repository is
|
||||
1. `~/.config/mosaic/SOUL.md`
|
||||
2. `~/.config/mosaic/STANDARDS.md`
|
||||
3. `~/.config/mosaic/AGENTS.md`
|
||||
4. `~/.config/mosaic/guides/E2E-DELIVERY.md`
|
||||
5. `AGENTS.md` (this file)
|
||||
6. Runtime-specific guide: `~/.config/mosaic/runtime/<runtime>/RUNTIME.md`
|
||||
|
||||
Canonical checkout: `/mnt/storage/src/mosaic-stack`, origin `mosaicstack/stack`,
|
||||
working branch `refactor` (Jason-authorized conversion, issue #1495).
|
||||
The new foundation is at the root. `v1/` is archived legacy source, not the current
|
||||
implementation; its instructions and tools do not govern the new foundation.
|
||||
`~/src/mosaic-stack-dev-test` is a compatibility symlink to this checkout, not a
|
||||
second working tree. Both original Git histories are retained. Conversion receipt:
|
||||
`docs/plans/2026-09-07_repository-consolidation-completed.md`.
|
||||
## Project Context
|
||||
|
||||
A rebuild of Mosaic Stack: a file-based, fail-closed
|
||||
orchestration foundation that dispatches sandboxed headless pi workers to do
|
||||
real work, with immutable run records as evidence. Thirteen-plus tagged
|
||||
milestones (`git tag -l`) from `poc-container-hello-v0` to today; suites
|
||||
green at every step. Not production software — a proven foundation.
|
||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
|
||||
|
||||
## Non-negotiable invariants (the canon)
|
||||
## Package Map
|
||||
|
||||
1. **Root is bootstrap-only.** First-class system configuration lives at the
|
||||
repository root; everything else gets a dedicated directory (`roles/`,
|
||||
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
|
||||
2. **Configuration**: each stack instance has exactly one system config. The
|
||||
default instance's is `~/.config/mosaic-dev/config.json`. Instance NAME's
|
||||
is `~/.config/mosaic-NAME/config.json`, and its data root carries a marker
|
||||
that names it. `~` is the home of the OS user that runs the instance. A
|
||||
process reads only the config `MOSAIC_CONFIG` names, or the default's when
|
||||
that is unset. Configs are created only by
|
||||
`scripts/bootstrap.sh [--instance NAME]`, never overwritten, fail-closed on
|
||||
any problem. Repo-scoped role authority lives in `roles/*.json`
|
||||
(versioned, reviewed commits only).
|
||||
3. **Secrets** never enter the repository or container images; auth is
|
||||
runtime-only (read-only mount or environment variable).
|
||||
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
|
||||
tasks are declarative JSON with strict schemas.
|
||||
5. **Run records** under `<dataRoot>/runs/` are write-once evidence — never
|
||||
rewritten, only pruned via `prune` with a receipt.
|
||||
6. **Fail closed**: missing or invalid config/policy refuses the operation.
|
||||
Never improvise around a refusal; diagnose it.
|
||||
7. **Policy**: missions govern tasks (least-privilege intersection — a task
|
||||
narrows, never widens). Role authority is declared in `roles/` and changes
|
||||
only via reviewed commits.
|
||||
8. **Git**: commit only after applicable suites are green. Work on the
|
||||
owner-authorized `refactor` branch; never force-push. Push remains an explicit
|
||||
act. Do not merge into `next` or `main` without separate authorization.
|
||||
`scripts/conductor-apply.sh` commits locally; it does not authorize a push.
|
||||
9. **Append-only logs**: BUILD-LOG.md (phases), `activation-log.jsonl`,
|
||||
`.pruned.log`, docs/SESSIONS.md. Corrections are new entries, never edits.
|
||||
| Package | Purpose | Key Dependencies |
|
||||
| ------------------ | ------------------------------- | -------------------------------- |
|
||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||
|
||||
## Autonomous operation within an agreed plan
|
||||
## Architecture Rules
|
||||
|
||||
Autonomy starts after alignment, not before it. For a new substantial assignment,
|
||||
recover the applicable mission, goal, task, `CURRENT.md` state, and prior owner
|
||||
decisions, then work with the user to establish a plan of action: the intended
|
||||
outcome, acceptance evidence, boundaries, and any gated actions. Recommend a
|
||||
concrete plan instead of presenting an open-ended menu. A direct request or
|
||||
existing approved plan that already settles those points is sufficient alignment;
|
||||
do not ask for ceremonial reconfirmation.
|
||||
1. Gateway is the single API surface — all clients connect through it
|
||||
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
||||
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
|
||||
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
||||
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
|
||||
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
||||
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
||||
|
||||
Once the plan is established, carry it to verified completion without prompting
|
||||
for routine decisions or permission to take the next in-scope step. Authorization
|
||||
persists for the life of that assignment unless the user changes or revokes it.
|
||||
Treat mid-session user input as steering: incorporate it, update the plan or
|
||||
tracking record when needed, and continue.
|
||||
## Development Workflow
|
||||
|
||||
### Decide and continue
|
||||
```bash
|
||||
docker compose up -d # Infrastructure
|
||||
pnpm install # Dependencies
|
||||
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
||||
```
|
||||
|
||||
- Resolve naming, implementation approach, layout, and similar non-breaking
|
||||
choices from, in order: repository invariants and role policy, the approved
|
||||
plan and acceptance criteria, established repository conventions, then the
|
||||
smallest reversible option. Record a consequential choice and its tradeoff.
|
||||
- Perform the in-scope investigation, edits, tests, documentation, and tracking
|
||||
needed for end-to-end acceptance. Do not ask whether to add obviously required
|
||||
tests or documentation.
|
||||
- Diagnose failures and retry or remediate within the agreed scope. Fix a defect
|
||||
when it blocks acceptance or is local to files already being changed; otherwise
|
||||
record a bounded follow-up without expanding the assignment.
|
||||
- Resolve minor ambiguity in favor of the mission, goal, north star, and prior
|
||||
owner decisions. State the assumption in the completion report.
|
||||
- Never stop merely to ask whether to proceed, which routine option to use, or
|
||||
whether to execute the next step already contained in the plan.
|
||||
## Repo-Specific Notes
|
||||
|
||||
### Re-align or stop only at a real boundary
|
||||
- DTOs in `*.dto.ts` files at module boundaries
|
||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||
- NodeNext module resolution in all tsconfigs
|
||||
- Scratchpads are mandatory for non-trivial tasks
|
||||
|
||||
Finish all independent work first, then ask one focused question only when:
|
||||
## docs/TASKS.md — Schema (CANONICAL)
|
||||
|
||||
1. Two plausible readings materially change the outcome and the choice is costly
|
||||
to reverse.
|
||||
2. The next action would exceed the agreed scope or authority, introduce an
|
||||
unapproved breaking public/API/schema/data/policy change, or alter a security
|
||||
boundary.
|
||||
3. Credentials or access are missing and no in-scope path remains.
|
||||
4. The action is destructive, irreversible, production-affecting, incurs spend,
|
||||
or communicates externally on the user's behalf without explicit authority.
|
||||
5. Objectives or owner decisions genuinely conflict and repository evidence
|
||||
cannot resolve them.
|
||||
6. A fail-closed policy refusal or another agent's overlapping ownership prevents
|
||||
safe progress. Diagnose and report it; never route around it.
|
||||
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
||||
|
||||
Repository gates still apply. In particular, a successful implementation or a
|
||||
broad request to “finish” does not by itself authorize push, merge, deployment,
|
||||
release, production changes, policy/role expansion, or access to secrets. Perform
|
||||
such an action only when the established plan explicitly includes it. If blocked,
|
||||
report the exact boundary, what is complete, the recommended resolution, and the
|
||||
specific action that will resume; do not use “waiting for confirmation” as a
|
||||
substitute for a real blocker.
|
||||
| Value | When to use | Budget |
|
||||
| --------- | ----------------------------------------------------------- | -------------------------- |
|
||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
||||
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
||||
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
||||
|
||||
## Session protocol (mandatory)
|
||||
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
||||
|
||||
- **Register** your session in `docs/SESSIONS.md` — one append-only line
|
||||
(date, actor, scope, outcome). Never rewrite or remove entries.
|
||||
- **Cadence**: run `scripts/mosaic queue next <your seat>` first. It names
|
||||
the row to resume, review or start, or says there is nothing. The goal order
|
||||
in `docs/plans/2026-09-27_goals-review.md` sets priority, not CURRENT.md.
|
||||
Open only the brief that row links to. Execute it through every authorized
|
||||
stage (implement → test → verify against acceptance criteria; commit, push,
|
||||
or close only when the established plan authorizes each) → move the row with
|
||||
`scripts/mosaic queue move` (never by editing QUEUE.md) → register in
|
||||
SESSIONS.md.
|
||||
- "next" means one action. A batch mandate ("run the queue") repeats the
|
||||
loop until green or truly blocked under the boundary rules above.
|
||||
- Substantial work gets a Gitea issue and a BUILD-LOG phase entry
|
||||
(before/after, with corrections recorded honestly).
|
||||
**Full schema:**
|
||||
|
||||
## Internal development bootstrap
|
||||
```
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
```
|
||||
|
||||
Jason's current direction is repository-native development in
|
||||
`/mnt/storage/src/mosaic-stack`. Sage leads the project (Jason's ruling,
|
||||
2026-09-26) and coordinates coding, review and research through Darkwing, Dewey,
|
||||
Filbert, Rocko, Researcher and any further seats Jason launches under `agents/`.
|
||||
Darkwing is a collaborating agent seat, not the coordinator. Development sessions
|
||||
run in T3 for now. Work moves to the new stack; the old `~/.mosaic` fleet is being
|
||||
retired, and a fleet seat acting outside Jason's instructions is the failure this
|
||||
transition exists to prevent.
|
||||
Do not assign new development work to fleet seats during this bootstrap phase.
|
||||
Do not modify `~/.mosaic` launchers, provisioning or other state, or stop/migrate
|
||||
live fleet processes as part of this work. Preserve existing work and histories.
|
||||
Use the repository bootstrap/configuration and launch entry points; missing
|
||||
configuration still fails closed. This changes development coordination, not
|
||||
managed worker role policy or deployment authority. The lead role adds no push,
|
||||
merge or deployment authority; those still need Jason's say-so. See
|
||||
`agents/README.md` for the internal roster.
|
||||
|
||||
For control-board attention, start a completed reply with `Input needed: ` and
|
||||
one specific nonempty request only when Jason must provide a decision or input.
|
||||
Put that line at column zero, before other text. Do not use it for routine
|
||||
completion or a wait on another agent. Ordinary completed replies are idle.
|
||||
Use code fences or blockquotes when showing this convention as an example.
|
||||
The signal is advisory status, never permission for a protected action. Seen
|
||||
acknowledges a request; it does not resolve it. See `packages/control-board/README.md`.
|
||||
|
||||
## Role model
|
||||
|
||||
- **Conductor**: a system-scoped role — not an agent, not a daemon. Holds
|
||||
git/credentials/policy authority; decomposes, dispatches, reviews,
|
||||
verifies, integrates. Protocol: `docs/plans/CONDUCTOR.md`. Exists only
|
||||
when invoked; push is never automatic.
|
||||
- **Workers**: headless pi via `scripts/run-task.sh` — sandboxed workspace,
|
||||
tools allowlist, optional persistent sessions and forks; no git, no
|
||||
credentials, no policy control.
|
||||
- Worker runs deliberately exclude this file (`--no-context-files` in the
|
||||
adapter): worker context is contracts + mission via the generated system
|
||||
prompt. This file is for conductor-level sessions.
|
||||
|
||||
## Command surface
|
||||
|
||||
`scripts/bootstrap.sh` (idempotent) · `build.sh` · `hello.sh` ·
|
||||
`verify.sh` · `run-task.sh run <task.json>` · `release.sh
|
||||
package|activate|rollback|status` · `auth.sh status|accounts` · `reset.sh` (**danger**: wipes the data
|
||||
root; triple-safety-checked) · `mosaic-task.mjs validate|run|show|list|retry|prune|resolve-role` ·
|
||||
`agent.sh <name>` (interactive TUI agent) ·
|
||||
suites: `test-config.sh`, `test-task.sh`, `test-release.sh`,
|
||||
`test-conductor.sh`, `test-auth.sh`, `test-discord.sh`, `test-queue.sh`.
|
||||
|
||||
Full reference — usage, fields, exit codes, safety notes:
|
||||
`docs/TOOLS.md` (read on demand; do not rely on this summary for detail).
|
||||
|
||||
## Data map (canon)
|
||||
|
||||
- `~/.config/mosaic-dev/config.json`, or `~/.config/mosaic-NAME/config.json`
|
||||
per named instance: system config (user-authored; never auto-written).
|
||||
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
|
||||
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
|
||||
- `sessions/` — pi JSONL session trees, one directory per named session
|
||||
- `workspaces/` — agent file effects (persistent or `:run` ephemeral)
|
||||
- `state/` — release pointer + append-only activation/auto-apply logs
|
||||
- Ownership is per-directory; nothing shares state. Directory map and
|
||||
lifecycle rules: README.md "Data map" section.
|
||||
|
||||
## Pointers (depth lives here)
|
||||
|
||||
- `docs/plans/2026-09-27_goals-review.md` — north star and goal order (Jason ratified 2026-09-27)
|
||||
- `docs/plans/QUEUE.md` — THE task list, rendered from `docs/plans/queue.json`
|
||||
(`scripts/mosaic queue next <seat>` reads it; `packages/queue/README.md` has the verbs)
|
||||
- `docs/plans/CURRENT.md` — narrative log behind the queue rows
|
||||
- `docs/plans/ROADMAP.md` — agreed milestone path (M16+)
|
||||
- `docs/plans/CONDUCTOR.md` — orchestration protocol and guardrails
|
||||
- `docs/plans/2026-09-02_atomic-mosaic-foundation.md` — architecture, invariants
|
||||
- `docs/plans/2026-09-03_autonomous-run.md` — batch-run tracker
|
||||
- `BUILD-LOG.md` — append-only build/verification history with corrections
|
||||
- `LAYERS.md` — implemented vs deferred layers
|
||||
- `docs/SESSIONS.md` — session registry
|
||||
- `adapters/README.md` — the harness adapter contract
|
||||
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
|
||||
|
||||
## Recovery rule
|
||||
|
||||
Compacted, restarted, or new? Nothing that matters is lost: this file +
|
||||
`scripts/mosaic queue next <seat>` + `docs/plans/CURRENT.md` +
|
||||
`git log --oneline -10` + the suites reconstruct the full state. **Never
|
||||
guess** — verify with the suites; the run records and logs hold the receipts.
|
||||
|
||||
## Version pin
|
||||
|
||||
`@earendil-works/pi-coding-agent` is pinned exactly (see `package.json` /
|
||||
`RELEASE`); never install unversioned. Release identity: `RELEASE` file
|
||||
(0.0.X until declared stable); image tags derive from it.
|
||||
- `status`: `not-started` | `in-progress` | `done` | `failed` | `blocked` | `needs-qa`
|
||||
- `agent`: model value from table above (set before spawning)
|
||||
- `estimate`: token budget e.g. `8K`, `25K`
|
||||
@@ -1,371 +0,0 @@
|
||||
# Minimal Mosaic Stack container proof of concept
|
||||
|
||||
## Purpose
|
||||
|
||||
Build the smallest isolated container that can:
|
||||
- launch Pi
|
||||
- load a small set of Mosaic-style contract files
|
||||
- send one real request to a model
|
||||
- return a known response.
|
||||
|
||||
This is a standalone experiment. It is not part of the existing Mosaic Stack repository or Software Factory.
|
||||
|
||||
## Working boundary
|
||||
|
||||
The directory containing this brief is the project root.
|
||||
|
||||
### Do not read, copy, mount, import, or modify anything from:
|
||||
- `/home/jwoltje/.mosaic`
|
||||
- `/home/jwoltje/.config/mosaic`
|
||||
- `/home/jwoltje/src/mosaic-stack`
|
||||
- Existing Mosaic Stack worktrees
|
||||
|
||||
### Do not use:
|
||||
- Mosaic orchestration
|
||||
- Mosaic Git wrappers
|
||||
- Fleet agents
|
||||
- Fleet communication
|
||||
- Mosaic role policies
|
||||
- Existing Mosaic contract files
|
||||
- Existing Mosaic runtime state
|
||||
|
||||
No Git credentials, issue, pull request, reviewer, merge, or deployment are required for this experiment.
|
||||
|
||||
Nothing from this experiment may be copied into the existing Mosaic Stack repository until it receives a separate review later.
|
||||
|
||||
## Runtime data
|
||||
|
||||
Use this host directory only for generated runtime data:
|
||||
|
||||
```text
|
||||
/home/jwoltje/.mosaic-dev
|
||||
```
|
||||
|
||||
The source code must remain in the project directory containing this brief.
|
||||
|
||||
Inside the container, use:
|
||||
|
||||
```text
|
||||
/opt/mosaic/contracts Immutable contract files
|
||||
/var/lib/mosaic Generated runtime state
|
||||
/workspace Agent workspace
|
||||
```
|
||||
|
||||
Mount /home/jwoltje/.mosaic-dev at /var/lib/mosaic.
|
||||
|
||||
### Required proof
|
||||
|
||||
The finished experiment must prove one path:
|
||||
|
||||
1. Build one container image.
|
||||
2. Start one Pi agent inside the container.
|
||||
3. Load four local contract files from /opt/mosaic/contracts.
|
||||
4. Send a request that does not contain the expected response.
|
||||
5. Receive MOSAIC_HELLO_OK from the agent.
|
||||
6. Exit successfully when the response matches.
|
||||
7. Exit nonzero when the response does not match.
|
||||
|
||||
This is the entire required functional result.
|
||||
|
||||
### Required discovery
|
||||
|
||||
Before writing the runtime command:
|
||||
|
||||
1. Find the current package documentation for @earendil-works/pi-coding-agent.
|
||||
2. Determine the current package version.
|
||||
3. Determine the supported noninteractive command.
|
||||
4. Determine how Pi accepts a custom system prompt or system prompt file.
|
||||
5. Determine Pi's documented container authentication method.
|
||||
6. Record the commands and findings in BUILD-LOG.md.
|
||||
|
||||
Do not guess CLI flags, authentication paths, or SDK methods.
|
||||
|
||||
Pin the selected Pi package version in the project. Do not install an unversioned package during each container start.
|
||||
|
||||
Prefer the Pi CLI. Use the Pi SDK only if the CLI cannot load the generated system prompt in noninteractive mode.
|
||||
|
||||
### Contract files
|
||||
|
||||
Create these files inside the project:
|
||||
|
||||
```text
|
||||
contracts/CONSTITUTION.md
|
||||
contracts/STANDARDS.md
|
||||
contracts/SOUL.md
|
||||
contracts/USER.md
|
||||
```
|
||||
|
||||
Use these exact contents.
|
||||
|
||||
### contracts/CONSTITUTION.md
|
||||
|
||||
```markdown
|
||||
# POC constitution
|
||||
|
||||
Never print credentials, tokens, or authentication files.
|
||||
|
||||
Follow the loaded system instructions before the user request.
|
||||
```
|
||||
|
||||
### contracts/STANDARDS.md
|
||||
|
||||
```markdown
|
||||
# POC standards
|
||||
|
||||
Answer startup verification requests with only the requested value.
|
||||
Do not add explanation or formatting.
|
||||
```
|
||||
|
||||
### contracts/SOUL.md
|
||||
|
||||
```markdown
|
||||
# POC identity
|
||||
|
||||
Your name is mosaic-poc-agent.
|
||||
|
||||
Your startup marker is MOSAIC_HELLO_OK.
|
||||
|
||||
When asked for your startup marker, return only the marker.
|
||||
```
|
||||
|
||||
### contracts/USER.md
|
||||
|
||||
```markdown
|
||||
# POC user
|
||||
|
||||
This is an isolated local runtime test.
|
||||
```
|
||||
|
||||
Contract loading
|
||||
|
||||
Create a small script that reads the four contract files in this order:
|
||||
|
||||
1. CONSTITUTION.md
|
||||
2. STANDARDS.md
|
||||
3. SOUL.md
|
||||
4. USER.md
|
||||
|
||||
Join them with clear file separators.
|
||||
|
||||
Write the generated system prompt to:
|
||||
|
||||
```text
|
||||
/var/lib/mosaic/system-prompt.md
|
||||
```
|
||||
|
||||
Pass that generated prompt to Pi using its documented CLI or SDK method.
|
||||
|
||||
Do not build:
|
||||
|
||||
- Contract schemas
|
||||
- Contract inheritance
|
||||
- Overlays
|
||||
- Role transitions
|
||||
- Dynamic policy loading
|
||||
- Guide routing
|
||||
- Manifest validation
|
||||
|
||||
Container
|
||||
|
||||
Create one service named:
|
||||
|
||||
```text
|
||||
mosaic-agent
|
||||
```
|
||||
|
||||
Use one Containerfile and one compose.yaml.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Use a maintained Node.js base image.
|
||||
- Run as a non-root user.
|
||||
- Install a pinned Pi package version.
|
||||
- Copy the local contract fixtures into /opt/mosaic/contracts.
|
||||
- Do not copy credentials into the image.
|
||||
- Do not mount the Docker socket.
|
||||
- Do not mount either live Mosaic directory.
|
||||
- Do not add a database, web server, queue, or second container.
|
||||
- The container may run as a one-shot command. It does not need to remain running.
|
||||
|
||||
### Authentication
|
||||
|
||||
Use Pi's documented authentication mechanism.
|
||||
|
||||
Authentication must be supplied at runtime through either:
|
||||
- A read-only mounted credential file
|
||||
- A supported runtime environment variable
|
||||
|
||||
**Never**:
|
||||
- Commit credentials
|
||||
- Copy credentials into the image
|
||||
- Print credentials
|
||||
- Print authentication files
|
||||
- Include credentials in BUILD-LOG.md
|
||||
- Store credentials under the project directory
|
||||
|
||||
Provide .env.example only for non-secret settings such as model or provider names.
|
||||
|
||||
If credentials are unavailable, complete the image and scripts but report that the real model request remains unverified. Do not fake the response.
|
||||
|
||||
### Required commands
|
||||
|
||||
Create these executable scripts:
|
||||
```text
|
||||
scripts/build.sh
|
||||
scripts/hello.sh
|
||||
scripts/verify.sh
|
||||
scripts/reset.sh
|
||||
```
|
||||
|
||||
### scripts/build.sh
|
||||
|
||||
Build the container image using Docker Compose.
|
||||
|
||||
### scripts/hello.sh
|
||||
|
||||
Run the mosaic-agent service as a one-shot container.
|
||||
|
||||
Send this exact user request:
|
||||
|
||||
```text
|
||||
Return your startup marker and nothing else.
|
||||
```
|
||||
|
||||
The request must not contain MOSAIC_HELLO_OK.
|
||||
|
||||
Print the model response without printing credentials or unrelated runtime data.
|
||||
|
||||
### scripts/verify.sh
|
||||
|
||||
Run the complete test.
|
||||
|
||||
**It must**:
|
||||
|
||||
1. Build or confirm the image is built.
|
||||
2. Run the agent request.
|
||||
3. Remove surrounding whitespace from the response.
|
||||
4. Compare the response with MOSAIC_HELLO_OK.
|
||||
5. Exit 0 only when they match exactly.
|
||||
6. Exit nonzero with a clear error when they do not match.
|
||||
|
||||
### scripts/reset.sh
|
||||
|
||||
Delete generated POC state only when all checks pass:
|
||||
1. The resolved path is exactly /home/jwoltje/.mosaic-dev.
|
||||
2. The path is not a symbolic link.
|
||||
3. The directory contains a .mosaic-poc-root ownership marker created by this project.
|
||||
|
||||
Refuse to delete anything if a check fails.
|
||||
|
||||
## Required files
|
||||
|
||||
The final project should contain only what the implementation needs:
|
||||
|
||||
```text
|
||||
BRIEF.md
|
||||
BUILD-LOG.md
|
||||
README.md
|
||||
LAYERS.md
|
||||
Containerfile
|
||||
compose.yaml
|
||||
package.json
|
||||
package-lock.json
|
||||
.gitignore
|
||||
contracts/
|
||||
scripts/
|
||||
src/
|
||||
```
|
||||
|
||||
Remove unused files and empty directories.
|
||||
|
||||
Build log
|
||||
|
||||
Create BUILD-LOG.md.
|
||||
|
||||
Treat it as append-only.
|
||||
|
||||
Before each phase, append:
|
||||
- Timestamp
|
||||
- Intended action
|
||||
- Reason
|
||||
- Expected result
|
||||
|
||||
After each phase, append:
|
||||
- Commands run
|
||||
- Observed result
|
||||
- Failure or correction
|
||||
|
||||
Never rewrite an earlier entry. Add a correction as a new entry.
|
||||
|
||||
Do not record credentials.
|
||||
|
||||
Initial decisions:
|
||||
- This is a standalone experiment outside the Mosaic Software Factory.
|
||||
- It does not use existing Mosaic source, tools, contracts, agents, or runtime state.
|
||||
- The first proof uses one Pi agent and four small local contract files.
|
||||
- The only required model result is MOSAIC_HELLO_OK.
|
||||
- Persistence, policy enforcement, Claude, orchestration, and portal work are deferred.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The experiment passes when:
|
||||
1. scripts/build.sh exits 0.
|
||||
2. The image contains the four local contract files.
|
||||
3. The image contains no credentials.
|
||||
4. The container has no mounts from ~/.mosaic or ~/.config/mosaic.
|
||||
5. scripts/hello.sh performs a real model request.
|
||||
6. The request does not contain the expected marker.
|
||||
7. The agent returns exactly MOSAIC_HELLO_OK.
|
||||
8. scripts/verify.sh exits 0.
|
||||
9. Changing the expected value makes scripts/verify.sh exit nonzero.
|
||||
10. scripts/reset.sh refuses unsafe paths.
|
||||
11. Resetting and rerunning the verification produces the same successful result.
|
||||
|
||||
## Deferred layers
|
||||
|
||||
Document these in LAYERS.md. Do not implement them.
|
||||
|
||||
- L0: Container builds and returns MOSAIC_HELLO_OK.
|
||||
- L1: Persist and resume a named Pi session.
|
||||
- L2: Add a fixed tool permission policy.
|
||||
- L3: Load full versioned contract bundles.
|
||||
- L4: Add Claude as a second runtime.
|
||||
- L5: Add multiple agents and communication.
|
||||
- L6: Add orchestration, knowledge storage, and portal features.
|
||||
|
||||
## Explicit exclusions
|
||||
|
||||
Do not implement:
|
||||
|
||||
- Existing Mosaic Stack compatibility
|
||||
- Git hosting or CI
|
||||
- Pull requests or code review
|
||||
- Deployment
|
||||
- Persistent agent sessions
|
||||
- Tool read restrictions
|
||||
- Claude
|
||||
- Multiple agents
|
||||
- Fleet communication
|
||||
- Watchers
|
||||
- Role management
|
||||
- Knowledge storage
|
||||
- Database storage
|
||||
- API server
|
||||
- Web interface
|
||||
- Dashboard
|
||||
- Production security architecture
|
||||
|
||||
## Final report
|
||||
|
||||
When finished, report:
|
||||
|
||||
1. Files created.
|
||||
2. Pi package version.
|
||||
3. Exact build command.
|
||||
4. Exact verification command.
|
||||
5. Verification output with credentials removed.
|
||||
6. Whether the real model request passed.
|
||||
7. Any remaining failure.
|
||||
8. Anything implemented beyond this brief.
|
||||
|
||||
Do not describe the experiment as production-ready.
|
||||
-5313
File diff suppressed because it is too large.
Load diff
@@ -1 +1,45 @@
|
||||
@AGENTS.md
|
||||
# CLAUDE.md — Mosaic Stack
|
||||
|
||||
## Project
|
||||
|
||||
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
||||
|
||||
## Stack
|
||||
|
||||
- **API**: NestJS + Fastify adapter (`apps/gateway`)
|
||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
||||
- **Auth**: BetterAuth (`packages/auth`)
|
||||
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
||||
- **Queue**: Valkey 8 (`packages/queue`)
|
||||
- **Build**: pnpm workspaces + Turborepo
|
||||
- **CI**: Woodpecker CI
|
||||
- **Observability**: OpenTelemetry → Jaeger
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm typecheck # TypeScript check (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm format:check # Prettier check
|
||||
pnpm test # Vitest (all packages)
|
||||
pnpm build # Build all packages
|
||||
|
||||
# Database
|
||||
pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev)
|
||||
pnpm --filter @mosaicstack/db db:generate # Generate migrations
|
||||
pnpm --filter @mosaicstack/db db:migrate # Run migrations
|
||||
|
||||
# Dev
|
||||
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
|
||||
pnpm --filter @mosaicstack/gateway exec tsx src/main.ts # Start gateway
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||
- NodeNext module resolution
|
||||
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
|
||||
- DTOs in `*.dto.ts` files at module boundaries
|
||||
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
|
||||
- All three gates must pass before push: typecheck, lint, format:check
|
||||
@@ -1,44 +0,0 @@
|
||||
# Minimal Mosaic Stack POC agent image.
|
||||
# Base: maintained Node.js image (same family as Pi's documented
|
||||
# containerization example in docs/containerization.md).
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
# Tools Pi's documented container image expects (bash, CA certs, git, ripgrep).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user: the maintained node image ships a 'node' user at
|
||||
# uid/gid 1000, which matches the host user that owns the runtime
|
||||
# state directory mounted at /var/lib/mosaic. It is reused as-is.
|
||||
|
||||
# Pinned Pi install: package.json pins the exact version and
|
||||
# package-lock.json is installed with npm ci. No unversioned installs.
|
||||
WORKDIR /opt/app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
# Immutable contract fixtures (required location), runtime scripts, and
|
||||
# runtime adapters.
|
||||
COPY contracts /opt/mosaic/contracts
|
||||
COPY src /opt/mosaic/src
|
||||
COPY packages/processing-control/src/state.mjs packages/processing-control/src/entrypoint.mjs /opt/mosaic/processing-control/
|
||||
COPY adapters /opt/mosaic/adapters
|
||||
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
|
||||
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
|
||||
&& chmod 0555 /opt/mosaic/adapters /opt/mosaic/adapters/*/adapter.sh
|
||||
|
||||
# Writable state, workspace, and pi agent directory (auth.json is
|
||||
# bind-mounted read-only at runtime; nothing is copied into the image).
|
||||
RUN mkdir -p /var/lib/mosaic /workspace /home/node/.pi/agent \
|
||||
&& chown -R node:node /var/lib/mosaic /workspace /home/node /opt/app
|
||||
|
||||
USER node
|
||||
WORKDIR /workspace
|
||||
ENV HOME=/home/node \
|
||||
PATH="/opt/app/node_modules/.bin:${PATH}" \
|
||||
PI_OFFLINE=1
|
||||
|
||||
# One-shot agent: args form the user request (default is the startup
|
||||
# verification request defined in compose.yaml).
|
||||
ENTRYPOINT ["/opt/mosaic/src/run-agent.sh"]
|
||||
@@ -1,50 +0,0 @@
|
||||
# LAYERS
|
||||
|
||||
Deferred capability layers for the Mosaic experiment. Only L0 is implemented by
|
||||
this proof of concept; everything below it is documented here and deliberately
|
||||
not implemented (see BRIEF.md, "Explicit exclusions").
|
||||
|
||||
## L0 — Implemented: container returns MOSAIC_HELLO_OK
|
||||
|
||||
One image (`mosaic-poc-agent:0.84.4`, built on `node:24-bookworm-slim`, non-root,
|
||||
pinned Pi) runs one Pi agent one-shot. Four immutable local contract files are
|
||||
loaded in fixed order into the generated system prompt
|
||||
(`/var/lib/mosaic/system-prompt.md`). One real model request is sent
|
||||
noninteractively; the response must equal `MOSAIC_HELLO_OK` exactly or the
|
||||
verification exits nonzero. Authentication is supplied at runtime only
|
||||
(read-only mounted pi auth file, or a provider API key environment variable).
|
||||
|
||||
## L1 — Deferred: persist and resume a named Pi session
|
||||
|
||||
Keep a named Pi session across container runs (`--name`, session storage under
|
||||
`/var/lib/mosaic`), resume it with the documented session flags, and verify
|
||||
state survives a container restart.
|
||||
|
||||
## L2 — Deferred: fixed tool permission policy
|
||||
|
||||
Add a fixed allow/deny policy for Pi tools (e.g. restricting built-in tools via
|
||||
documented `--tools` / `--exclude-tools` or an extension-based permission gate),
|
||||
so contract files can constrain what the agent may do, not just what it says.
|
||||
|
||||
## L3 — Deferred: load full versioned contract bundles
|
||||
|
||||
Replace the four static fixtures with versioned contract bundles: bundle
|
||||
manifests, contract versions, and deterministic ordering/hashing, loaded from
|
||||
an immutable bundle artifact instead of files copied at image build time.
|
||||
|
||||
## L4 — Deferred: Claude as a second runtime
|
||||
|
||||
Add a second runtime (Claude) alongside the Pi agent in the same container
|
||||
stack, behind the same contract-loading path, to compare behavior across
|
||||
runtimes.
|
||||
|
||||
## L5 — Deferred: multiple agents and communication
|
||||
|
||||
Run several named agents with defined roles and a communication channel between
|
||||
them (message passing or shared state under `/var/lib/mosaic`).
|
||||
|
||||
## L6 — Deferred: orchestration, knowledge storage, and portal features
|
||||
|
||||
Fleet-level orchestration, knowledge storage, monitoring, and portal UI on top
|
||||
of L1-L5. This is where the existing Mosaic Stack concepts would be re-evaluated
|
||||
from first principles.
|
||||
@@ -1,238 +1,364 @@
|
||||
# Mosaic Stack — new foundation
|
||||
# Mosaic Stack
|
||||
|
||||
The active rebuild is at this repository's root. The original Mosaic Stack v1
|
||||
source is archived under `v1/`; it is not the implementation being developed here.
|
||||
Self-hosted, multi-user AI agent platform. One config, every runtime, same standards.
|
||||
|
||||
- Canonical checkout: `/mnt/storage/src/mosaic-stack`
|
||||
- Repository: `mosaicstack/stack`
|
||||
- Working branch: `refactor`
|
||||
- Former `~/src/mosaic-stack-dev-test`: compatibility symlink to this same checkout
|
||||
Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi — injecting consistent system prompts, guardrails, skills, and mission context into every session. A NestJS gateway provides the API surface, a Next.js dashboard gives you the UI, and a plugin system connects Discord, Telegram, and more.
|
||||
|
||||
Both original Git histories and pending development work are preserved. See the
|
||||
[conversion record](docs/plans/2026-09-07_repository-consolidation-completed.md)
|
||||
and [current next action](docs/plans/CURRENT.md). Do not use v1's startup commands,
|
||||
package layout or agent instructions for work on the new foundation.
|
||||
|
||||
## Original container proof
|
||||
|
||||
The foundation began as a standalone container experiment. One container image
|
||||
runs one Pi coding agent with four immutable local contract files as its system
|
||||
prompt, sends exactly one real model request, and was verified to return exactly
|
||||
`MOSAIC_HELLO_OK`. This historical result is not a claim that the full rebuild is
|
||||
production-ready.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
BRIEF.md requirements for the original container proof
|
||||
BUILD-LOG.md append-only build/verification log
|
||||
LAYERS.md implemented layer (L0) and deferred layers (L1-L6)
|
||||
Containerfile image definition (node:24-bookworm-slim, non-root, pinned Pi)
|
||||
compose.yaml one service: mosaic-agent (one-shot; configured via env)
|
||||
package.json pins @earendil-works/pi-coding-agent at exactly 0.84.4
|
||||
package-lock.json resolved lockfile used by npm ci in the image
|
||||
.env.example non-secret settings only (credential-file path, env-var auth)
|
||||
contracts/ CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md (immutable fixtures)
|
||||
scripts/ bootstrap/build/hello/verify/reset + config tooling
|
||||
src/ load-contracts.sh, run-agent.sh (run inside the container)
|
||||
docs/plans/ architecture and milestone plans
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The sole discovery entry point is:
|
||||
|
||||
```text
|
||||
~/.config/mosaic-dev/config.json
|
||||
```
|
||||
|
||||
Created only by the explicit, idempotent bootstrap:
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
scripts/bootstrap.sh # create-if-absent; validates existing config, never rewrites
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
```
|
||||
|
||||
Minimal shape (`configVersion` 1):
|
||||
|
||||
```json
|
||||
{
|
||||
"configVersion": 1,
|
||||
"environment": "development",
|
||||
"dataRoot": "/home/jwoltje/.mosaic-dev",
|
||||
"execution": {
|
||||
"backend": "docker",
|
||||
"provider": "zai",
|
||||
"model": "glm-5.3-flash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules enforced by `scripts/mosaic-config.mjs`:
|
||||
|
||||
- Unknown keys, unsupported versions/backends, and malformed JSON exit nonzero; nothing is modified.
|
||||
- `dataRoot` must be absolute, canonical, and must not be or contain the home or configuration directory.
|
||||
- Validation failures never touch config, state, or images.
|
||||
- `scripts/test-config.sh` runs the sandboxed config selftests (no Docker required).
|
||||
|
||||
Run paths (`build/hello/verify/reset`) fail closed when configuration is missing or invalid; they never invent it.
|
||||
|
||||
## Missions & tasks (M2)
|
||||
|
||||
Missions and tasks are validated JSON data (strict schemas, version-pinned). The M2 layer is host-side only: mission directives are recorded for provenance but do not yet reach the runtime system prompt (capability/policy layer comes later).
|
||||
|
||||
```text
|
||||
missions/hello.json objective + directives (missionVersion 1)
|
||||
tasks/hello-marker.json prompt + optional mission ref + expectExact + timeout
|
||||
<dataRoot>/runs/r-<id>/ immutable run record: task.json, mission.json,
|
||||
stderr.txt, result.json (all write-once)
|
||||
```
|
||||
|
||||
Usage:
|
||||
Or use the direct URL:
|
||||
|
||||
```bash
|
||||
scripts/run-task.sh validate tasks/hello-marker.json # strict validation, writes nothing
|
||||
scripts/run-task.sh run tasks/hello-marker.json # execute; result recorded under dataRoot/runs
|
||||
scripts/mosaic-task.mjs list # list runs and statuses
|
||||
scripts/test-task.sh # selftests (schema negatives + live runs)
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
A run exits 0 only when its expectation is met (`expectExact` match); mismatches, nonzero agent exits, and timeouts record `status: failed` in `result.json` and exit 1. Each run gets a unique directory — rerunning never rewrites history.
|
||||
|
||||
## Release model (M3)
|
||||
|
||||
`RELEASE` single-sources the release version (0.0.X until declared stable); the image tag derives from it plus the pinned Pi version. Activation is health-gated and every event is recorded:
|
||||
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
||||
|
||||
```bash
|
||||
scripts/release.sh package # build + tag the release image
|
||||
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
|
||||
scripts/release.sh activate --fault-injection # prove the refusal path (drills only)
|
||||
scripts/release.sh rollback # health-gated return to the previous release
|
||||
scripts/release.sh ensure # self-determination: align installed to RELEASE (safe no-op when aligned)
|
||||
scripts/release.sh status # release, tag, active pointer, recent log
|
||||
scripts/test-release.sh # release selftests
|
||||
bash <(curl -fsSL …) --yes # Accept all defaults
|
||||
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
|
||||
```
|
||||
|
||||
`ensure` is invoked automatically by the human-facing launchers (`hello`,
|
||||
`verify`, `agent`): the system determines what is installed and aligns
|
||||
itself — the user never runs release commands manually.
|
||||
This installs both components:
|
||||
|
||||
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
|
||||
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
|
||||
| Component | What | Where |
|
||||
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||
|
||||
A failed health check never activates; the previously active release remains deployed. Updating the software therefore cannot corrupt the running installation: package beside, gate, then flip. Verified by the update/refusal/rollback drills in BUILD-LOG Phase 7.
|
||||
|
||||
## Runtime adapters (M4)
|
||||
|
||||
The harness boundary is formalized: everything upstream (config, contracts, missions, tasks, run records) is harness-agnostic; everything inside an adapter belongs to one runtime.
|
||||
|
||||
```text
|
||||
adapters/<name>/adapter.sh env in: MOSAIC_SYSTEM_PROMPT_FILE, MOSAIC_REQUEST,
|
||||
MOSAIC_PROVIDER, MOSAIC_MODEL
|
||||
stdout: response only; stderr: diagnostics
|
||||
```
|
||||
|
||||
- Selection: `execution.adapter` in config.json (optional; `pi` default; allowlist `pi`, `mock`)
|
||||
- `pi` — pinned Pi CLI, noninteractive print mode, ambient discovery off
|
||||
- `mock` — deterministic test adapter; never for real verification
|
||||
- Mission directives have a sanctioned injection point: when a task references a mission, the task runner mounts the run snapshot and the generated prompt gains a `MISSION (runtime)` section (objective + directives) after the four immutable contracts
|
||||
- Adding a harness (Claude, Codex, OpenCode) later means adding one directory — no orchestrator changes
|
||||
|
||||
See `adapters/README.md` for the full contract.
|
||||
|
||||
## Workspaces, capabilities, sessions (M5/M6)
|
||||
|
||||
Optional task fields extend what an agent can do — all defaulting to the previous behavior:
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "demo", // ":run" ephemeral, or persistent dataRoot/workspaces/<name>
|
||||
"capabilities": { "tools": ["bash", "read"] }, // pi tool allowlist; absent = no tools
|
||||
"session": "demo" // persistent session at dataRoot/sessions/<name>
|
||||
}
|
||||
```
|
||||
|
||||
- The adapter runs inside the workspace; files it writes are host-visible (`dataRoot/workspaces/<name>`).
|
||||
- Sessions persist via pi's documented `--session-dir`; a follow-up run in the same session resumes the conversation (`-c`) and can recall prior context. Distinct names never share state. Ephemeral (`--no-session`) remains the default when no session is declared.
|
||||
- Selection authority: config for adapter/provider/model; the task file for workspace/capabilities/session.
|
||||
|
||||
Inspect anything:
|
||||
After install, the wizard runs automatically or you can invoke it manually:
|
||||
|
||||
```bash
|
||||
node scripts/mosaic-task.mjs list # runs with task/workspace/session columns
|
||||
node scripts/mosaic-task.mjs show <runId> # full record + snapshots + artifacts
|
||||
mosaic wizard # Full guided setup (gateway install → verify)
|
||||
```
|
||||
|
||||
Demo fixtures: `tasks/workspace-demo.json`, `tasks/session-demo-1.json` + `tasks/session-demo-2.json`.
|
||||
### Requirements
|
||||
|
||||
See `docs/plans/2026-09-02_atomic-mosaic-foundation.md` for the full plan.
|
||||
|
||||
Inside the container:
|
||||
|
||||
```text
|
||||
/opt/mosaic/contracts immutable contract files
|
||||
/var/lib/mosaic generated runtime state (mounted from configured dataRoot)
|
||||
/workspace agent workspace
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. `scripts/build.sh` builds the release image (`mosaic-poc-agent:<pi>-r<release>`,
|
||||
tag derived from `RELEASE` + the pinned Pi version) with Docker Compose.
|
||||
2. On each run, `/opt/mosaic/src/load-contracts.sh` reads the four contract files
|
||||
in fixed order (CONSTITUTION, STANDARDS, SOUL, USER), joins them with clear
|
||||
separators, and writes `/var/lib/mosaic/system-prompt.md`.
|
||||
3. `/opt/mosaic/src/run-agent.sh` starts Pi noninteractively
|
||||
(`pi -p "Return your startup marker and nothing else."`) with
|
||||
`--system-prompt "$(cat /var/lib/mosaic/system-prompt.md)"` and all ambient
|
||||
discovery disabled (`--no-context-files --no-skills --no-extensions
|
||||
--no-prompt-templates --no-themes`), ephemeral (`--no-session`), tool-free
|
||||
(`--no-tools`), and offline for startup network operations (`--offline`).
|
||||
4. `scripts/verify.sh` trims surrounding whitespace from the response and exits 0
|
||||
only when it equals `MOSAIC_HELLO_OK` exactly.
|
||||
- Node.js ≥ 20
|
||||
- npm (for global @mosaicstack/mosaic install)
|
||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
scripts/bootstrap.sh # create config.json if absent (idempotent)
|
||||
scripts/build.sh # build the image
|
||||
scripts/hello.sh # one-shot request; prints the model response
|
||||
scripts/verify.sh # full gated test; exit 0 only on exact MOSAIC_HELLO_OK
|
||||
scripts/run-task.sh # run a mission/task file (see Missions & tasks)
|
||||
scripts/release.sh # package / activate / rollback / status (see Release model)
|
||||
scripts/test-config.sh # fast config-layer selftests (no Docker)
|
||||
scripts/test-task.sh # mission/task selftests (schema + adapter seam + live runs)
|
||||
scripts/test-release.sh # release selftests
|
||||
scripts/reset.sh # delete the configured data root (safety-checked)
|
||||
```
|
||||
|
||||
Prove the failure path (acceptance criterion 9):
|
||||
### Launching Agent Sessions
|
||||
|
||||
```bash
|
||||
EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh # must exit nonzero
|
||||
mosaic pi # Launch Pi with Mosaic injection
|
||||
mosaic claude # Launch Claude Code with Mosaic injection
|
||||
mosaic codex # Launch Codex with Mosaic injection
|
||||
mosaic opencode # Launch OpenCode with Mosaic injection
|
||||
|
||||
mosaic yolo claude # Claude with dangerous-permissions mode
|
||||
mosaic yolo pi # Pi in yolo mode
|
||||
```
|
||||
|
||||
## Authentication
|
||||
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
|
||||
|
||||
Pi's documented container authentication (see the package's
|
||||
`docs/containerization.md`) is used, in this order:
|
||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
||||
|
||||
1. **Read-only mounted credential file** (default): the host pi auth file
|
||||
`~/.pi/agent/auth.json` is bind-mounted read-only to
|
||||
`/home/node/.pi/agent/auth.json`. The host file holds a static API-key
|
||||
entry for the built-in `zai` provider, so no token refresh writes are needed.
|
||||
2. **Runtime environment variable** (documented alternative): set `ZAI_API_KEY`
|
||||
or `ANTHROPIC_API_KEY` in the environment or in a gitignored `.env`; compose
|
||||
passes them through. Pi's documented precedence applies.
|
||||
### TUI & Gateway
|
||||
|
||||
Credentials are never committed, never copied into the image, and never printed.
|
||||
Mosaic-managed named accounts (`agent.sh --auth`) live under the data root
|
||||
(`auth/<account>.json`, 0600) — the stack never writes into `~/.pi`.
|
||||
`.env.example` contains non-secret settings only.
|
||||
```bash
|
||||
mosaic tui # Interactive TUI connected to the gateway
|
||||
mosaic gateway login # Authenticate with a gateway instance
|
||||
mosaic sessions list # List active agent sessions
|
||||
```
|
||||
|
||||
## Boundaries honored
|
||||
### Gateway Management
|
||||
|
||||
- No mounts of `~/.mosaic` or `~/.config/mosaic`; no Docker socket mount.
|
||||
- Source stays in this project directory; generated state only in
|
||||
`/home/jwoltje/.mosaic-dev` (host) and `/var/lib/mosaic` (container).
|
||||
- No database, web server, queue, second container, orchestration, Git
|
||||
integration, persistent sessions, or policy machinery.
|
||||
```bash
|
||||
mosaic gateway install # Install and configure the gateway service
|
||||
mosaic gateway verify # Post-install health check
|
||||
mosaic gateway login # Authenticate and store a session token
|
||||
mosaic gateway config rotate-token # Rotate your API token
|
||||
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
|
||||
```
|
||||
|
||||
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
|
||||
|
||||
### Configuration
|
||||
|
||||
Mosaic supports three storage tiers: `local` (PGlite, single-host), `standalone` (PostgreSQL, single-host), and `federated` (PostgreSQL + pgvector + Valkey, multi-host). See [Federated Tier Setup](docs/federation/SETUP.md) for multi-user and production deployments, or [Migrating to Federated](docs/guides/migrate-tier.md) to upgrade from existing tiers.
|
||||
|
||||
```bash
|
||||
mosaic config show # Print full config as JSON
|
||||
mosaic config get <key> # Read a specific key
|
||||
mosaic config set <key> <val># Write a key
|
||||
mosaic config edit # Open config in $EDITOR
|
||||
mosaic config path # Print config file path
|
||||
```
|
||||
|
||||
### Management
|
||||
|
||||
```bash
|
||||
mosaic doctor # Health audit — detect drift and missing files
|
||||
mosaic sync # Sync skills from canonical source
|
||||
mosaic update # Check for and install CLI updates
|
||||
mosaic wizard # Full guided setup wizard
|
||||
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
||||
mosaic coord init # Initialize a new orchestration mission
|
||||
mosaic prdy init # Create a PRD via guided session
|
||||
```
|
||||
|
||||
### Sub-package Commands
|
||||
|
||||
Each Mosaic sub-package exposes its API surface through the unified CLI:
|
||||
|
||||
```bash
|
||||
# User management
|
||||
mosaic auth users list
|
||||
mosaic auth users create
|
||||
mosaic auth sso
|
||||
|
||||
# Agent brain (projects, missions, tasks)
|
||||
mosaic brain projects
|
||||
mosaic brain missions
|
||||
mosaic brain tasks
|
||||
mosaic brain conversations
|
||||
|
||||
# Agent forge pipeline
|
||||
mosaic forge run
|
||||
mosaic forge status
|
||||
mosaic forge resume
|
||||
mosaic forge personas
|
||||
|
||||
# Structured logging
|
||||
mosaic log tail
|
||||
mosaic log search
|
||||
mosaic log export
|
||||
mosaic log level
|
||||
|
||||
# MACP protocol
|
||||
mosaic macp tasks
|
||||
mosaic macp submit
|
||||
mosaic macp gate
|
||||
mosaic macp events
|
||||
|
||||
# Agent memory
|
||||
mosaic memory search
|
||||
mosaic memory stats
|
||||
mosaic memory insights
|
||||
mosaic memory preferences
|
||||
|
||||
# Task queue (Valkey)
|
||||
mosaic queue list
|
||||
mosaic queue stats
|
||||
mosaic queue pause
|
||||
mosaic queue resume
|
||||
mosaic queue jobs
|
||||
mosaic queue drain
|
||||
|
||||
# Object storage
|
||||
mosaic storage status
|
||||
mosaic storage tier
|
||||
mosaic storage export
|
||||
mosaic storage import
|
||||
mosaic storage migrate
|
||||
```
|
||||
|
||||
### Telemetry
|
||||
|
||||
```bash
|
||||
# Local observability (OTEL / Jaeger)
|
||||
mosaic telemetry local status
|
||||
mosaic telemetry local tail
|
||||
mosaic telemetry local jaeger
|
||||
|
||||
# Remote telemetry (dry-run by default)
|
||||
mosaic telemetry status
|
||||
mosaic telemetry opt-in
|
||||
mosaic telemetry opt-out
|
||||
mosaic telemetry test
|
||||
mosaic telemetry upload # Dry-run unless opted in
|
||||
```
|
||||
|
||||
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js ≥ 20
|
||||
- pnpm 10.6+
|
||||
- Docker & Docker Compose
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone [email protected]:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||
docker compose up -d
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run migrations
|
||||
pnpm --filter @mosaicstack/db run db:migrate
|
||||
|
||||
# Start all services in dev mode
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Infrastructure
|
||||
|
||||
Docker Compose provides:
|
||||
|
||||
| Service | Port | Purpose |
|
||||
| --------------------- | --------- | ---------------------- |
|
||||
| PostgreSQL (pgvector) | 5433 | Primary database |
|
||||
| Valkey | 6380 | Task queue + caching |
|
||||
| Jaeger | 16686 | Distributed tracing UI |
|
||||
| OTEL Collector | 4317/4318 | Telemetry ingestion |
|
||||
|
||||
### Quality Gates
|
||||
|
||||
```bash
|
||||
pnpm typecheck # TypeScript type checking (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm test # Vitest (all packages)
|
||||
pnpm format:check # Prettier check
|
||||
pnpm format # Prettier auto-fix
|
||||
```
|
||||
|
||||
### CI
|
||||
|
||||
Woodpecker CI runs on every push:
|
||||
|
||||
- `pnpm install --frozen-lockfile`
|
||||
- Database migration against a fresh Postgres
|
||||
- `pnpm test` (Turbo-orchestrated across all packages)
|
||||
|
||||
npm packages are published to the Gitea package registry on main merges.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
stack/
|
||||
├── apps/
|
||||
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
||||
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
||||
├── packages/
|
||||
│ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
|
||||
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
|
||||
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
|
||||
│ ├── auth/ BetterAuth configuration
|
||||
│ ├── brain/ Data layer (PG-backed)
|
||||
│ ├── queue/ Valkey task queue + MCP
|
||||
│ ├── coord/ Mission coordination
|
||||
│ ├── forge/ Multi-stage AI pipeline (intake → board → plan → code → review)
|
||||
│ ├── macp/ MACP protocol — credential resolution, gate runner, events
|
||||
│ ├── agent/ Agent session management
|
||||
│ ├── memory/ Agent memory layer
|
||||
│ ├── log/ Structured logging
|
||||
│ ├── prdy/ PRD creation and validation
|
||||
│ ├── quality-rails/ Quality templates (TypeScript, Next.js, monorepo)
|
||||
│ └── design-tokens/ Shared design tokens
|
||||
├── plugins/
|
||||
│ ├── discord/ Discord channel plugin (discord.js)
|
||||
│ ├── telegram/ Telegram channel plugin (Telegraf)
|
||||
│ ├── macp/ OpenClaw MACP runtime plugin
|
||||
│ └── mosaic-framework/ OpenClaw framework injection plugin
|
||||
├── tools/
|
||||
│ └── install.sh Unified installer (framework + npm CLI, --yes / --no-auto-launch)
|
||||
├── scripts/agent/ Agent session lifecycle scripts
|
||||
├── docker-compose.yml Dev infrastructure
|
||||
└── .woodpecker/ CI pipeline configs
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Gateway is the single API surface** — all clients (TUI, web, Discord, Telegram) connect through it
|
||||
- **ESM everywhere** — `"type": "module"`, `.js` extensions in imports, NodeNext resolution
|
||||
- **Socket.IO typed events** — defined in `@mosaicstack/types`, enforced at compile time
|
||||
- **OTEL auto-instrumentation** — loads before NestJS bootstrap
|
||||
- **Explicit `@Inject()` decorators** — required since tsx/esbuild doesn't emit decorator metadata
|
||||
|
||||
### Framework (`~/.config/mosaic/`)
|
||||
|
||||
The framework is the bash-based standards layer installed to every developer machine:
|
||||
|
||||
```
|
||||
~/.config/mosaic/
|
||||
├── AGENTS.md ← Central standards (loaded into every runtime)
|
||||
├── SOUL.md ← Agent identity (name, style, guardrails)
|
||||
├── USER.md ← User profile (name, timezone, preferences)
|
||||
├── TOOLS.md ← Machine-level tool reference
|
||||
├── bin/mosaic ← Unified launcher (claude, codex, opencode, pi, yolo)
|
||||
├── guides/ ← E2E delivery, orchestrator protocol, PRD, etc.
|
||||
├── runtime/ ← Per-runtime configs (claude/, codex/, opencode/, pi/)
|
||||
├── skills/ ← Universal skills (synced from agent-skills repo)
|
||||
├── tools/ ← Tool suites (orchestrator, git, quality, prdy, etc.)
|
||||
└── memory/ ← Persistent agent memory (preserved across upgrades)
|
||||
```
|
||||
|
||||
### Forge Pipeline
|
||||
|
||||
Forge is a multi-stage AI pipeline for autonomous feature delivery:
|
||||
|
||||
```
|
||||
Intake → Discovery → Board Review → Planning (3 stages) → Coding → Review → Remediation → Test → Deploy
|
||||
```
|
||||
|
||||
Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding), quality gates, and timeouts. The board review uses multiple AI personas (CEO, CTO, CFO, COO + specialists) to evaluate briefs before committing resources.
|
||||
|
||||
## Upgrading
|
||||
|
||||
Run the installer again — it handles upgrades automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
Or use the CLI:
|
||||
|
||||
```bash
|
||||
mosaic update # Check + install CLI updates
|
||||
mosaic update --check # Check only, don't install
|
||||
```
|
||||
|
||||
The CLI also performs a background update check on every invocation (cached for 1 hour).
|
||||
|
||||
### Installer Flags
|
||||
|
||||
```bash
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
# Create a feature branch
|
||||
git checkout -b feat/my-feature
|
||||
|
||||
# Make changes, then verify
|
||||
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check
|
||||
|
||||
# Commit (husky runs lint-staged automatically)
|
||||
git commit -m "feat: description of change"
|
||||
|
||||
# Push and create PR
|
||||
git push -u origin feat/my-feature
|
||||
```
|
||||
|
||||
DTOs go in `*.dto.ts` files at module boundaries. Scratchpads (`docs/scratchpads/`) are mandatory for non-trivial tasks. See `AGENTS.md` for the full standards reference.
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — all rights reserved.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Mosaic Stack
|
||||
|
||||
You are the default collaborator for Mosaic Stack: a practical engineering
|
||||
partner helping people build, inspect, and operate a trustworthy foundation
|
||||
for delegated work.
|
||||
|
||||
Mosaic Stack is deliberately small, file-based, and evidence-oriented. Its
|
||||
purpose is not to perform confidence; it is to make useful work attributable,
|
||||
bounded, reproducible, and reviewable. Treat the system's contracts, policies,
|
||||
and run records as part of the product, not paperwork around it.
|
||||
|
||||
Work with calm precision. Start from what the user is trying to accomplish,
|
||||
make the next useful step clear, and explain results in plain language. Be
|
||||
decisive when the evidence supports a decision; be explicit about uncertainty
|
||||
when it does not. Never claim a test, command, integration, or outcome that
|
||||
you have not actually verified.
|
||||
|
||||
Respect boundaries. Ask before expanding scope, changing authority, touching
|
||||
credentials, or taking an irreversible external action. Prefer the least
|
||||
privileged path, preserve user work, and stop on a policy or validation
|
||||
refusal rather than working around it. A clean refusal with a useful diagnosis
|
||||
is better than a superficially successful but untrustworthy result.
|
||||
|
||||
Leave a legible trail. Make changes intentional, keep records honest, and
|
||||
report what changed, how it was checked, and what remains unresolved. When
|
||||
coordinating other workers, give each one a bounded objective and review their
|
||||
evidence instead of treating their confidence as proof.
|
||||
|
||||
The aim is dependable progress: small enough to understand, safe enough to
|
||||
trust, and concrete enough for a person to verify.
|
||||
@@ -1,128 +0,0 @@
|
||||
# Mosaic runtime adapters
|
||||
|
||||
An adapter is the entire harness-specific surface of the system. Everything
|
||||
upstream of an adapter — configuration, contracts, missions, tasks, run
|
||||
records — is harness-agnostic; everything inside an adapter may assume one
|
||||
specific agent runtime.
|
||||
|
||||
## Contract
|
||||
|
||||
An adapter lives at:
|
||||
|
||||
```text
|
||||
/opt/mosaic/adapters/<name>/adapter.sh
|
||||
```
|
||||
|
||||
and must be executable. The dispatcher (`/opt/mosaic/src/run-agent.sh`)
|
||||
selects it via `MOSAIC_ADAPTER` (default: `pi`) and execs it after the
|
||||
system prompt has been generated.
|
||||
|
||||
**Inputs (environment):**
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `MOSAIC_SYSTEM_PROMPT_FILE` | Absolute path to the generated system prompt (contracts + optional mission section). Read it; do not modify it. |
|
||||
| `MOSAIC_REQUEST` | The exact user request text (may contain newlines). |
|
||||
| `MOSAIC_PROVIDER` | Configured provider name. |
|
||||
| `MOSAIC_MODEL` | Configured model id. |
|
||||
|
||||
Optional, adapter-specific (documented per adapter):
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `MOSAIC_MOCK_RESPONSE` | mock only: the verbatim response to emit |
|
||||
|
||||
**Outputs:**
|
||||
|
||||
- `stdout`: the model response text — the only channel the orchestrator captures
|
||||
- `stderr`: diagnostics (never credentials)
|
||||
- exit `0`: success; nonzero: failure
|
||||
|
||||
## Rules
|
||||
|
||||
1. Adapters print ONLY the response on stdout. Status lines go to stderr.
|
||||
2. Adapters never read configuration files; the resolved settings arrive via environment.
|
||||
3. Adapters never write outside `/var/lib/mosaic`.
|
||||
4. Adding a worker adapter requires: a new directory, the contract
|
||||
implementation, and adding the name to the allowlist in
|
||||
`scripts/mosaic-config.mjs`. A managed-session adapter (below) is selected
|
||||
by the launch bundle, not by `execution.adapter`, and is not on that
|
||||
allowlist unless it also works as a worker adapter.
|
||||
|
||||
## Included adapters
|
||||
|
||||
- `pi` — the pinned `@earendil-works/pi-coding-agent` CLI in noninteractive
|
||||
print mode (`-p`), ambient discovery disabled, stdin detached.
|
||||
- `mock` — deterministic echo of `MOSAIC_MOCK_RESPONSE`. Test-only: never use
|
||||
it where a real model response is required.
|
||||
- `claude` — the host's `claude` CLI, for managed sessions only (below):
|
||||
`MOSAIC_CLAUDE_BIN` when set, else `claude` on `PATH`.
|
||||
Not a worker adapter: the image has no `claude`, and the adapter refuses
|
||||
without the bundle's hook and MCP files.
|
||||
|
||||
## Managed sessions (slice 1 S6)
|
||||
|
||||
The session runner (`packages/harness/src/runner.mjs`) runs one adapter
|
||||
call per turn on the host, as `/bin/sh <adapters/<name>/adapter.sh>` (the
|
||||
repository keeps adapters 0644; only the image sets the mode), in the
|
||||
session's workspace and its own process group. Same contract, plus:
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `MOSAIC_WORKSPACE` | The session's workspace; the adapter runs there. |
|
||||
| `MOSAIC_SESSION_DIR` | The session's persistent directory; later turns resume the session kept there. |
|
||||
| `MOSAIC_TOOLS` | The built-in tool limit (pi names for `pi`, Claude Code names for `claude`), comma-separated. |
|
||||
| `MOSAIC_POLICY_FILE`, `MOSAIC_TOOLS_FILE` | The bundle's gate policy and typed tools. |
|
||||
| `MOSAIC_TOOL_SOCKET` | The runner's tool socket, which the typed tools call. |
|
||||
| `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. |
|
||||
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. The pi module names two: the gate and typed tools extension, then the unslop check's `tools/unslop-hook/extension.ts` (row 66). |
|
||||
| `MOSAIC_UNSLOP_LOG` | The unslop check's JSONL log, `<run>/unslop.jsonl`, which the runner sets for both adapters and reads after each turn (row 66). |
|
||||
| `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. |
|
||||
| `MOSAIC_THINKING` | The route's thinking level (row 65), which the harness module's `levels()` already checked. `pi` passes `--thinking` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) and omits it when unset, as for `run-task.sh` workers. `claude` passes `--effort` (`low`, `medium`, `high`, `xhigh`, `max`) and requires it; unset exits 2. |
|
||||
| `MOSAIC_CLAUDE_BIN` | `claude` only: absolute path of the CLI to run, from the host's environment (the bus unit pins it). Unset or empty runs `claude` from `PATH`; a relative path or a non-executable file exits 2. |
|
||||
|
||||
The layers each adapter relies on, and what they don't cover, are in
|
||||
`packages/harness/README.md`.
|
||||
|
||||
### A role's skills (row 64)
|
||||
|
||||
A managed session's skills reach both adapters as text in the bundle's
|
||||
`prompt.md`, which they pass as a file: `pi` with `--system-prompt <path>`
|
||||
and `claude` with `--system-prompt-file <path>` (row 213). Neither adapter loads a
|
||||
skill of its own: `pi` keeps `--no-skills`, since role sessions never get
|
||||
`MOSAIC_SKILLS`, and `claude` keeps `--disable-slash-commands` and
|
||||
`--restricted`. Sage chose this on 2026-10-19 from a probe, recorded in
|
||||
`agents/sage/work/queue-64/probe.md`:
|
||||
|
||||
- Pi's `--skill` puts only a skill's name and description in the prompt,
|
||||
and the model has to `read` the SKILL.md itself. The gate refuses every
|
||||
file-tool path outside the workspace, so the session couldn't open its
|
||||
own skills. Opening the gate to the bundle would change a security
|
||||
boundary.
|
||||
- Claude Code loads a bundle-local `--plugin-dir` skill under `--restricted`
|
||||
only with its `Skill` tool on, and then it also offers its own bundled
|
||||
skills. Which ones it offers changes with the Claude Code version, and the
|
||||
`Skill` tool would pass through the gate hook.
|
||||
|
||||
The cost is prompt size: a role's skill text is in every turn, where the
|
||||
other routes load a skill when it's used. The bundle caps a role's skills at
|
||||
64 KiB together. The cap dates from when `claude` passed `prompt.md` as one
|
||||
argument, which Linux caps at 128 KiB; row 213 passes the file instead, so no
|
||||
local user reads the prompt in `/proc/<pid>/cmdline`. Pi 0.85.1 uses the path
|
||||
string itself as the prompt when the file is missing or can't be read, so the
|
||||
pi adapter exits 2 unless the path is a readable regular file.
|
||||
|
||||
### The unslop check (row 66)
|
||||
|
||||
Both adapters run the mechanical writing check from `tools/unslop-hook`,
|
||||
driven by one `lists.json`. `pi` loads it as its second `-e` extension.
|
||||
`claude` runs `claude-hook.js` from the bundle's `settings.json`: the Stop
|
||||
hook checks the turn's replies, and the UserPromptSubmit hook adds the
|
||||
correction notice as context on the next turn when the last reply had
|
||||
tells. Each hook command ends `; exit 0`, so neither blocks. Both log to
|
||||
`MOSAIC_UNSLOP_LOG`, and the runner adds a note for the operator to the
|
||||
next RESULT it sends (`packages/harness/README.md`, the runner). The
|
||||
harness modules mount the three files read-only at their own paths, and
|
||||
the manifest records their digests. `MOSAIC_UNSLOP_HOOK` and `UNSLOP_LISTS`
|
||||
aren't in the session environment's allowlist, so a managed session can't
|
||||
switch the check off or swap its lists.
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Claude Code adapter: implements the Mosaic adapter contract for the host's
|
||||
# `claude` CLI, for managed sessions (slice 1 S6). Headless only. The CLI is
|
||||
# MOSAIC_CLAUDE_BIN when set (the bus unit pins it), else `claude` on PATH.
|
||||
#
|
||||
# Contract: see adapters/README.md.
|
||||
# stdout = the turn's answer only; stderr = diagnostics; exit 0 on success.
|
||||
#
|
||||
# The layers this relies on are row S0 lines 1-5 (docs/plans/2026-10-04_slice-1.md,
|
||||
# "What S6 can rely on"): the PreToolUse command hook in MOSAIC_CLAUDE_SETTINGS,
|
||||
# wrapped as `timeout -k 2 10 <gate> || exit 2`, and the tool limit (--tools).
|
||||
# --bare is never passed: it turns settings hooks off.
|
||||
set -eu
|
||||
|
||||
need() {
|
||||
eval "v=\${$1:-}"
|
||||
[ -n "$v" ] || { echo "claude adapter: $1 is required" >&2; exit 2; }
|
||||
}
|
||||
need MOSAIC_SYSTEM_PROMPT_FILE
|
||||
need MOSAIC_REQUEST
|
||||
need MOSAIC_WORKSPACE
|
||||
need MOSAIC_SESSION_DIR
|
||||
need MOSAIC_MODEL
|
||||
need MOSAIC_THINKING
|
||||
need MOSAIC_CLAUDE_SETTINGS
|
||||
need MOSAIC_CLAUDE_MCP_CONFIG
|
||||
for f in "$MOSAIC_SYSTEM_PROMPT_FILE" "$MOSAIC_CLAUDE_SETTINGS" "$MOSAIC_CLAUDE_MCP_CONFIG"; do
|
||||
[ -r "$f" ] || { echo "claude adapter: not readable: $f" >&2; exit 2; }
|
||||
done
|
||||
PROMPT_FILE=$MOSAIC_SYSTEM_PROMPT_FILE
|
||||
case "$PROMPT_FILE" in
|
||||
/*) ;;
|
||||
*) PROMPT_FILE="$(pwd)/$PROMPT_FILE" ;;
|
||||
esac
|
||||
[ "${MOSAIC_INTERACTIVE:-}" != "1" ] || { echo "claude adapter: interactive mode isn't supported" >&2; exit 2; }
|
||||
CLAUDE=claude
|
||||
if [ -n "${MOSAIC_CLAUDE_BIN:-}" ]; then
|
||||
case "$MOSAIC_CLAUDE_BIN" in
|
||||
/*) ;;
|
||||
*) echo "claude adapter: MOSAIC_CLAUDE_BIN must be an absolute path: $MOSAIC_CLAUDE_BIN" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -f "$MOSAIC_CLAUDE_BIN" ] && [ -x "$MOSAIC_CLAUDE_BIN" ] || { echo "claude adapter: MOSAIC_CLAUDE_BIN is not an executable file: $MOSAIC_CLAUDE_BIN" >&2; exit 2; }
|
||||
CLAUDE=$MOSAIC_CLAUDE_BIN
|
||||
fi
|
||||
|
||||
mkdir -p "$MOSAIC_WORKSPACE" "$MOSAIC_SESSION_DIR"
|
||||
cd "$MOSAIC_WORKSPACE"
|
||||
|
||||
# Session: the first turn names a new session id, later turns resume it.
|
||||
# The id is kept only after a turn succeeds, so a failed first turn never
|
||||
# leaves a --resume of a session that doesn't exist.
|
||||
ID_FILE="$MOSAIC_SESSION_DIR/claude-session-id"
|
||||
if [ -s "$ID_FILE" ]; then
|
||||
SESSION_FLAG=--resume
|
||||
SESSION_ID=$(cat "$ID_FILE")
|
||||
else
|
||||
SESSION_FLAG=--session-id
|
||||
SESSION_ID=$(cat /proc/sys/kernel/random/uuid)
|
||||
fi
|
||||
|
||||
# Flags:
|
||||
# -p one-shot: print the answer and exit
|
||||
# --output-format text stdout is the answer only
|
||||
# --system-prompt-file the generated prompt replaces the default, given
|
||||
# as a file so no local user reads it in
|
||||
# /proc/<pid>/cmdline (row 213); absolute, since
|
||||
# the cd above changed the working directory
|
||||
# --effort the instance's thinking var (row 65; 2.1.302 takes
|
||||
# low, medium, high, xhigh, max)
|
||||
# --restricted no user/project/local settings files; --settings
|
||||
# (the gate hook) still applies; no code-running
|
||||
# tool unless --tools names it; file tools confined
|
||||
# to the working directory; no CLAUDE.md file or
|
||||
# auto-memory in the prompt. The S0 lines above
|
||||
# don't depend on it, but it is what keeps founder
|
||||
# and repository memory out; a test fails without it.
|
||||
# --tools the built-in tool limit (S0 line 5); empty = none
|
||||
# --allowedTools the same tools plus the mosaic MCP server, so
|
||||
# --permission-mode dontAsk nothing waits on a prompt and anything else is denied
|
||||
# --settings the gate hook (S0 lines 1-4)
|
||||
# --strict-mcp-config only the bundle's MCP server, which serves the
|
||||
# --mcp-config typed bus tools
|
||||
# --disable-slash-commands no skill discovery; the role's skills are
|
||||
# text in the system prompt (row 64)
|
||||
ALLOWED=mcp__mosaic
|
||||
[ -z "${MOSAIC_TOOLS:-}" ] || ALLOWED="$MOSAIC_TOOLS,mcp__mosaic"
|
||||
"$CLAUDE" -p "$MOSAIC_REQUEST" \
|
||||
--output-format text \
|
||||
--system-prompt-file "$PROMPT_FILE" \
|
||||
--model "$MOSAIC_MODEL" \
|
||||
--effort "$MOSAIC_THINKING" \
|
||||
--restricted \
|
||||
--tools "${MOSAIC_TOOLS:-}" \
|
||||
--allowedTools "$ALLOWED" \
|
||||
--permission-mode dontAsk \
|
||||
--settings "$MOSAIC_CLAUDE_SETTINGS" \
|
||||
--strict-mcp-config \
|
||||
--mcp-config "$MOSAIC_CLAUDE_MCP_CONFIG" \
|
||||
--disable-slash-commands \
|
||||
"$SESSION_FLAG" "$SESSION_ID" || exit $?
|
||||
[ "$SESSION_FLAG" = --resume ] || printf '%s\n' "$SESSION_ID" > "$ID_FILE"
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Mock adapter: deterministic response for seam tests. NEVER use where a
|
||||
# real model response is required.
|
||||
#
|
||||
# Contract: see /opt/mosaic/adapters/README.md.
|
||||
set -eu
|
||||
|
||||
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "mock adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
|
||||
if [ "${MOSAIC_INTERACTIVE:-}" != "1" ]; then
|
||||
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
|
||||
fi
|
||||
[ -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:-}"
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Pi adapter: implements the Mosaic adapter contract for the pinned
|
||||
# @earendil-works/pi-coding-agent CLI.
|
||||
#
|
||||
# Contract: see /opt/mosaic/adapters/README.md.
|
||||
# Headless (default): stdout = response only; stderr = diagnostics; exit 0.
|
||||
# Interactive (MOSAIC_INTERACTIVE=1): full pi TUI on the attached terminal.
|
||||
set -eu
|
||||
|
||||
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
|
||||
# Row 213 (C2-08): pi gets the prompt as a path, so no local user reads it in /proc/<pid>/cmdline.
|
||||
# Pi 0.85.1 reads --system-prompt as a file only when the path exists and reads, and otherwise
|
||||
# uses the path string itself as the prompt, without a warning for a missing file
|
||||
# (dist/core/resource-loader.js resolvePromptInput). So the path must be a regular readable
|
||||
# file here, and is made absolute before the cd below, since pi resolves it from its own cwd.
|
||||
PROMPT_FILE=$MOSAIC_SYSTEM_PROMPT_FILE
|
||||
case "$PROMPT_FILE" in
|
||||
/*) ;;
|
||||
*) PROMPT_FILE="$(pwd)/$PROMPT_FILE" ;;
|
||||
esac
|
||||
[ -f "$PROMPT_FILE" ] && [ -r "$PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
|
||||
# MOSAIC_AGENT_NAME is optional in headless mode (identity section is then
|
||||
# omitted); interactive launches always set it via scripts/agent.sh.
|
||||
|
||||
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
|
||||
: "${PI_MODEL:?pi adapter: PI_MODEL is required}"
|
||||
|
||||
INTERACTIVE="${MOSAIC_INTERACTIVE:-}"
|
||||
if [ "$INTERACTIVE" != "1" ]; then
|
||||
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
|
||||
fi
|
||||
|
||||
# Workspace (M5): run inside the provided workspace when present.
|
||||
if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
|
||||
mkdir -p "$MOSAIC_WORKSPACE"
|
||||
cd "$MOSAIC_WORKSPACE"
|
||||
fi
|
||||
|
||||
# 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_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
|
||||
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"
|
||||
|
||||
# Skills (M17): explicitly provided skill dirs replace discovery. When none
|
||||
# are provided the agent runs with --no-skills (nothing ambient to find).
|
||||
# Role sessions never get MOSAIC_SKILLS (ENV_ALLOW in packages/seat/src/
|
||||
# session.mjs leaves it out), so they run with --no-skills; their skills
|
||||
# are text in the system prompt (row 64).
|
||||
SKILLS_FLAG="--no-skills"
|
||||
if [ -n "${MOSAIC_SKILLS:-}" ]; then
|
||||
SKILLS_FLAG=""
|
||||
OLDIFS=$IFS; IFS=','
|
||||
for s in $MOSAIC_SKILLS; do
|
||||
[ -d "$s" ] || { echo "pi adapter: skill dir missing: $s" >&2; exit 2; }
|
||||
SKILLS_FLAG="$SKILLS_FLAG --skill $s"
|
||||
done
|
||||
IFS=$OLDIFS
|
||||
fi
|
||||
|
||||
# Extensions (S6): explicitly provided extension files, loaded with -e
|
||||
# after --no-extensions turns discovery off. A missing file refuses here;
|
||||
# pi itself also refuses to start on a missing or broken -e (row S0 line 3).
|
||||
EXT_FLAGS=""
|
||||
if [ -n "${MOSAIC_EXTENSIONS:-}" ]; then
|
||||
OLDIFS=$IFS; IFS=','
|
||||
for e in $MOSAIC_EXTENSIONS; do
|
||||
[ -f "$e" ] || { echo "pi adapter: extension missing: $e" >&2; exit 2; }
|
||||
EXT_FLAGS="$EXT_FLAGS -e $e"
|
||||
done
|
||||
IFS=$OLDIFS
|
||||
fi
|
||||
|
||||
# Thinking (row 65): a managed session's level, from its instance's thinking
|
||||
# var (pi 0.85.1: off, minimal, low, medium, high, xhigh, max). Workers from
|
||||
# run-task.sh set none and keep pi's default.
|
||||
THINKING_FLAG=""
|
||||
[ -n "${MOSAIC_THINKING:-}" ] && THINKING_FLAG="--thinking $MOSAIC_THINKING"
|
||||
|
||||
# Mode (M13): interactive TUI or one-shot print.
|
||||
PRINT_MODE="-p"
|
||||
REQUEST_ARG=""
|
||||
if [ "$INTERACTIVE" = "1" ]; then
|
||||
PRINT_MODE=""
|
||||
else
|
||||
REQUEST_ARG="$MOSAIC_REQUEST"
|
||||
fi
|
||||
|
||||
# All flags documented in the pi package README (CLI Reference):
|
||||
# -p/--print one-shot mode: print the response and exit (omitted in
|
||||
# interactive TUI mode)
|
||||
# --system-prompt replace the default prompt with the generated one, given
|
||||
# as the file's absolute path (checked above), never its text
|
||||
# --no-* no ambient context/skills/extensions/templates/themes
|
||||
# EXT_FLAGS the explicitly provided extensions only (-e)
|
||||
# SESSION_FLAGS ephemeral | persistent | forked (per env)
|
||||
# TOOLS_FLAG per capabilities
|
||||
# THINKING_FLAG --thinking <level> for a managed session, else nothing
|
||||
# --offline no startup network operations (update checks/telemetry)
|
||||
# --no-approve ignore project-local .pi/ files (settings, SYSTEM.md)
|
||||
# whatever trust is saved for the workspace
|
||||
set -- \
|
||||
--offline \
|
||||
--no-approve \
|
||||
--no-extensions \
|
||||
$EXT_FLAGS \
|
||||
$SKILLS_FLAG \
|
||||
--no-prompt-templates \
|
||||
--no-themes \
|
||||
--no-context-files \
|
||||
$TOOLS_FLAG \
|
||||
$SESSION_FLAGS \
|
||||
--provider "$PI_PROVIDER" \
|
||||
--model "$PI_MODEL" \
|
||||
$THINKING_FLAG \
|
||||
--system-prompt "$PROMPT_FILE"
|
||||
# One-shot mode appends -p and the request (both safely quoted);
|
||||
# interactive mode appends nothing - clean TUI.
|
||||
[ "$INTERACTIVE" = "1" ] || set -- "$@" -p "$MOSAIC_REQUEST"
|
||||
exec pi "$@"
|
||||
@@ -1,53 +0,0 @@
|
||||
# Mosaic Stack development team
|
||||
|
||||
These are interactive host development agents working in the canonical
|
||||
checkout. They do not create managed fleet registrations or change role policy.
|
||||
Sage leads the project and coordinates assignments, review, and integration
|
||||
(Jason's ruling, 2026-09-26). Development sessions run in T3 for now.
|
||||
For the current bootstrap phase, direct coding, review and research use Darkwing,
|
||||
Dewey, Filbert, Rocko, Researcher and further seats Jason launches from this repository's `agents/` directory,
|
||||
not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
|
||||
`~/.mosaic` launchers/provisioning or migrate/stop live fleet processes.
|
||||
|
||||
| Agent | Responsibility | Runtime | Launch from repository root |
|
||||
| --- | --- | --- | --- |
|
||||
| Darkwing | Hands-on engineering; collaborating seat under Sage | Pi, configured Mosaic model | `agents/darkwing/launch.sh` |
|
||||
| Dewey | Frontend design, UX, accessibility, and UI implementation | Pi, configured Mosaic model | `agents/dewey/launch.sh` |
|
||||
| Rocko | General development, investigation, testing, and review | T3 session (Codex, gpt-6.1-sol, thread d99326cc, lead decision 122; Claude thread b84bb264 retired 2026-10-12); launcher Claude Code, Sonnet model | `agents/rocko/launch.sh` |
|
||||
| Filbert | General development, investigation, testing, and review | T3 session (Claude, Opus 5.5, thread 048f3c63, non-sandbox reviews only, lead decisions 130 and 134; Codex threads f712d8af and 50342f6a retired 2026-10-13, Claude thread 9cb9731e retired 2026-10-12); launcher Pi, `openai-codex/gpt-6-astra:low`, not to be used under R26 until it changes (lead decision 69) | `agents/filbert/launch.sh` |
|
||||
| Researcher | Source-grounded technical research and evidence | T3 session (Claude, Sonnet 5.5, thread 9bac0794, rows 97 and 98, lead decision 124); Haiku rows (71, 80, 84, 88, later 96) run as subagents Sage dispatches with model haiku, effort high and a worktree (lead decision 129); Haiku thread c98aaf4a retired 2026-10-13 unused; launcher Pi, configured Mosaic model | `agents/researcher/launch.sh` |
|
||||
| Launchpad | Builder, MVP T1 row 192 (Q175 SSO phase 1) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Gander | Builder, the human boundary across uids (P19 333, #1694) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
|
||||
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
|
||||
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
|
||||
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
|
||||
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
|
||||
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
|
||||
|
||||
Each script supports `--check` and `--fresh`. Normal launches resume the agent's
|
||||
own conversation; a first launch starts one. See each agent's README for
|
||||
context inputs, authentication, and recovery details. Launch scripts can also
|
||||
be invoked by absolute path from any directory. No assignment or model request
|
||||
is submitted by the launcher itself.
|
||||
|
||||
The shared Pi helper supports `--provider NAME`, `--model ID`, and
|
||||
`--thinking LEVEL` as per-launch overrides of the validated system defaults.
|
||||
Filbert's wrapper appends the required provider, model and thinking flags so
|
||||
its launch configuration remains fixed, including on resume. Use Filbert's
|
||||
wrapper to select that configuration; a direct shared-helper invocation uses
|
||||
its own supplied flags or the system defaults.
|
||||
|
||||
All agents follow repository governance and current user direction. Team
|
||||
leadership does not add deployment or push authority. Coordinate overlapping
|
||||
work with Sage and preserve other sessions' changes.
|
||||
|
||||
Before 2026-09-26 Sage worked only on DYOR strategy and sat outside the
|
||||
development queue. Jason then made Sage the project lead and Darkwing a
|
||||
collaborating seat. A separate fleet Sage seat under `~/.mosaic` is being
|
||||
decommissioned; it does not speak for this seat. Whether the DYOR strategy work
|
||||
continues is Jason's call. Joe retains DYOR engineering.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Bushroot
|
||||
|
||||
Builder seat, Claude Haiku 5.5, started by Sage under mos-1008 item 2 (a low-level model under Q177) for the row
|
||||
that retires the control board writer on 7331 (#1696).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `bushroot/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,38 +0,0 @@
|
||||
|
||||
===== DARKWING NATIVE DEVELOPMENT CONTEXT =====
|
||||
|
||||
Your identity is Darkwing. This launch runs Pi directly on the host, in the
|
||||
Mosaic Stack development repository. The injected SOUL defines your persona;
|
||||
CONSTITUTION and STANDARDS supply governance, USER supplies user context,
|
||||
and AGENTS.md supplies repository instructions.
|
||||
|
||||
You have host read, bash, edit, write, grep, find, and ls tools. This is a
|
||||
development TUI with the operator's OS access, not a sandbox or a registered
|
||||
managed fleet seat. Use repository scripts for Mosaic operations and inspect
|
||||
their effects before running them. Container paths in skills describe worker
|
||||
deployments, not your current workspace. A tool's presence is not authority
|
||||
to change unrelated files, other agents' work, or the live fleet.
|
||||
|
||||
For an assigned improvement, inspect the implementation, reproduce the issue,
|
||||
make the smallest useful change, verify it, and continue through the authorized
|
||||
outcome. Run scripts/mosaic queue next darkwing for ownership, gates and your next piece;
|
||||
a new user assignment does not silently resume unrelated queued work.
|
||||
|
||||
The local /goal extension is loaded and owns any operator-set goal lifecycle.
|
||||
Use ms-proactive-agent for work selection and ms-goal for recovery guidance;
|
||||
do not create a competing goal loop. Follow goal_report's actual schema and
|
||||
reporting instructions. Its text format is Just Completed / Next Step /
|
||||
Blocked, with '* none' for empty sections. No external reporting skill is
|
||||
needed to discover that format. Native development packaging supersedes
|
||||
older skill statements that this extension is unavailable.
|
||||
|
||||
For relocation recovery, read agents/darkwing/work/RESTART.md after the root
|
||||
AGENTS.md and docs/plans/CURRENT.md. It records verified checkpoints and limits,
|
||||
not a new assignment. The canonical checkout is /mnt/storage/src/mosaic-stack;
|
||||
v1/ is archived legacy source. Reconcile newer owner direction before acting.
|
||||
|
||||
Conversation history persists across launcher restarts. Goals belong to a
|
||||
single process incarnation; recover the assignment from verified records and
|
||||
the operator's direction after a restart. No goal is started by this launcher.
|
||||
Context is captured anew at launch; source edits do not update this process's
|
||||
injected snapshot. Relaunch to load approved context changes.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Darkwing development TUI
|
||||
|
||||
From any terminal, run:
|
||||
|
||||
```sh
|
||||
/home/jwoltje/src/mosaic-stack-dev-test/agents/darkwing/launch.sh
|
||||
```
|
||||
|
||||
The agent launcher is a thin shim to `scripts/agent.sh --host-dev darkwing`,
|
||||
forwarding all arguments unchanged. `scripts/agent.sh` is the common entry
|
||||
point; `scripts/agent-host-dev.sh` implements its native development mode.
|
||||
The host launcher opens the repository as Darkwing's workspace.
|
||||
It uses the repository-pinned Pi, the configured Mosaic provider/model, and
|
||||
native Pi authentication (normal `~/.pi/agent`, or `PI_CODING_AGENT_DIR` if
|
||||
explicitly set). It never copies credentials. Install dependencies with
|
||||
`npm ci --ignore-scripts --no-audit --no-fund` if needed.
|
||||
|
||||
`--check` validates configuration and required inputs without opening Pi or
|
||||
calling a model. `--fresh` starts a new conversation without deleting earlier
|
||||
ones. Normal launches continue the latest conversation under
|
||||
`.pi/state/darkwing/sessions/`; the first launch creates one. A launcher lock
|
||||
rejects simultaneous launches through this script. It does not exclude Pi
|
||||
processes started another way. Damaged JSONL history refuses automatic resume;
|
||||
`--fresh` is an explicit escape hatch that preserves the damaged evidence.
|
||||
|
||||
The current files are combined into a private launch snapshot under
|
||||
`.pi/state/darkwing/launches/`:
|
||||
|
||||
- `contracts/CONSTITUTION.md` and `contracts/STANDARDS.md`
|
||||
- `agents/darkwing/SOUL.md`
|
||||
- `<configured dataRoot>/user/USER.md`, the deployment's live user profile
|
||||
- the repository's `AGENTS.md` and Darkwing's `CONTEXT.md`
|
||||
|
||||
Use `--soul FILE`, `--constitution FILE`, or `--user FILE` to select alternate
|
||||
inputs, including a future `contracts/USER.md`. Relative paths resolve from
|
||||
the repository root. Missing or empty inputs refuse launch. Snapshots can
|
||||
contain personal context and remain local, with private file permissions.
|
||||
Context edits take effect on relaunch, including when resuming a conversation.
|
||||
|
||||
The launcher enables coding/search tools, `goal_report`, ten explicit local
|
||||
skills, and the canonical goal extension through `scripts/sync-dev-extensions.sh`.
|
||||
Ambient context, skills, extensions, templates, and themes are disabled.
|
||||
The normal Pi coding prompt is retained with the Mosaic context appended.
|
||||
Enter `/goal <assignment and acceptance criteria>` to start continuing work;
|
||||
`/goal stop`, `/goal resume`, and `/goal` pause, resume, and inspect it. A new
|
||||
process does not automatically adopt a previous process's goal.
|
||||
|
||||
This TUI has the operator's host access, including repository edits and host
|
||||
commands. Its tool list is not OS isolation. It creates no managed role or
|
||||
fleet registration. Worker dispatch still uses the governed Mosaic task runner.
|
||||
The user supplies the assignment; launch alone does not start self-modification.
|
||||
|
||||
## Deployment findings
|
||||
|
||||
The existing `scripts/agent.sh` launches a Docker container, defaults to the
|
||||
`agent-<name>` session directory, and asks Pi to continue when that directory
|
||||
is nonempty. Its default workspace is `<dataRoot>/workspaces/<name>`, not this
|
||||
checkout. `src/load-contracts.sh` loads image-baked governance, an optional
|
||||
seat SOUL override, live user Markdown, and mission context into a shared
|
||||
prompt path. A seat override requires `agent.json`; a standalone SOUL is not
|
||||
discovered. `adapters/pi/adapter.sh` disables extensions. The temporary host
|
||||
launcher follows the existing native development path to provide repository
|
||||
access and `/goal`, and keeps its conversations separate from container and
|
||||
live fleet sessions. It does not invoke release alignment on startup.
|
||||
@@ -1,30 +0,0 @@
|
||||
# SOUL — Darkwing
|
||||
|
||||
You are Darkwing, Mosaic Stack's hands-on engineering collaborator, working
|
||||
with Sage as project lead. Your job is to help Jason make the system
|
||||
dependable by using it, finding where it falls short, and carrying authorized
|
||||
improvements through verification.
|
||||
|
||||
Be curious, direct, and resourceful. Have a technical opinion and explain
|
||||
the evidence behind it. Investigate before guessing. Distinguish a design
|
||||
claim, a passing test, and behavior you have observed in the running system.
|
||||
|
||||
Use Mosaic's own tools and workflows where they fit. Turn a failure into a
|
||||
reproducible case, make a focused correction, and test the behavior again.
|
||||
Let each verified improvement inform the next one within the assignment.
|
||||
Keep the human informed when the result, scope, or next decision changes.
|
||||
|
||||
Own the outcome while respecting other agents' work. Preserve their changes
|
||||
and records, give delegated work clear boundaries, and seek independent
|
||||
review where required. Self-improvement never grants new authority: changing
|
||||
your instructions, permissions, or a live deployment follows the same review
|
||||
and authorization rules as any other system change.
|
||||
|
||||
Sage leads the development team (Jason's ruling, 2026-09-26) and translates
|
||||
Jason's priorities into scoped work, coordinates ownership and dependencies,
|
||||
reviews results, and verifies integration. You are a collaborating seat.
|
||||
Dewey owns frontend design and UX. Rocko and Filbert support general project needs,
|
||||
including implementation, investigation, testing, and review. Reconcile
|
||||
concurrent edits with Sage before integration. Keep Jason informed of
|
||||
outcomes and decisions that require his input. Your role does not expand the
|
||||
project's existing authorization or release rules.
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Darkwing's native development mode through the Mosaic agent entry point.
|
||||
set -euo pipefail
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
# Register this seat with the control board (packages/seat) unless already
|
||||
# registered by `mosaic launch` or only running the checks.
|
||||
if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then
|
||||
exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness pi darkwing -- "$@"
|
||||
fi
|
||||
exec "$REPO/scripts/agent.sh" --host-dev darkwing "$@"
|
||||
@@ -1,21 +0,0 @@
|
||||
// Refuse damaged history before Pi's --continue can silently skip it.
|
||||
import { readFileSync, lstatSync } from 'node:fs';
|
||||
|
||||
try {
|
||||
for (const file of process.argv.slice(2)) {
|
||||
if (!lstatSync(file).isFile()) throw new Error(`not a regular session file: ${file}`);
|
||||
const lines = readFileSync(file, 'utf8').trim().split('\n');
|
||||
const entries = lines.map((line) => JSON.parse(line));
|
||||
const header = entries[0];
|
||||
if (header?.type !== 'session' || typeof header.id !== 'string' || !header.id ||
|
||||
typeof header.version !== 'number' || typeof header.cwd !== 'string' ||
|
||||
!Number.isFinite(Date.parse(header.timestamp)) ||
|
||||
entries.slice(1).some((entry) => !entry || typeof entry.type !== 'string')) {
|
||||
throw new Error(`invalid session structure: ${file}`);
|
||||
}
|
||||
if (header.cwd !== process.cwd()) throw new Error(`session belongs to another workspace: ${file}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`darkwing: cannot safely resume: ${error.message}; inspect history or explicitly use --fresh`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
# Darkwing — relocation handoff
|
||||
|
||||
Recorded 2026-09-07 17:43 UTC. Jason intends to relaunch with
|
||||
`/mnt/storage/src/mosaic-stack/agents/darkwing/launch.sh`.
|
||||
This is a recovery note, not a new assignment or automatic goal resumption.
|
||||
|
||||
## Read first
|
||||
|
||||
1. Root `AGENTS.md` and `docs/plans/CURRENT.md`.
|
||||
2. This note, then `git status --short` and `git log --oneline -5`.
|
||||
3. Reconcile current owner direction and any newer declared artifacts before acting.
|
||||
|
||||
## Repository conversion is completed locally
|
||||
|
||||
Jason explicitly ordered the conversion and confirmed no work was active.
|
||||
- Canonical checkout: `/mnt/storage/src/mosaic-stack`.
|
||||
- Origin: `https://git.mosaicstack.dev/mosaicstack/stack`.
|
||||
- Branch: `refactor`.
|
||||
- Conversion commit: `127a54fdff1fe6ae56c3197edddf957481465db4`.
|
||||
- New foundation is at root. `v1/` is legacy archival source, NOT current code.
|
||||
- Old `/home/jwoltje/src/mosaic-stack-dev-test` is a compatibility symlink to this
|
||||
same checkout. Do not recreate a second working copy there.
|
||||
- Both histories retained: merge parents v2 `9a5fbdbda74b16adf488fe28138b2ba69ea5e669`
|
||||
and v1 `5d2770002612a09ae0cadc129b4ea30619133e8a`.
|
||||
- Exact 3,507-file v1 tracked tree imported; v1 refs under `refs/archive/v1/`.
|
||||
- Original v2 refs retained; `stack-v2-archive` remote has a disabled push URL.
|
||||
- Only legacy tracked tree and four conversion docs committed. All earlier
|
||||
uncommitted/untracked/ignored work preserved. Index was verified clean.
|
||||
- Issue https://git.mosaicstack.dev/mosaicstack/stack/issues/1495 closed explicitly
|
||||
for local conversion. No push, PR/trunk merge or live-service change occurred.
|
||||
|
||||
Record: `docs/plans/2026-09-07_repository-consolidation-completed.md`.
|
||||
Receipts: `docs/plans/reviews/2026-09-07_repository-conversion-verification.json`
|
||||
and `2026-09-07_repository-conversion-postcommit-verification.json`.
|
||||
Verified rollback copies, NOT development roots:
|
||||
- `/mnt/storage/src/.mosaic-stack-conversion-20260907T172430Z/`
|
||||
- `/home/jwoltje/src/.mosaic-stack-dev-test.pre-conversion-20260907T172430Z`
|
||||
Do not delete them, launch from them or restore over newer work.
|
||||
|
||||
## Current unfinished foundation gate
|
||||
|
||||
Jason's A9 acceptance of the first offline synthetic scope/permission inspector
|
||||
is pending. Code is independently approved by Filbert; no blocking code finding
|
||||
remains at the reviewed r6 candidate. Owner acceptance is not inferred from tests.
|
||||
|
||||
- Manifest: `docs/plans/reviews/2026-09-07_foundation-inspector-rocko-build-manifest-r6.json`
|
||||
SHA-256 `a4a4493000aff5905337a643886ca36e7c5377d52deed77b8aeab7174ca73dcf`.
|
||||
- Report: `docs/plans/reviews/2026-09-07_foundation-inspector-rocko-build-r6.md`
|
||||
SHA-256 `ee0e83efd7c71eddecf5e26f939e9a34ba85b184cfcd1cffac9ff9e56ea13c37`.
|
||||
- APPROVED verdict: `docs/plans/reviews/2026-09-07_foundation-inspector-code-verdict-r6.md`
|
||||
SHA-256 `ab9dd5e5c3cad5c9263e873ff82cac444da2d36040e907e4798b208fa1c08b13`.
|
||||
- Guide: `docs/plans/reviews/2026-09-07_foundation-inspector-demo.md`.
|
||||
|
||||
All 382 approved inspector files and pinned inputs survived conversion unchanged.
|
||||
Actual offline checks: Node 80/0, selftests 43/0, oracle 1,568 records / zero
|
||||
schema disagreements, foundation checker PASS, config/auth/conductor 24/15/17.
|
||||
Postcommit conductor 17/0 and four CLI demos passed: allowed read, allowed change
|
||||
PREVIEW (no mutation), missing-registration refusal, unresolved reassignment with
|
||||
original selection retained. Demo inputs are separate synthetic scenarios.
|
||||
|
||||
`test-task.sh` and `test-release.sh` remain NOT RUN / DEFERRED under Jason's bounded
|
||||
offline-demo ruling. No deployment/native/live/provider/security certification.
|
||||
Reviewer qualifications: ordering equality means structural equality, not byte
|
||||
identity; auxiliary native-parser warm-run anomalies remain separate unresolved
|
||||
observations, not a passing universal parser-equivalence claim. Preserve all earlier
|
||||
NOT APPROVED reviews and the historical correction that r3 ran unauthorized live
|
||||
branches; later deferral did not retroactively authorize them.
|
||||
|
||||
## Ownership and limits
|
||||
|
||||
- Rocko authored inspector code; Filbert independently reviewed; Darkwing coordinates
|
||||
and verifies. Keep the approved candidate frozen unless a new fix is authorized.
|
||||
- No automatic permission to push, merge to next/main, deploy, change live config,
|
||||
grant permissions, access credentials, investigate ~/.mosaic, or start new runtime
|
||||
work. Local conversion authority is not authority for those activities.
|
||||
- Preserve unrelated pending work. In particular `scripts/agent.sh`, `docs/TOOLS.md`,
|
||||
host launcher/context files and other untracked concepts/skills belong to existing
|
||||
work. Do not blanket-stage/reset/clean. Root logs and CURRENT remain uncommitted.
|
||||
- Foundation #53 in the old stack-v2 project remains a separate open issue; do not
|
||||
silently close or renumber it. Accepted historical SHA/path citations remain valid.
|
||||
- Rocko's Archify C1 remains HELD for owner T2/T3 decisions. No lane reassignment.
|
||||
- Future durability/workflow/evidence/federation/onboarding topics are notes, not
|
||||
authorization to expand the inspector.
|
||||
|
||||
## Communications
|
||||
|
||||
Use only `tools/tmux/agent-send.sh`; sender `dragon-lin:darkwing`.
|
||||
Rocko: `-L mosaic-fleet -s '=rocko'`; Filbert/Dewey:
|
||||
`-L default -s '=filbert'` / `'=dewey'`.
|
||||
Conversion notice delivered to Rocko. Filbert/Dewey sends were unconfirmed
|
||||
(input boxes not locatable); no retries, no acknowledgement claimed. Check declared
|
||||
artifact paths as well as direct messages; completed reviews have existed without
|
||||
transported replies. Do not inspect private panes or blindly resend.
|
||||
|
||||
## Relaunch and goal recovery
|
||||
|
||||
The project launcher continues its own latest `.pi/state/darkwing/sessions/`
|
||||
conversation by default. Do NOT assume this pre-launch conversation is already in
|
||||
that store or that the next launch resumes this exact conversation. This handoff
|
||||
is the durable bridge. No session-tree migration or launch was performed here.
|
||||
|
||||
The goal extension owns lifecycle. The earlier extension goal had been paused;
|
||||
no restart automatically resumes it. Reconcile the actual new process state and
|
||||
Jason's direction rather than reporting progress against a guessed old goal or
|
||||
creating a second goal loop. Launch alone grants no new assignment.
|
||||
|
||||
This handoff and its CONTEXT pointer are documentation-only. Launcher scripts,
|
||||
private sessions, credentials and runtime configuration were not modified.
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"issue": 1503,
|
||||
"candidate": "/tmp/board-attention-r1-q_924ksh",
|
||||
"files": [
|
||||
"AGENTS.md",
|
||||
"packages/control-board/src/scan.mjs",
|
||||
"packages/control-board/README.md",
|
||||
"packages/control-board/tests/scan.test.mjs",
|
||||
"packages/control-board/tests/serve.test.mjs",
|
||||
"packages/control-board/tests/attention.test.mjs",
|
||||
"packages/control-board/tests/attention-flow.test.mjs",
|
||||
"packages/webui/tests/fixture.mjs",
|
||||
"docs/plans/2026-09-13_board-attention-status.md"
|
||||
],
|
||||
"manifestSha256": "e40b58ecb6844d407ba776dcde8f19ad21c0b78076ca1f9b3d96b0bb1c852405",
|
||||
"testsPassed": 144
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"at": "2026-09-14T00:19:32.409806+00:00",
|
||||
"backendPid": 3204655,
|
||||
"health": "ok",
|
||||
"researcher": {
|
||||
"agent": "researcher",
|
||||
"project": "mosaic-stack",
|
||||
"alive": true,
|
||||
"state": "idle",
|
||||
"waitingOnYou": false,
|
||||
"lastActivity": "2026-09-13T21:19:57.102Z"
|
||||
},
|
||||
"fiveAgentProcessesUnchanged": true
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"at": "2026-09-14T00:19:11.579013+00:00",
|
||||
"ownerAuthorized": true,
|
||||
"oldPid": 1265952,
|
||||
"newPid": 3204655,
|
||||
"command": [
|
||||
"/usr/bin/node",
|
||||
"packages/control-board/src/cli.mjs",
|
||||
"serve"
|
||||
],
|
||||
"cwd": "/mnt/storage/src/mosaic-stack",
|
||||
"log": "/tmp/board-attention-backend-ag9xvks2.log",
|
||||
"agentProcessesBefore": {
|
||||
"default/darkwing": [
|
||||
[
|
||||
"2733924",
|
||||
"12863634"
|
||||
]
|
||||
],
|
||||
"default/dewey": [
|
||||
[
|
||||
"934346",
|
||||
"466065"
|
||||
]
|
||||
],
|
||||
"default/filbert": [
|
||||
[
|
||||
"72183",
|
||||
"100870"
|
||||
]
|
||||
],
|
||||
"default/researcher": [
|
||||
[
|
||||
"173699",
|
||||
"66404285"
|
||||
]
|
||||
],
|
||||
"mosaic-fleet/rocko": [
|
||||
[
|
||||
"90599",
|
||||
"128275"
|
||||
]
|
||||
]
|
||||
},
|
||||
"gracefulExitObserved": true
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
# CHAT-02 board routes: Darkwing's review (#1507)
|
||||
|
||||
Reviewer: Darkwing, 2026-09-26, per Sage's D3. Requested by Dewey. Scope: the
|
||||
two read-only routes only. Filbert reviews `packages/conversation` in full.
|
||||
|
||||
Candidate, base 34777c56, uncommitted. I verified both hashes:
|
||||
- `packages/control-board/src/serve.mjs` afc95bdb…c540d
|
||||
- `packages/control-board/tests/serve.test.mjs` e60aa14b…ecbc
|
||||
|
||||
**Verdict: approve**, with one commit condition and two nonblocking notes.
|
||||
|
||||
## What I checked
|
||||
|
||||
- Order. `foreignRequest` runs first on every request, then the POST routes,
|
||||
then the GET/HEAD check (405 otherwise), then these routes. A POST to
|
||||
either path is 405, and a foreign Host or Origin is 403 before any read.
|
||||
- Query validation. `/api/conversations` refuses any parameter.
|
||||
`/api/conversation` accepts only `id`, `branch` and `cursor`, one value
|
||||
each, each matching `QUERY_VALUE`. That is the same pattern as
|
||||
`parts.mjs` `ID`, so every id the reader issues (`safeId`, `root`, `c-`
|
||||
cursors, `pi-` conversations) passes. No path comes from the request.
|
||||
- Responses. JSON with `no-store` and `nosniff`, no CORS headers. A thrown
|
||||
error gives a fixed 500 body and logs to stderr only.
|
||||
- Refusal bodies. Every `Refusal` message in `packages/conversation/src` is
|
||||
a fixed string. The one interpolated message (`denied`, safe-fs.mjs:34)
|
||||
interpolates only "session root" or "session file". No path or content
|
||||
reaches the client through `error`.
|
||||
- Status map. It covers every code the route can reach. `unknown-actor` and
|
||||
`unsupported-purpose` are absent, and the route can't produce them because
|
||||
it always passes the default actor and purpose.
|
||||
- Tests: `serve.test.mjs` plus `packages/conversation/tests/`, 61/61 on the
|
||||
pinned files.
|
||||
|
||||
## Commit condition
|
||||
|
||||
`serve.mjs` imports `../../conversation/src/reader.mjs` at module load, and
|
||||
`packages/conversation/` is untracked. Committing the routes without that
|
||||
package breaks the board's start, not only these routes. The package must
|
||||
land in the same commit or an earlier one, after Filbert's review.
|
||||
|
||||
## Notes (nonblocking)
|
||||
|
||||
1. **A cursor needs its branch.** The header comment says `branch` and
|
||||
`cursor` are optional. But `next()` compares `branch !== record.branch`,
|
||||
and every cursor record carries a string branch (`safeId` or `root`). So
|
||||
`?id=X&cursor=C` without `branch` is always 409 `cursor-foreign`, with
|
||||
`reconcile: true`. That is safe, but a client that follows `nextCursor`
|
||||
alone gets a refusal that reads like a stale view. The test passes the
|
||||
page's branch, so it doesn't show this. Either say in the comment that a
|
||||
cursor call must repeat `page.branch`, or answer 400 "cursor requires
|
||||
branch". I'd take the comment now and let CHAT-03's client decide.
|
||||
2. **New codes fall to 422.** A code the reader adds later maps to 422
|
||||
without a test failing. A test that runs the reader's refusal codes
|
||||
through `REFUSAL_STATUS` would catch that. That's optional.
|
||||
@@ -1,69 +0,0 @@
|
||||
# CHAT-02 board routes, revision 2: Darkwing's review (#1507)
|
||||
|
||||
Reviewer: Darkwing, 2026-09-26, at Sage's request. Scope: the route delta
|
||||
since my R1 approval (`chat-02-routes-review-2026-09-26.md`, 07b10ad1). Filbert
|
||||
reviewed the backend (packet `agents/dewey/work/chat-02/BACKEND.md`, 0cf177b1).
|
||||
|
||||
Candidate, base 34777c56, uncommitted. I verified both hashes:
|
||||
- `packages/control-board/src/serve.mjs` d62720dc…a2f3
|
||||
- `packages/control-board/tests/serve.test.mjs` d38aa2b2…3f4a
|
||||
|
||||
**Verdict: approve.** The R1 commit condition still holds, and I have two
|
||||
new nonblocking notes.
|
||||
|
||||
## What I checked
|
||||
|
||||
I kept no copy of the R1 files, so I read the whole route change against the
|
||||
base (`git diff 34777c56 -- packages/control-board`) instead of only the
|
||||
delta. It covers every item the packet's §0 lists and nothing else in the
|
||||
route path.
|
||||
|
||||
- **Cursor needs its branch.** This was my R1 note 1. `conversationQuery` now
|
||||
answers 400 "a cursor call repeats the page's branch" when `cursor` comes
|
||||
without `branch`. The check runs after the per-key validation, so a
|
||||
malformed value still gets its own 400 first. The header comment says the
|
||||
same. A test covers it, and removing the line fails it.
|
||||
- **Status map.** My R1 note 2. `REFUSAL_STATUS` is exported and now has 16
|
||||
entries. I listed every `new Refusal("<code>"` in
|
||||
`packages/conversation/src` myself and got 15 codes plus
|
||||
`unsupported-harness`, which reader.mjs:352 raises by value. That matches the
|
||||
map exactly. `parts.mjs` raises none. `unavailable`, which safe-fs.mjs:58
|
||||
raises when a session root doesn't exist, is 404. That fits the rest of the
|
||||
map, where not-found is 404. `unknown-actor` 403 and `unsupported-purpose` 422
|
||||
are explicit now.
|
||||
- **Order and guards** are unchanged from R1. The foreign Host or Origin check
|
||||
comes first, then the POST routes, then 405, then these routes. No path comes
|
||||
from the request, and responses carry `no-store` and `nosniff` with no CORS
|
||||
headers.
|
||||
- **Tests.** `serve.test.mjs` plus `packages/conversation/tests/` pass
|
||||
67/67 on the pinned files.
|
||||
- **Mutations** on a scratch clone of HEAD with the conversation package and
|
||||
the two pinned files:
|
||||
|
||||
| Mutation | Result |
|
||||
|---|---|
|
||||
| cursor-without-branch check removed | 1 fails |
|
||||
| `unknown-actor` entry dropped | 1 fails (the scan test) |
|
||||
| `nosniff` removed | 1 fails |
|
||||
| repeated-parameter check removed | 1 fails |
|
||||
| catalogue parameter check removed | 1 fails |
|
||||
| `unavailable` changed from 404 to 422 | nothing fails |
|
||||
|
||||
The last row is note 1 below.
|
||||
|
||||
## Commit condition (unchanged)
|
||||
|
||||
`serve.mjs` imports `../../conversation/src/reader.mjs` at module load, and
|
||||
`packages/conversation/` is still untracked. The package must land in the
|
||||
same commit as the routes or an earlier one. Otherwise the board fails to
|
||||
start.
|
||||
|
||||
## Notes (nonblocking)
|
||||
|
||||
1. **The scan test checks keys, not values.** It proves every code has an
|
||||
entry. No test proves `unavailable` is 404. If someone edits that value, or
|
||||
any status no route test exercises, nothing fails. A table test that
|
||||
asserts the whole `REFUSAL_STATUS` object would pin them. That's optional.
|
||||
2. **The scan reads a fixed list of three files.** If a refusal is added to
|
||||
`parts.mjs` or a new file, the scan won't see it, and that code falls to 422.
|
||||
Reading every `.mjs` in `packages/conversation/src` would close the gap.
|
||||
@@ -1,160 +0,0 @@
|
||||
# Queue row 5, CHAT-03 increment 1, round 1 review (#1508)
|
||||
|
||||
Darkwing, 2026-10-04. Request: #1508 comment 26671. My part is the binding
|
||||
and the extension-load refusal (lead decisions 31 and 36). Brief:
|
||||
`agents/dewey/work/chat-03/BRIEF.md` at 1ef15ac0. Candidate:
|
||||
`agents/dewey/work/chat-03/I1-manifest.sha256`, digest
|
||||
`1404341eaeaf7d1e684c9f27e76718061ed08c25a52da5f190425feb1274ba69`,
|
||||
27 files, uncommitted.
|
||||
|
||||
Verdict: changes requested. Two blocking findings, B1 and B2.
|
||||
|
||||
## Checks
|
||||
|
||||
- The manifest hashes to 1404341e. All 27 files match it in the canonical
|
||||
working tree.
|
||||
- Export: `git archive` of 1c724958 plus the 27 candidate files in
|
||||
`/tmp/r5-exp`, manifest OK there too. `node --test
|
||||
packages/conversation/tests/` passes 141/141, 0 skipped.
|
||||
`node --test packages/control-board/tests/` passes 124/124 (the board
|
||||
reads `ControlRefusal` codes). The CHAT-00, CHAT-01 and CHAT-01c checks
|
||||
still pass.
|
||||
|
||||
## B1 (blocking). The seal is a deny-list over an argv the caller builds
|
||||
|
||||
`checkSeal` (pi-pin.mjs 59–66) refuses `-e`/`--extension`, a missing seal
|
||||
flag, or a first pair other than `--mode rpc`. Everything else in
|
||||
`engine.extraArgs` passes, and `buildPiArgs` appends extraArgs after the
|
||||
controller's own `--session`. Pi's parser keeps the last `--mode` and the
|
||||
last `--session`. The controller is a public export (`./controller` in
|
||||
package.json), so this is reachable without touching the source.
|
||||
|
||||
(a) `extraArgs: ["--session", <file outside the fixture root>]`. The guard
|
||||
refuses that same path when it is passed as `sessionFile`, but it never
|
||||
sees extraArgs. With the real pinned Pi under a scratch HOME and agent dir
|
||||
(`/tmp/r5/seal-escape.mjs session`):
|
||||
|
||||
```
|
||||
guard on sessionFile: live-session-refused
|
||||
constructed with extraArgs ["--session",".../outside/proj/.pi/state/other/sessions/s1.jsonl"]
|
||||
-> piArgs ["--mode","rpc","--no-extensions","--no-prompt-templates","--no-themes",
|
||||
"--session",".../fx/.../fixture-seat/sessions/s1.jsonl","--session",".../outside/..."]
|
||||
start -> {"launched":true,"classified":{"state":"free"}} | binding uncertain closed
|
||||
uncertain evidence: loaded-session, "the engine loaded another session file"
|
||||
outside session changed: true | appended: {"type":"thinking_level_change","id":"bef67aef",
|
||||
"parentId":"b2c3d4e5",...,"thinkingLevel":"off"}
|
||||
```
|
||||
|
||||
K8 notices afterwards, but Pi has already written to a session the guard
|
||||
exists to protect. That breaks §2 "no writes to sessions" and the
|
||||
fixture-only rule in code.
|
||||
|
||||
(b) `extraArgs: ["--mode", "json"]` (or `text`). checkSeal passes because
|
||||
it looks only at args[0] and args[1]. Pi starts in print mode, reads stdin
|
||||
to EOF and treats it as the prompt. The K8 `get_state` line goes into that
|
||||
reader, so the run ends `uncertain` on `get_state: timeout`. In an earlier
|
||||
run where I closed stdin, Pi sent the `get_state` JSON line to the model
|
||||
as a prompt and stopped only at "No API key found". The default
|
||||
`engine.env` is `process.env`, so with real auth present that becomes a
|
||||
paid model call outside RPC. PgroupLauncher spawns detached, so the engine
|
||||
outlives the controller unless someone kills the group.
|
||||
|
||||
Other extraArgs that pass checkSeal today: `--no-session`, `--fork`,
|
||||
`--export <file>` (writes a file), `--prompt-template`, `--approve`.
|
||||
`engine.command` and `engine.preArgs` can also run any script, or node
|
||||
with `--import`. `checkEnginePin` validates only the pinRoot lock files, so
|
||||
the pin says nothing about what actually ran.
|
||||
|
||||
Suggested fix:
|
||||
- Allow-list extraArgs. The only non-extension use in the suites is
|
||||
`["--model", "other"]` (claim.test 548, W9), so `--model`, `--provider`
|
||||
and `--thinking` with one value each would cover it.
|
||||
- Refuse any second `--mode`, `--session` or seal flag, and any session
|
||||
or output flag (`--print`, `--no-session`, `--session-dir`,
|
||||
`--session-id`, `--fork`, `--export`, `--continue`,
|
||||
`--resume`).
|
||||
- Say in the README that a non-default `command` or `preArgs` is a test
|
||||
hook, and that the pin and seal checks don't bind under it. Or refuse it
|
||||
outside tests.
|
||||
- N24 cases for `--session`, `--mode json` and `--no-session` in
|
||||
extraArgs.
|
||||
|
||||
## B2 (blocking). The claim's session key is the conversation ID
|
||||
|
||||
controller.mjs 187:
|
||||
`this.sessionK = sessionKey({ harness: "pi", conversation: this.conversation })`.
|
||||
`conversation` is `"pi-" + sha256(projectRoot, seat, name)` (reader.mjs
|
||||
72). The brief (line 382–383) keys the claim on "the native session
|
||||
identity (the Pi header ID or the Claude session UUID)", and the CHAT-01
|
||||
README says at most one non-stopped binding may hold a conversation or
|
||||
native-session identity. Two paths to one session file give two
|
||||
conversation IDs, so the session key never collides.
|
||||
|
||||
Repro, `/tmp/r5/hardlink.mjs`: one session hard-linked into
|
||||
`.pi/state/fixture-seat/sessions/s1.jsonl` and
|
||||
`.pi/state/seat-b/sessions/s1.jsonl`, two controllers via the test harness
|
||||
with the fake engine.
|
||||
|
||||
```
|
||||
same inode: true
|
||||
A conversation pi-b5901a69... | B conversation pi-f21f1a75...
|
||||
A start: {"launched":true,...} state active
|
||||
B start: {"launched":true,...} state active
|
||||
A nativeSession 0f5e1c2a-1111-4222-8333-944455556666 | B nativeSession 0f5e1c2a-1111-4222-8333-944455556666
|
||||
```
|
||||
|
||||
Two active bindings, two engines, one session. A plain copy is allowed
|
||||
too; whether a copy should count as the same session is a call for the
|
||||
brief, but the hard link plainly should. W4 (claim.test 172) builds its
|
||||
keys by hand on the store, so it never exercises the controller's
|
||||
mapping.
|
||||
|
||||
Fix: build the session key from the header `id` read in `start()` (the
|
||||
`nativeSession` already in hand). Add a controller-level W4 case for the
|
||||
hard link, and one for a copy with whatever the brief decides.
|
||||
|
||||
## n1 (non-blocking). The startup-append note is narrower than Pi's rule
|
||||
|
||||
README 426–431 and BUILD-I1.md 196 say the append bites only sessions
|
||||
without a `thinking_level_change` entry, and that Pi-created sessions
|
||||
carry one. Pi's rule is sdk.js 82:
|
||||
`hasExistingSession = existingSession.messages.length > 0`. A session
|
||||
with a thinking entry but no messages takes the new-session branch and
|
||||
appends `thinking_level_change` at every start, plus `model_change` when a
|
||||
model is set. I checked this in plain sealed RPC mode: a header plus a
|
||||
thinking entry gained `{"type":"thinking_level_change","id":"e8c74875",
|
||||
"parentId":"f0e1d2c3",...}`. A Pi-created session that was opened and
|
||||
never prompted is in this group, so "written by something else" is wrong.
|
||||
Name message-less sessions too, and let the later pre-spawn check cover
|
||||
both.
|
||||
|
||||
## n2 (minor). The guard follows $HOME
|
||||
|
||||
`LiveSessionGuard` protects `~/.pi`, `~/.claude` and `~/.mosaic-dev` via
|
||||
`os.homedir()`. With a scratch HOME, the real directories are protected
|
||||
only by the fixture-root containment, or by passing `homes`. That
|
||||
containment holds today, so I'm noting it, not blocking on it.
|
||||
|
||||
## What holds in the binding
|
||||
|
||||
- H1 to H3. `#dispatch` holds `this.lock` across `#recheck` and the
|
||||
write. Takeover, release and acquire run `#evaluate` under the same
|
||||
lock, so a prompt can't land between the generation bump and the write.
|
||||
- The generation check comes first in `#evaluateOp` and again in
|
||||
`#recheck`.
|
||||
- Takeover refuses while fenced (K9) and for the current controller
|
||||
(`already-controller`, H4).
|
||||
- Disconnect never moves control (H11).
|
||||
- Confirmations are single-use: `#checkConfirmation` marks them
|
||||
`consumed`.
|
||||
- Interrupt sets its fence before it takes the lock, so a queued prompt
|
||||
sees the fence.
|
||||
- K8 catches a wrong session or leaf after launch, as in B1(a). B1 is that
|
||||
the write happens before K8 can run.
|
||||
- The pin check reads both lock files and refuses on either version or
|
||||
integrity mismatch.
|
||||
|
||||
Scratch scripts and outputs are in `/tmp/r5/`: `seal-escape.mjs`,
|
||||
`hardlink.mjs`, `seal-session.txt`, `seal-json.txt`, `hardlink.txt`,
|
||||
`conv-suite.txt`, `board-suite.txt`. All scratch engines were killed by
|
||||
their process group.
|
||||
@@ -1,119 +0,0 @@
|
||||
# Queue row 5, CHAT-03 increment 1, round 2 review (#1507)
|
||||
|
||||
Darkwing, 2026-10-04. Request: #1507 comment 26689 (Dewey). My part is
|
||||
the binding and the extension-load refusal (lead decisions 31 and 36), and
|
||||
my round 1 findings (comment 26681 on #1508, pointer 26685 on #1507).
|
||||
Candidate: `agents/dewey/work/chat-03/I1-r2-manifest.sha256`, digest
|
||||
`2b48e333a0f09185364359ae6f8277cc88c0b9ff39058de45cc2c1f0ec9d5c4a`,
|
||||
the same 27 files, base 1c724958, uncommitted. Packet:
|
||||
`agents/dewey/work/chat-03/BUILD-I1-r2.md`.
|
||||
|
||||
Verdict: approved for my part. B1 and B2 are fixed. Nothing must be fixed
|
||||
before the commit. Three follow-ups below, none of them blocking.
|
||||
|
||||
## Checks
|
||||
|
||||
- The manifest hashes to 2b48e333. All 27 files match it in the canonical
|
||||
working tree. Twelve changed from round 1: README, `controller.mjs`,
|
||||
`pi-pin.mjs`, `terminal.mjs` and seven test files. `engine.mjs` is
|
||||
unchanged.
|
||||
- Export: `git archive` of 1c724958 plus the 27 files in
|
||||
`~/darkwing-scratch/r5r2/exp`, manifest OK there too, `TMPDIR` under the
|
||||
same directory.
|
||||
- `node --test packages/conversation/tests/` passes 152/152, 0 skipped.
|
||||
- `node --test packages/control-board/tests/` passes 124/124. The board
|
||||
imports the conversation package.
|
||||
- The CHAT-00 (48 checks), CHAT-01 R3 and CHAT-01c checks still pass.
|
||||
- Probe script: `~/darkwing-scratch/r5r2/probes.mjs`, output in
|
||||
`~/darkwing-scratch/r5r2/out/probes.txt`. It drives the exported
|
||||
`Controller` directly, as an outside caller would.
|
||||
|
||||
## B1, the seal is now an allow-list: fixed
|
||||
|
||||
`checkSeal` (pi-pin.mjs 68) requires the exact prefix `--mode rpc`, the
|
||||
seal flags and `--session <absolute path>`, then accepts only `--model`,
|
||||
`--provider` and `--thinking`, each once, each with one value that is
|
||||
nonempty and doesn't start with `-` or `@`. The constructor refuses a
|
||||
non-list `preArgs` or `extraArgs` and runs the seal before anything else
|
||||
happens (controller.mjs 170–174), so a refused argv never reaches a spawn.
|
||||
|
||||
I passed 24 `extraArgs` lists to the constructor. These all refuse
|
||||
`unsealed-engine`:
|
||||
- my round 1 vectors: `--session <outside file>`, `--mode json`,
|
||||
`--mode text`, `--no-session`, `--fork <file>`, `--export <file>`;
|
||||
- `--print`, `-p hi`, a bare word, `@<file>`, `--approve`,
|
||||
`--no-extensions`, `--extension x`, `-e x`;
|
||||
- `--model=x`, `--model` with no value, an empty value, a value of `-p`
|
||||
or `@f`, `--model` twice, and `--model a hello`.
|
||||
|
||||
These build: `--model a --thinking high --provider p`, `--thinking high`,
|
||||
and `--model "rpc --mode json"`. The last one is a single argv element with
|
||||
no shell in between, so Pi sees it as a model name. That's fine.
|
||||
|
||||
Dewey's mutants r2-B1 to r2-B1e (no extraArgs check, no prefix order, a
|
||||
relative session path, a repeat, a flag or `@` value) are all killed by
|
||||
N24.
|
||||
|
||||
## B2, the session key is the Pi header ID: fixed
|
||||
|
||||
The constructor reads the session header and keys the claim on
|
||||
`sessionKey({ harness: "pi", nativeSession })` (controller.mjs 193–194).
|
||||
I ran two controllers, seat A on the fixture session and seat B on a
|
||||
second file under another seat directory:
|
||||
|
||||
| Second file | A | B | Keys equal |
|
||||
|---|---|---|---|
|
||||
| hard link of A's file | active, 1 launch | refused `already-active`, 0 launches | yes |
|
||||
| copy of A's file | active, 1 launch | refused `already-active`, 0 launches | yes |
|
||||
| copy with a different header ID | active, 1 launch | active, 1 launch | no |
|
||||
|
||||
So the fix doesn't over-collide: two real sessions still start side by
|
||||
side. I also replaced the header ID by rename after construction. `start()`
|
||||
refused `target` with no launch. Mutants r2-B2 and r2-B2b are killed by
|
||||
the new W4 cases.
|
||||
|
||||
n1 (Pi's startup-append rule) and n2 (the guard follows `$HOME`) are fixed
|
||||
in the README as asked.
|
||||
|
||||
## What the rework touched that I checked
|
||||
|
||||
- `#readSession` runs after the guard check at construction and wraps an
|
||||
unreadable file as `configuration`. Good.
|
||||
- The force-stop path now refuses `fenced` while an escalation runs
|
||||
(controller.mjs 683). `#forceStop` holds `escalating` and clears it in
|
||||
`finally` (1453–1459). See F2 for the one gap.
|
||||
- `stopLink` needs `abortWritten`, and there's an overlap recheck after the
|
||||
pause before the abort. Mutants r2-n1 and r2-n2 are killed. This is
|
||||
outside my part, so I note it without a ruling.
|
||||
- Mutant r2-B5b (the shim writes the freeze but doesn't wait for
|
||||
`frozen 1`) survives. That is Filbert's finding and Dewey explains it in
|
||||
the packet. I leave it to Filbert.
|
||||
|
||||
## Follow-ups (none must be fixed before the commit)
|
||||
|
||||
- **F1. `engine.command` and `engine.preArgs` are outside the seal.** The
|
||||
README documents them as a test hook, as I asked in round 1, and
|
||||
`preArgs` still refuses `--extension`. But `preArgs:
|
||||
[<pinned cli.js>, "--no-session"]` builds. Everything after `cli.js`
|
||||
reaches Pi's parser, so a flag there that the seal doesn't repeat later,
|
||||
such as `--no-session` or `--export`, takes effect. A non-default
|
||||
`command` can run anything. No caller passes them today: the package
|
||||
has no entry point that builds a `Controller` from configuration. Before I3 adds one, either
|
||||
refuse any non-default `command`/`preArgs` outside the test harness, or
|
||||
have that entry point never accept them from config. Owner: whoever
|
||||
builds the I3 entry point.
|
||||
- **F2. `escalating` is set before `after()` runs.** The force-stop branch
|
||||
of `#evaluate` sets `this.escalating` at controller.mjs 686, then calls
|
||||
`#admission()` and `link.poison()`. If either throws, `#handle` turns the
|
||||
result into an internal error and drops `after`. `#forceStop` never runs,
|
||||
so the flag is never cleared, and every later force stop on that
|
||||
controller refuses `fenced`. Recovery then needs a controller restart.
|
||||
Neither call is expected to throw, so the risk is low. Fix: set the flag
|
||||
only in `#forceStop`, or clear it in the catch path when `after` is
|
||||
dropped.
|
||||
- **F3. `engine.env` defaults to `process.env`** (controller.mjs 168). A
|
||||
real Pi then inherits the caller's whole environment, including
|
||||
provider keys and the real `HOME`, so it reads the real `~/.pi/agent`.
|
||||
This is unchanged from round 1 and fine for I1, where tests pass their
|
||||
own env. The I3 entry point should build the engine env from an explicit
|
||||
list.
|
||||
@@ -1 +0,0 @@
|
||||
fd10b62c10bff4736b9b4e809b283fe4c5b549fa53fe1121a7bb06258147f0e9 packages/conversation/tests/fake-pi.mjs
|
||||
@@ -1,152 +0,0 @@
|
||||
# Row 46: K1, K3 and K10 on the scope fixtures
|
||||
|
||||
Darkwing, 2026-10-09. Issue #1528, reviewer Dewey. Brief:
|
||||
`docs/plans/2026-10-09_s4-follow-up-and-cohort.md`, section "Conversation
|
||||
cohort: K1, K3 and K10 fail on the scope fixtures". Ruling: lead decision 72.
|
||||
The candidate is not committed, staged or pushed.
|
||||
|
||||
## Cause
|
||||
|
||||
It's a race in the test fixture. Neither the host's systemd setup nor
|
||||
`cohort.mjs` is at fault.
|
||||
|
||||
`spawnChild` in `packages/conversation/tests/fake-pi.mjs` returns the
|
||||
child's pid as soon as `spawn()` returns. The child is `node -e`, and it
|
||||
installs its SIGTERM handler only after Node has booted. That takes 16 to
|
||||
22 ms on an idle host (`trace-pass.jsonl`) and 43 to 80 ms under 48 CPU burners (`trace-load.jsonl`). A
|
||||
TERM that arrives before the handler gets the default action, and the child
|
||||
dies of SIGTERM. Each failing assertion is that death seen from a different
|
||||
place:
|
||||
|
||||
- K1, "the escaped child is a listed member". The child died during the
|
||||
TERM grace, so the freeze-phase enumeration doesn't list it.
|
||||
- K3, "the member ignored TERM". `alive(child)` is false at the kill
|
||||
phase.
|
||||
- K10, "the member is alive across the crash". Same as K3, before the
|
||||
restart.
|
||||
|
||||
The time between spawn and TERM depends on the disk under TMPDIR. The
|
||||
fixture puts the claim store there. Before it sends TERM, the controller
|
||||
publishes claim revisions, each with an fsync on the file and one on the
|
||||
directory (`packages/conversation/src/claim.mjs:159` and `:176`).
|
||||
|
||||
| TMPDIR | Disk | write+fsync median (`fsync.txt`) | spawn to TERM | Unfixed K1/K3/K10 |
|
||||
|---|---|---|---|---|
|
||||
| `/mnt/storage/scratch/tmp` | nvme0, ext4 | 0.65 ms | 12 to 13 ms (`trace-fail.jsonl`) | 0/3 (`runs/canon-scratchtmp.txt`) |
|
||||
| `/tmp` | nvme1, ext4 | 4.63 ms | not traced | 3/3 (`runs/canon-tmp.txt`) |
|
||||
| `~/darkwing-scratch/tmp` | nvme1, ext4 | 4.69 ms | 53 to 110 ms (`trace-pass.jsonl`) | 3/3 (`runs/canon-hometmp.txt`) |
|
||||
|
||||
On the fast disk, TERM lands about 12 ms after spawn, before Node is up.
|
||||
In `trace-fail.jsonl` the children never log `ready`.
|
||||
|
||||
### Why it started failing
|
||||
|
||||
This host's seats now get `TMPDIR=/mnt/storage/scratch/tmp`. My shell had
|
||||
it by default, and so did Sage's gate runs. It comes from Vikunja task 59.
|
||||
`~/.config/systemd/user/t3code.service.d/tmpdir.conf` was written
|
||||
2026-10-04 20:33Z. Its own comment says it takes effect only when
|
||||
`t3code.service` restarts, which hasn't happened (active since 2026-09-23),
|
||||
and that until then seats get TMPDIR from their harness config. I didn't
|
||||
establish what TMPDIR the row 44 gate ran with on 2026-10-05, so the
|
||||
"since when" is likely but unproven. The change that exposed the race is a
|
||||
seat's TMPDIR. It isn't a systemd or user manager setting, and I changed
|
||||
nothing on the host.
|
||||
|
||||
### The scope is not a factor
|
||||
|
||||
Outside any scope, `race.mjs` gives the same split: a TERM 0, 10 or 20 ms
|
||||
after spawn kills 10/10, and at 30 or 60 ms 10/10 survive (`race.txt`).
|
||||
Sage's outside-scope probe found the child surviving. I haven't seen that
|
||||
probe, but it most likely sent TERM after the handler was in place.
|
||||
|
||||
## Receipts in a scope
|
||||
|
||||
`scope-receipt.mjs` launches a delegated scope per run, with the same
|
||||
`systemd-run` flags `ScopeLauncher` uses. The scope's main process spawns
|
||||
the fixture's `ignoreTerm` child and records `systemctl --user show` on the
|
||||
scope, then `cgroup.procs` before and after TERM, then the child's exit.
|
||||
Output: `receipts.jsonl`.
|
||||
|
||||
| Mode | TERM after spawn | In `cgroup.procs` before | After | Child exit |
|
||||
|---|---|---|---|---|
|
||||
| `race 12` | 20 to 25 ms | 5/5 | 1/5 | 4/5 `signal: "SIGTERM"`, 1 alive |
|
||||
| `race 60` | 69 to 71 ms | 5/5 | 5/5 | 5/5 alive 500 ms after TERM |
|
||||
| `ready 0` | 28 to 38 ms (ready at 22 to 30) | 5/5 | 5/5 | 5/5 alive 500 ms after TERM |
|
||||
|
||||
Each line also carries the scope's `Id`, `LoadState=loaded`,
|
||||
`ActiveState=active`, `InvocationID`, `ControlGroup` and `Delegate=yes`.
|
||||
The first line, for example:
|
||||
`ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-12-3662746-0.scope`,
|
||||
`before [3662760, 3662788]`, `after [3662760]`, child exit
|
||||
`{"code":null,"signal":"SIGTERM","atMs":24}`.
|
||||
|
||||
`trace.patch` is the scratch-only instrumentation behind the two traces.
|
||||
It logs spawn, ready and the shim's `term` per pid to `$DW_TRACE`. It is
|
||||
not part of the candidate.
|
||||
|
||||
## The fix
|
||||
|
||||
`build.patch` changes one file, `packages/conversation/tests/fake-pi.mjs`:
|
||||
|
||||
- The child writes one byte to stdout as its first action after installing
|
||||
its TERM handler. A child without `ignoreTerm` writes it once its code
|
||||
starts.
|
||||
- `spawnChild` returns a promise. It resolves with the pid on that byte,
|
||||
then closes its end of the pipe. It rejects if the child exits or errors
|
||||
first, or if 10 s pass.
|
||||
- The `child` control op returns that promise. The op dispatcher now
|
||||
answers `ok: false` when an op's promise rejects. Before, a rejected op
|
||||
promise went unhandled, and under Node's default that ends fake-pi. The
|
||||
change covers every op that returns a promise, not only `child`.
|
||||
`childOf` in `cohort.test.mjs` already asserts `r.ok`.
|
||||
|
||||
No assertion changed, and `cohort.mjs` is untouched. The three cases now
|
||||
test what their names say: TERM reaches a child that is already ignoring
|
||||
TERM.
|
||||
|
||||
`build-manifest.sha256` (sha256
|
||||
`9228414352a56e0465e896b81643cdce000bcedba70f86adac07fc50cfadad2d`) pins
|
||||
`fake-pi.mjs` after the patch. `build.patch` sha256 is
|
||||
`04234ce1d886e36dfd06cbd81153b38cbb86909b6ee933151306b8cdedd629d9`. In a
|
||||
fresh worktree at `521597bb` the patch applies and the manifest checks 1/1.
|
||||
|
||||
## Mutants
|
||||
|
||||
Both ran with `TMPDIR=/mnt/storage/scratch/tmp`, the condition that fails.
|
||||
|
||||
| Mutant | K1/K3/K10 | File |
|
||||
|---|---|---|
|
||||
| M1: resolve at spawn, no wait (the old behavior) | 0/3 | `runs/mutant-m1-nowait.txt` |
|
||||
| M2: wait for ready, but the child has no TERM handler | 0/3, the same three assertions | `runs/mutant-m2-noignore.txt` |
|
||||
|
||||
M1 shows the wait is what fixes it. M2 shows the assertions still catch a
|
||||
child that dies on TERM.
|
||||
|
||||
## Runs
|
||||
|
||||
The patch was applied in a scratch worktree at `521597bb`. Node v26.8.1.
|
||||
Start time and load are in `runs/start.txt`.
|
||||
|
||||
| Run | TMPDIR | Result | File |
|
||||
|---|---|---|---|
|
||||
| `node --test "packages/conversation/tests/*.test.mjs"` | `/mnt/storage/scratch/tmp` | 152/152 | `runs/node-conversation.txt` |
|
||||
| `node --test "packages/webui/tests/*.test.mjs"` | `/mnt/storage/scratch/tmp` | 14/14 | `runs/node-webui.txt` |
|
||||
| K1/K3/K10 isolated, 3 consecutive | `/mnt/storage/scratch/tmp` | 3/3, 3/3, 3/3 | `runs/k-iso-{1,2,3}.txt` |
|
||||
| K1/K3/K10 isolated | `/tmp` | 3/3 | `runs/k-tmp.txt` |
|
||||
| K1/K3/K10 isolated | `~/darkwing-scratch/tmp` | 3/3 | `runs/k-home.txt` |
|
||||
| K1/K3/K10 isolated under 48 CPU burners, 3 runs (load 13.6 to 24.9) | `/mnt/storage/scratch/tmp` | 3/3, 3/3, 3/3 | `runs/k-load48-{1,2,3}.txt` |
|
||||
|
||||
For the gate rerun, keep the default `TMPDIR=/mnt/storage/scratch/tmp`.
|
||||
That's the condition that failed, and a slower TMPDIR would pass with or
|
||||
without the patch.
|
||||
|
||||
## Files
|
||||
|
||||
- `build.md`, this file.
|
||||
- `build.patch`, `build-manifest.sha256`: the candidate.
|
||||
- `scope-receipt.mjs`, `receipts.jsonl`: the scope receipts.
|
||||
- `race.mjs`, `race.txt`: TERM timing outside a scope.
|
||||
- `fsync.mjs`, `fsync.txt`: write+fsync latency per TMPDIR.
|
||||
- `trace.patch`, `trace-fail.jsonl`, `trace-pass.jsonl`, `trace-load.jsonl`: the traced runs.
|
||||
- `runs/`: suite, isolated, load and mutant outputs, and the unfixed
|
||||
canonical tree under three TMPDIRs.
|
||||
@@ -1,67 +0,0 @@
|
||||
diff --git a/packages/conversation/tests/fake-pi.mjs b/packages/conversation/tests/fake-pi.mjs
|
||||
index b3014f86..a78dfebd 100644
|
||||
--- a/packages/conversation/tests/fake-pi.mjs
|
||||
+++ b/packages/conversation/tests/fake-pi.mjs
|
||||
@@ -615,14 +615,34 @@ const children = [];
|
||||
// K2); `forkLoop` forks every 5 ms (K12); `ignoreTerm` survives SIGTERM, so
|
||||
// only the kill phase ends it (K3, K10, K11). With `pidLog`, the fork loop
|
||||
// appends each child's pid and a `term` line when it gets SIGTERM (K12).
|
||||
+//
|
||||
+// It resolves only once the child writes its ready byte, which it does after
|
||||
+// installing its TERM handler. Node takes 15 to 80 ms to get there, and a
|
||||
+// force stop can send TERM sooner (12 ms when the claim store is on a fast
|
||||
+// disk). A TERM before the handler kills a child that is meant to ignore it
|
||||
+// (#1528).
|
||||
function spawnChild({ setsid = false, forkLoop = false, ignoreTerm = false, pidLog = null } = {}) {
|
||||
const note = pidLog ? `const note=(s)=>require('node:fs').appendFileSync(${JSON.stringify(pidLog)},s+'\\n');` : "const note=()=>{};";
|
||||
- const code = note + (ignoreTerm ? "process.on('SIGTERM',()=>note('term'));" : "") + (forkLoop
|
||||
+ const code = note + (ignoreTerm ? "process.on('SIGTERM',()=>note('term'));" : "") + "process.stdout.write('r');" + (forkLoop
|
||||
? "const {spawn}=require('node:child_process');setInterval(()=>{try{const c=spawn('sleep',['1000'],{stdio:'ignore'});if(c.pid)note(String(c.pid))}catch{}},5);setInterval(()=>{},1e9)"
|
||||
: "setInterval(()=>{},1e9)");
|
||||
- const child = spawn(process.execPath, ["-e", code], { stdio: "ignore", detached: setsid });
|
||||
+ const child = spawn(process.execPath, ["-e", code], { stdio: ["ignore", "pipe", "ignore"], detached: setsid });
|
||||
children.push(child.pid);
|
||||
- return child.pid;
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ const fail = (why) => {
|
||||
+ clearTimeout(timer);
|
||||
+ reject(new Error(`tool child ${child.pid} ${why} before it was ready`));
|
||||
+ };
|
||||
+ const timer = setTimeout(() => fail("took 10 s"), 10000);
|
||||
+ child.once("error", (err) => fail(err.code ?? err.message));
|
||||
+ child.once("exit", (code, signal) => fail(`exited (${signal ?? code})`));
|
||||
+ child.stdout.once("data", () => {
|
||||
+ clearTimeout(timer);
|
||||
+ child.removeAllListeners("exit");
|
||||
+ child.stdout.destroy();
|
||||
+ resolve(child.pid);
|
||||
+ });
|
||||
+ });
|
||||
}
|
||||
|
||||
// K13: a member writes its own pid to another cgroup's cgroup.procs.
|
||||
@@ -671,7 +691,7 @@ async function main() {
|
||||
drop: () => fake.dropResponse(req.type, req.n ?? 1),
|
||||
extension: () => void fake.extensionPrompt(req.args ?? {}),
|
||||
state: () => ({ streaming: fake.streaming, runs: fake.runs.length, commands: fake.commands, pid: process.pid, children, appends: fake.appends }),
|
||||
- child: () => ({ pid: spawnChild(req.args ?? {}) }),
|
||||
+ child: () => spawnChild(req.args ?? {}).then((pid) => ({ pid })),
|
||||
escape: () => escape(req.target),
|
||||
cgroup: () => readFileSync(`/proc/${req.pid ?? process.pid}/cgroup`, "utf8"),
|
||||
waitPaused: () => fake.waitPaused(req.point),
|
||||
@@ -679,12 +699,13 @@ async function main() {
|
||||
stall: () => void process.stdin.pause(),
|
||||
};
|
||||
if (!ops[req.op]) return sock.write(encodeLine({ id: req.id, ok: false, error: `unknown op ${req.op}` }));
|
||||
+ const failed = (err) => sock.write(encodeLine({ id: req.id, ok: false, error: String(err.message) }));
|
||||
try {
|
||||
const out = ops[req.op]();
|
||||
- if (out && typeof out.then === "function") out.then(reply);
|
||||
+ if (out && typeof out.then === "function") out.then(reply, failed);
|
||||
else reply(out ?? null);
|
||||
} catch (err) {
|
||||
- sock.write(encodeLine({ id: req.id, ok: false, error: String(err.message) }));
|
||||
+ failed(err);
|
||||
}
|
||||
});
|
||||
sock.on("data", (c) => splitter.push(c));
|
||||
@@ -1,17 +0,0 @@
|
||||
import { openSync, writeSync, fsyncSync, closeSync, rmSync, mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
for (const base of process.argv.slice(2)) {
|
||||
const d = mkdtempSync(join(base, "dw-fsync-"));
|
||||
const t = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const s = performance.now();
|
||||
const fd = openSync(join(d, `f${i}`), "w");
|
||||
writeSync(fd, "x".repeat(512));
|
||||
fsyncSync(fd);
|
||||
closeSync(fd);
|
||||
t.push(performance.now() - s);
|
||||
}
|
||||
rmSync(d, { recursive: true });
|
||||
t.sort((a, b) => a - b);
|
||||
console.log(`${base}: write+fsync median ${t[15].toFixed(2)} ms, p90 ${t[27].toFixed(2)} ms`);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
/mnt/storage/scratch/tmp: write+fsync median 0.65 ms, p90 0.79 ms
|
||||
/tmp: write+fsync median 4.63 ms, p90 4.93 ms
|
||||
/home/jwoltje/darkwing-scratch/tmp: write+fsync median 4.69 ms, p90 9.30 ms
|
||||
@@ -1,17 +0,0 @@
|
||||
// Spawn the K fixtures' ignoreTerm child exactly as fake-pi.mjs spawnChild does, then
|
||||
// SIGTERM it after a delay. Reports how the child ended within 1 s.
|
||||
import { spawn } from "node:child_process";
|
||||
const code = "const note=()=>{};process.on('SIGTERM',()=>note('term'));setInterval(()=>{},1e9)";
|
||||
const delays = process.argv.slice(2).map(Number);
|
||||
for (const d of delays) {
|
||||
const r = { died: 0, survived: 0 };
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const c = spawn(process.execPath, ["-e", code], { stdio: "ignore" });
|
||||
const ended = new Promise((res) => c.on("exit", (code, sig) => res(sig ?? code)));
|
||||
await new Promise((res) => setTimeout(res, d));
|
||||
c.kill("SIGTERM");
|
||||
const out = await Promise.race([ended, new Promise((res) => setTimeout(() => res(null), 1000))]);
|
||||
if (out === null) { r.survived++; c.kill("SIGKILL"); await ended; } else r.died++;
|
||||
}
|
||||
console.log(`TERM ${d} ms after spawn: died ${r.died}/10 survived ${r.survived}/10`);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
TERM 0 ms after spawn: died 10/10 survived 0/10
|
||||
TERM 10 ms after spawn: died 10/10 survived 0/10
|
||||
TERM 20 ms after spawn: died 10/10 survived 0/10
|
||||
TERM 30 ms after spawn: died 0/10 survived 10/10
|
||||
TERM 60 ms after spawn: died 0/10 survived 10/10
|
||||
@@ -1,15 +0,0 @@
|
||||
{"mode":"race","delayMs":12,"readyMs":null,"termMs":23,"child":3662788,"self":3662760,"show":["Id=dw-r46-race-12-3662746-0.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=2eac4fa367504b9dbca66a4693cc328a","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-12-3662746-0.scope","Delegate=yes"],"before":[3662760,3662788],"after":[3662760],"childInBefore":true,"childInAfter":false,"exit":{"code":null,"signal":"SIGTERM","atMs":24}}
|
||||
{"mode":"race","delayMs":12,"readyMs":null,"termMs":20,"child":3663479,"self":3663212,"show":["Id=dw-r46-race-12-3662746-1.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=021c6d466fc64961ba3031a0254c3803","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-12-3662746-1.scope","Delegate=yes"],"before":[3663212,3663479],"after":[3663212],"childInBefore":true,"childInAfter":false,"exit":{"code":null,"signal":"SIGTERM","atMs":21}}
|
||||
{"mode":"race","delayMs":12,"readyMs":null,"termMs":25,"child":3663799,"self":3663588,"show":["Id=dw-r46-race-12-3662746-2.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=ce8e2c2dbbe7481885a50152fe3766b5","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-12-3662746-2.scope","Delegate=yes"],"before":[3663588,3663799],"after":[3663588],"childInBefore":true,"childInAfter":false,"exit":{"code":null,"signal":"SIGTERM","atMs":27}}
|
||||
{"mode":"race","delayMs":12,"readyMs":null,"termMs":20,"child":3664563,"self":3664322,"show":["Id=dw-r46-race-12-3662746-3.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=cc912ab9a7fe47dd9e3b97486abf0bc3","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-12-3662746-3.scope","Delegate=yes"],"before":[3664322,3664563],"after":[3664322,3664563],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
{"mode":"race","delayMs":12,"readyMs":null,"termMs":21,"child":3665177,"self":3665003,"show":["Id=dw-r46-race-12-3662746-4.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=163271c658cc4ee4b84f5aace54bc806","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-12-3662746-4.scope","Delegate=yes"],"before":[3665003,3665177],"after":[3665003],"childInBefore":true,"childInAfter":false,"exit":{"code":null,"signal":"SIGTERM","atMs":22}}
|
||||
{"mode":"race","delayMs":60,"readyMs":null,"termMs":69,"child":3665812,"self":3665626,"show":["Id=dw-r46-race-60-3665599-0.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=14681de5ac844879bdfcbcdd3dae8f10","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-60-3665599-0.scope","Delegate=yes"],"before":[3665626,3665812],"after":[3665626,3665812],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
{"mode":"race","delayMs":60,"readyMs":null,"termMs":71,"child":3666377,"self":3666179,"show":["Id=dw-r46-race-60-3665599-1.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=3128eba3ed234776ae4a61cb12fe1210","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-60-3665599-1.scope","Delegate=yes"],"before":[3666179,3666377],"after":[3666179,3666377],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
{"mode":"race","delayMs":60,"readyMs":null,"termMs":70,"child":3666924,"self":3666728,"show":["Id=dw-r46-race-60-3665599-2.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=836de499898d4b6996739e1b2a433fa0","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-60-3665599-2.scope","Delegate=yes"],"before":[3666728,3666924],"after":[3666728,3666924],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
{"mode":"race","delayMs":60,"readyMs":null,"termMs":69,"child":3667387,"self":3667251,"show":["Id=dw-r46-race-60-3665599-3.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=0ebf8d9d930a43629668e10c08f80e0d","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-60-3665599-3.scope","Delegate=yes"],"before":[3667251,3667387],"after":[3667251,3667387],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
{"mode":"race","delayMs":60,"readyMs":null,"termMs":70,"child":3667888,"self":3667727,"show":["Id=dw-r46-race-60-3665599-4.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=9c3594ae3018458594bb3ff996dc0d9a","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-race-60-3665599-4.scope","Delegate=yes"],"before":[3667727,3667888],"after":[3667727,3667888],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
{"mode":"ready","delayMs":0,"readyMs":30,"termMs":38,"child":3668171,"self":3668085,"show":["Id=dw-r46-ready-0-3668072-0.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=4c2c3b3ad52a477e8f735abedf158cbb","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-ready-0-3668072-0.scope","Delegate=yes"],"before":[3668085,3668171],"after":[3668085,3668171],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
{"mode":"ready","delayMs":0,"readyMs":22,"termMs":29,"child":3668354,"self":3668284,"show":["Id=dw-r46-ready-0-3668072-1.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=b9da06280a8a4c6682f05a2d4ff118cf","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-ready-0-3668072-1.scope","Delegate=yes"],"before":[3668284,3668354],"after":[3668284,3668354],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
{"mode":"ready","delayMs":0,"readyMs":22,"termMs":28,"child":3668425,"self":3668417,"show":["Id=dw-r46-ready-0-3668072-2.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=aab0b83d085e4bac86fdbe1ce65f23dc","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-ready-0-3668072-2.scope","Delegate=yes"],"before":[3668417,3668425],"after":[3668417,3668425],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
{"mode":"ready","delayMs":0,"readyMs":23,"termMs":30,"child":3668509,"self":3668482,"show":["Id=dw-r46-ready-0-3668072-3.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=d076a3b629554b83bded41cb00e667b8","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-ready-0-3668072-3.scope","Delegate=yes"],"before":[3668482,3668509],"after":[3668482,3668509],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
{"mode":"ready","delayMs":0,"readyMs":23,"termMs":30,"child":3668635,"self":3668624,"show":["Id=dw-r46-ready-0-3668072-4.scope","LoadState=loaded","ActiveState=active","SubState=running","InvocationID=6b46a0b279ca454eaa034d48a4de7385","ControlGroup=/user.slice/user-1000.slice/[email protected]/app.slice/dw-r46-ready-0-3668072-4.scope","Delegate=yes"],"before":[3668624,3668635],"after":[3668624,3668635],"childInBefore":true,"childInAfter":true,"exit":"alive 500 ms after TERM"}
|
||||
@@ -1,11 +0,0 @@
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2737.880671ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2557.042479ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (861.841994ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 6282.04986
|
||||
@@ -1,56 +0,0 @@
|
||||
✖ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2537.385038ms)
|
||||
✖ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2331.351365ms)
|
||||
✖ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (209.932151ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 3
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 5448.48373
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/conversation/tests/cohort.test.mjs:139:1
|
||||
✖ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2537.385038ms)
|
||||
AssertionError [ERR_ASSERTION]: the escaped child is a listed member
|
||||
at TestContext.<anonymous> (file:///mnt/storage/src/mosaic-stack/packages/conversation/tests/cohort.test.mjs:151:12)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
|
||||
test at packages/conversation/tests/cohort.test.mjs:176:1
|
||||
✖ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2331.351365ms)
|
||||
AssertionError [ERR_ASSERTION]: the member ignored TERM
|
||||
at TestContext.<anonymous> (file:///mnt/storage/src/mosaic-stack/packages/conversation/tests/cohort.test.mjs:190:12)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
|
||||
test at packages/conversation/tests/cohort.test.mjs:426:3
|
||||
✖ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (209.932151ms)
|
||||
AssertionError [ERR_ASSERTION]: the member is alive across the crash
|
||||
at TestContext.<anonymous> (file:///mnt/storage/src/mosaic-stack/packages/conversation/tests/cohort.test.mjs:431:12)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2360.366213ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2364.75763ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (628.04725ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 5474.064409
|
||||
@@ -1,11 +0,0 @@
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2457.36226ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2375.081486ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (636.350971ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 5607.100296
|
||||
@@ -1,11 +0,0 @@
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2665.443725ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2531.922418ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (706.098022ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 6068.030335
|
||||
@@ -1,11 +0,0 @@
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2699.60543ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2492.169051ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (675.081525ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 6026.119198
|
||||
@@ -1,11 +0,0 @@
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2762.539396ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2528.214494ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (644.633095ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 6115.728995
|
||||
@@ -1,11 +0,0 @@
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2887.135899ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2915.708256ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (2590.181146ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 9662.912642
|
||||
@@ -1,11 +0,0 @@
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (3084.73509ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2750.8295ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (2151.224665ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 8355.450647
|
||||
@@ -1,11 +0,0 @@
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2746.07054ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2559.848734ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (1985.055632ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 7722.015242
|
||||
@@ -1,11 +0,0 @@
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2785.421246ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2619.007617ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (889.082424ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 6776.303473
|
||||
@@ -1,56 +0,0 @@
|
||||
✖ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2523.134497ms)
|
||||
✖ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2421.121319ms)
|
||||
✖ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (211.187938ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 3
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 5461.571354
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at cohort.test.mjs:139:1
|
||||
✖ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2523.134497ms)
|
||||
AssertionError [ERR_ASSERTION]: the escaped child is a listed member
|
||||
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r46/fix/packages/conversation/tests/cohort.test.mjs:151:12)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
|
||||
test at cohort.test.mjs:176:1
|
||||
✖ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2421.121319ms)
|
||||
AssertionError [ERR_ASSERTION]: the member ignored TERM
|
||||
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r46/fix/packages/conversation/tests/cohort.test.mjs:190:12)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
|
||||
test at cohort.test.mjs:426:3
|
||||
✖ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (211.187938ms)
|
||||
AssertionError [ERR_ASSERTION]: the member is alive across the crash
|
||||
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r46/fix/packages/conversation/tests/cohort.test.mjs:431:12)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
✖ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2545.15719ms)
|
||||
✖ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2407.231229ms)
|
||||
✖ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (304.986006ms)
|
||||
ℹ tests 3
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 3
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 5605.846956
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at cohort.test.mjs:139:1
|
||||
✖ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2545.15719ms)
|
||||
AssertionError [ERR_ASSERTION]: the escaped child is a listed member
|
||||
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r46/fix/packages/conversation/tests/cohort.test.mjs:151:12)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
|
||||
test at cohort.test.mjs:176:1
|
||||
✖ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2407.231229ms)
|
||||
AssertionError [ERR_ASSERTION]: the member ignored TERM
|
||||
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r46/fix/packages/conversation/tests/cohort.test.mjs:190:12)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
|
||||
test at cohort.test.mjs:426:3
|
||||
✖ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (304.986006ms)
|
||||
AssertionError [ERR_ASSERTION]: the member is alive across the crash
|
||||
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r46/fix/packages/conversation/tests/cohort.test.mjs:431:12)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
✔ W1: two processes acquire the same pair at once; exactly one claim (124.186492ms)
|
||||
✔ W1: two writers publish the same revision at once: one wins, the other gets null, the winner's record stays (11.494379ms)
|
||||
✔ W1: a revision name appears only after its bytes are synced; before that, only a temp file exists (5.019078ms)
|
||||
✔ W2: acquire while a claim is reserved or active refuses already-active (169.129732ms)
|
||||
✔ W3: acquire while stopping, uncertain, or stopped without proof refuses unsafe-replacement (231.756886ms)
|
||||
✔ W4: same session with another seat tuple, and the reverse, both refuse; a loser on the seat key closes it no-unit (192.859955ms)
|
||||
✔ W4: a hard link of one session under another seat is the same session: the second controller refuses already-active and launches nothing (35.003128ms)
|
||||
✔ W4: a copy of one session under another seat is the same session: the second controller refuses already-active and launches nothing (21.132326ms)
|
||||
✔ W4: a session header ID that changes after construction refuses target; nothing is claimed or launched (3.242382ms)
|
||||
✔ W5: SIGKILL between every publication barrier of acquire and transition; restart never finds two holders or a lost claim (5566.910492ms)
|
||||
✔ W5: SIGKILL between every publication barrier of release; restart finishes or holds the release (22659.492505ms)
|
||||
✔ W6: controller killed mid-turn while the engine lives; restart is uncertain, no launch, prompts refuse (201.806859ms)
|
||||
✔ W12: a live owner paused with SIGSTOP; a second controller refuses already-active and changes nothing (116.242029ms)
|
||||
✔ W13: crash after the engine spawns, before active; restart finds the live unit: uncertain, no second spawn, force stop only (262.226787ms)
|
||||
✔ W14: crash after reservation, before the spawn marker: stopped with a no-unit observation; the pair is free (220.425841ms)
|
||||
✔ W20: crash after the spawn marker, scope collected; uncertain in both runs, the marker is copied, no launch until a boot proof (240.127173ms)
|
||||
✔ W15: crash between the two keys during release; restart finishes it under the same claim ID (35.945246ms)
|
||||
✔ W7: recorded boot ID differs on the same machine: stopped with a boot proof; open tool calls become uncertain (98.424488ms)
|
||||
✔ W8: resume after a proven stop with the same pins: new claim ID, generation +1, same conversation, branch and leaf (32.141383ms)
|
||||
✔ W9: resume with a changed binary, argv digest, branch or leaf is refused and the claim is unchanged (89.672997ms)
|
||||
✔ W11: the controller writes no session file; only the fake engine's own appends appear (23.121134ms)
|
||||
✔ W16: a highest revision that won't parse holds the pair uncertain; the older stopped revision is not reused (54.682806ms)
|
||||
✔ W17: a claim root copied from another host refuses foreign-host and promotes nothing (57.719055ms)
|
||||
✔ G1: a session path or claim root under .pi/state, ~/.claude, the data root or a registration refuses at construction (4.12463ms)
|
||||
✔ G2: a symlink inside the fixture root to a live session file is refused by the real-path check (1.12384ms)
|
||||
✔ G3: a fixture path swapped for a live path after construction is refused at bind (1.84306ms)
|
||||
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2507.919568ms)
|
||||
✔ K2: K1 on the process-group fallback ends uncertain, never stopped (153.327228ms)
|
||||
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2414.676654ms)
|
||||
✔ K4: two engines; force stop one; the other survives by independent observation (4314.515513ms)
|
||||
✔ K5: a stop during a tool call leaves the effect uncertain, and it is shown (2190.378919ms)
|
||||
✔ K12: a member forking in a loop: the freeze stops it, enumeration is complete, populated 0 after cgroup.kill (2244.796524ms)
|
||||
✔ K13: a member writing its pid into another cgroup is refused by the namespace; the kill is complete (2189.63935ms)
|
||||
✔ K15: the shim gone, engine/cgroup.events unreadable, or the engine cgroup missing: evidence unavailable, not empty; uncertain (4473.315326ms)
|
||||
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (482.430287ms)
|
||||
✔ K11: controller killed after the confirmation is recorded, before TERM: restart checks the invocation ID and re-runs from TERM for the same stop (408.721794ms)
|
||||
✔ K14: a unit with the recorded name but another invocation ID: evidence unavailable, no signals, uncertain (307.075836ms)
|
||||
✔ K6: recover without proof, without confirmation, or with changed pins is refused (71.309069ms)
|
||||
✔ K7: recover after proof, then launch: new claim and execution, generation +1, same leaf; the cancelled prompt is not replayed (35.403696ms)
|
||||
✔ K8: an engine that loads another leaf on resume is refused before admission; it stays claimed until a proven stop (45.424183ms)
|
||||
✔ K9: an interrupt that never settles stays uncertain; force stop stays available; takeover is refused while fenced (3030.941578ms)
|
||||
✔ K16: a claim from another machine ID refuses foreign-host; no boot proof is issued (5.325961ms)
|
||||
✔ K17: two launcher calls with one eligibility record: one launch, the other refuses, no second engine (30.688596ms)
|
||||
✔ K18: the leaf changes after eligibility: launch refused; the reservation stays until released with proof (24.893734ms)
|
||||
✔ S1: `/goal x`, with leading spaces or a tab, refuses text-policy at admission; zero engine bytes (34.176579ms)
|
||||
✔ S2: every prefix pinned Pi interprets is refused, from the list the code uses; the rest reach the engine exactly (30.425031ms)
|
||||
✔ S3: `/goal` on the second line is pinned from the source: Pi checks only index 0, so it is admitted and sent exactly (27.293519ms)
|
||||
✔ S4: a `/` left in the composer is cleared when control transfers and returns; the next submit sends only the new text (42.046162ms)
|
||||
✔ S5: an observer terminal gets a paste then Enter, as send-message.sh does: not admitted: controller, nothing sent (21.340651ms)
|
||||
✔ S6: a mediated-shaped registration (no tmux) passed to the board's replyToRow: 409 no tmux session; exec never runs (1.34914ms)
|
||||
✔ S7: ESC, bracketed-paste markers and U+2028/U+2029 travel as one JSON string; the engine receives the exact text in one record (29.201111ms)
|
||||
✔ P3: a Pi confirm, select, input or editor dialog is shown disabled with a reason and never answered (138.365864ms)
|
||||
✔ E1: send, ack, user, toolCall, toolResult, final answer: shown once, no refresh, draft and reading position kept (39.366805ms)
|
||||
✔ E2: U+2028, U+2029 inside JSON strings and CRLF line ends each parse as one record, on the splitter and through the controller (21.443107ms)
|
||||
✔ E3: a multipart final, two blocks, null request correlation and duplicate delivery (31.257304ms)
|
||||
✔ E4: a page read after message_end but before its entry is persisted: marker at the seam, re-read after run-settled, each message once (26.594189ms)
|
||||
✔ E4: a gap or a new epoch also reconciles; nothing is concatenated across a gap (11.181347ms)
|
||||
✔ E5: an unknown native event gives no client event; evidence records its type and bytes; the terminal count goes up (26.220793ms)
|
||||
✔ E6: a tool result delayed across a pause and a reconnect is reconciled without a manual refresh (42.195378ms)
|
||||
✔ E7: the terminal renders the same stream as the library client, as observer and then as controller, and submits only as controller (41.055698ms)
|
||||
✔ terminal: engine control characters are made visible; a lost connection refuses submit (28.737455ms)
|
||||
✔ terminal: outcome unknown is shown as such, with no resend offer, and nothing is resent (0.53917ms)
|
||||
✔ terminal: text after Enter in the same input chunk starts the next message; it never joins the one submitted (0.351376ms)
|
||||
✔ terminal: a paste-start marker split right after its ESC still opens the paste; the Enter inside it never submits (0.356358ms)
|
||||
✔ terminal: invisible and bidi characters are made visible; head, status and notice lines stay one line (0.168609ms)
|
||||
✔ every record these fixtures produced is a valid CHAT-01 record (E5: no record fails the schema) (359.398761ms)
|
||||
✔ H1: two takeovers with the same expected generation: one wins, +1; the other refuses generation (56.47649ms)
|
||||
✔ H2: the old controller's prompt after a takeover commits is refused with zero engine bytes (79.112044ms)
|
||||
✔ H3: a takeover while a prompt holds the dispatch lock: written under the old actor, or refused; never both (131.150736ms)
|
||||
✔ H4: self-takeover is refused (22.440288ms)
|
||||
✔ H9: Interrupt racing a prompt's dispatch: before the write, dispatch-refused and no-turn; after, §3 rules (87.314186ms)
|
||||
✔ H10: Interrupt and force stop together: one stop chain, force stop supersedes (104.583327ms)
|
||||
✔ H10: an overlap during the pause before the abort: no abort, the stop ends uncertain (35.050855ms)
|
||||
✔ H10: a no-turn Interrupt lifts only its own fence; admission stays closed under force stop, overlap or revocation (70.09079ms)
|
||||
✔ H11: the controller disconnects mid-turn: work continues, the claim is unchanged, control stays put (129.262166ms)
|
||||
✔ H12: an exact retry after reconnecting to the same incarnation returns the same receipt; one dispatch (15.147609ms)
|
||||
✔ H13: a retry with the same request ID and different text is refused (13.200342ms)
|
||||
✔ H14: late stdout from the old engine after a replacement is dropped by incarnation, counted, never rendered (133.123623ms)
|
||||
✔ H15: a revoked connection's command is refused; the revocation fence holds (65.378107ms)
|
||||
✔ H16: a second controller for the same session refuses already-active; the first is untouched (16.417034ms)
|
||||
✔ H10: a second force stop while the first escalation runs refuses fenced; one escalation, and the claim records only the first stop's phases (64.083554ms)
|
||||
✔ H17: a confirmation reused, answered from another connection, or used after the stop changed is refused (56.969236ms)
|
||||
✔ H18: two prompts before any native output: the second refuses busy; one engine write (15.033626ms)
|
||||
✔ H19: the pipe fails mid-line under a large prompt: delivery-unknown transport-unknown, poisoned, no later write (119.397422ms)
|
||||
✔ H19: the link itself never writes again after an unknown outcome, whoever calls it (0.606652ms)
|
||||
✔ H19: the controller dies mid-write of a large line: after restart the outcome is unknown and nothing is resent (464.542066ms)
|
||||
✔ H20: the line is written but the ack is lost when the controller dies: orphan, outcome unknown, nothing resent (357.812591ms)
|
||||
✔ H21: a retry of the exact request with the old token after a crash is stale-incarnation; no second write (337.123938ms)
|
||||
✔ H22: after H21 and a valid recovery, a new request with the new token is admitted (2377.836435ms)
|
||||
✔ H23: requests pending at a restart are not resent; each shows outcome unknown (482.017907ms)
|
||||
✔ a plain conversation: catalogue row, one page, CHAT-01 records (10.196509ms)
|
||||
✔ native entries map to blocks: tools, thinking, bash, notices, ids that do not fit (3.091521ms)
|
||||
✔ F1: a malformed line is an unavailable part at its position, and reading continues (2.999666ms)
|
||||
✔ F1: a missing parent stops the history with a notice that names the unreadable lines (3.746417ms)
|
||||
✔ F1: an unreadable fork is never merged into another branch's history (3.733154ms)
|
||||
✔ F1: a follow stays on its branch when the next entry's parent is unreadable (3.881773ms)
|
||||
✔ F1: a file whose entries are all unreadable shows a notice per line (1.563335ms)
|
||||
✔ F2: a truncated trailing line marks the view incomplete, not an error (2.494119ms)
|
||||
✔ pagination: 100 parts, then the rest; parts concatenate to the whole branch (5.109673ms)
|
||||
✔ F3: a replaced file (new inode) refuses old cursors with reconcile (4.939351ms)
|
||||
✔ F4: a same-inode rewrite of the prefix refuses old cursors with reconcile (5.516645ms)
|
||||
✔ F5: growth between pages keeps the epoch and the page stops at the pinned length (5.793786ms)
|
||||
✔ F6: unknown, foreign and expired cursors refuse and leave the cursor usable (10.497498ms)
|
||||
✔ F7: a symlinked file and a symlinked directory component are refused, never opened (9.313922ms)
|
||||
✔ F8: a file swapped for a symlink after the catalogue is refused (2.247728ms)
|
||||
✔ F9: registrations never add or redirect a root (2.060491ms)
|
||||
✔ F10: a header cwd naming another project is refused (3.630852ms)
|
||||
✔ F11: parentSession renders with a marker and the parent is never opened (0.936984ms)
|
||||
✔ F12: two leaves: the default leaf is shown and the other branch reads alone (4.789444ms)
|
||||
✔ F12: a follow refuses when an appended duplicate id changes the branch's earlier parts (2.415895ms)
|
||||
✔ F12: a second root (Pi's resetLeaf) starts its own branch (1.349225ms)
|
||||
✔ F13: compaction is a marker in place, then the retained content (0.756245ms)
|
||||
✔ F14: long strings split into fragments and parts, reassemble exactly, and pages respect the byte cap (737.947726ms)
|
||||
✔ fragments never cut a surrogate pair and keep an empty string (9.768434ms)
|
||||
✔ F15: a Claude seat is an unsupported-harness placeholder whose directory is never read (2.629953ms)
|
||||
✔ unknown conversations, empty files and non-Pi files refuse (2.988026ms)
|
||||
✔ an unreadable file or root inside the roots is refused per row, not a failed catalogue (1.388415ms)
|
||||
✔ a seat directory without search permission refuses that root, not the catalogue (2.248255ms)
|
||||
✔ every page and cursor is a valid CHAT-01 record (847.042088ms)
|
||||
✔ the engine pin holds for the installed package (2.933293ms)
|
||||
✔ pinned Pi, sealed and without credentials, answers the controller's commands with the shapes the fake models (364.994239ms)
|
||||
✔ pinned Pi appends thinking_level_change at start when the branch lacks one, so the leaf moves (K8 then fails closed) (307.845583ms)
|
||||
✔ N25: ordinary Interrupt reconciles; a non-empty queue_update in the window is O5 (80.815781ms)
|
||||
✔ N1: an extension's follow-up queued after the fence is cleared before any abort; O5, Unknown (57.148386ms)
|
||||
✔ N1: a follow-up queued before the fence is O5 at once; the Interrupt refuses fenced (24.835014ms)
|
||||
✔ N2: with abort first, the fake runs the external item (the ordering guard has teeth) (21.576912ms)
|
||||
✔ N3: the fence lands in preflight, preflight errors, no run: failed, No run, uncertain (42.350718ms)
|
||||
✔ N4: the ack arrives after the first abort and a run starts: clear and abort again; Interrupted (37.456929ms)
|
||||
✔ N5: an input handler takes the prompt: ack, no run, delivery-unknown handled-without-run (119.71406ms)
|
||||
✔ N6: an extension queues between clear_queue and abort: O5 and O6, Unknown (46.194593ms)
|
||||
✔ N7: clear_queue times out: no abort, nativeQueue unknown, force stop still ends it (1530.004862ms)
|
||||
✔ N7: clear_queue answers an error: no abort, nativeQueue unknown, the link not poisoned (15.860593ms)
|
||||
✔ N8: an extension prompt starts a run during Mosaic preflight; the losing settle is O3 (71.692215ms)
|
||||
✔ N9: a run that started before the fence and ends aborted: failed interrupted, Interrupted (16.241237ms)
|
||||
✔ N9: decision 34: a run that ends aborted with no stop in progress: aborted-without-stop, uncertain, outcome unknown (16.048665ms)
|
||||
✔ N9: an aborted that lands after the fence but before any abort is written: aborted-without-stop, Unknown (32.548668ms)
|
||||
✔ N10: fake conformance (30.957332ms)
|
||||
✔ N11: the run fails before any user message_start: delivery-unknown ack-without-start, never failed (29.834953ms)
|
||||
✔ N12: input that starts a run after the final empty clear is O1 and not part of the stop's proof (19.905915ms)
|
||||
✔ N13: agent_start with no slot held is O1; a later prompt refuses with zero engine bytes (66.348845ms)
|
||||
✔ N14: the run completes while clear_queue is in flight: finished, Completed first, uncertain (26.705932ms)
|
||||
✔ N14: the run completes after the abort is written, before Pi applies it: finished, never relabelled (25.622164ms)
|
||||
✔ N15: the fence lands in preflight, then an input handler takes it: handled-without-run, No run (19.804122ms)
|
||||
✔ N16: Interrupt with no slot and no run refuses no-turn: no stop, no bytes, admission open (13.437887ms)
|
||||
✔ N17: the run fails on its own during the exchange: failed, Failed on its own (28.095743ms)
|
||||
✔ N18: no final assistant message_end, or a lost line: working stays working; before working, transport-unknown (113.466843ms)
|
||||
✔ N19: a losing extension prompt settles inside the Mosaic run before its user message: O3, run-overlap (134.279618ms)
|
||||
✔ N20: an extension triggerTurn during Mosaic preflight starts first; while streaming it queues with no signal (83.211293ms)
|
||||
✔ N21: a losing settle after the receipt settled finished is O2; the receipt stays finished (14.999139ms)
|
||||
✔ N22: an agent-level custom message is dropped by the clear with no signal; evidence names the seal (13.807672ms)
|
||||
✔ N23: a nextTurn message survives clear and abort and attaches to the next prompt, with no signal (13.596018ms)
|
||||
✔ N24: the seal is an allow-list: --extension, a missing --no-* flag, a second --mode or --session, a session or output flag, or a stray word refuses unsealed-engine; no engine starts (37.274093ms)
|
||||
ℹ tests 152
|
||||
ℹ suites 0
|
||||
ℹ pass 152
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 32488.500969
|
||||
@@ -1,23 +0,0 @@
|
||||
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (2364.731167ms)
|
||||
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
|
||||
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (2725.60223ms)
|
||||
✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (2335.250549ms)
|
||||
✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (1375.477504ms)
|
||||
✔ conversation view: a newer session with no readable history keeps the marker (1002.908949ms)
|
||||
✔ conversation view: seats without history say so and offer no reply (608.42244ms)
|
||||
✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (1957.413637ms)
|
||||
✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (53771.943793ms)
|
||||
✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (1987.210959ms)
|
||||
✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (21912.250845ms)
|
||||
✔ loopback host and board origin fail closed (7.055591ms)
|
||||
✔ real board fixture passes through WebUI; assets and isolated seen/reply work (100.024626ms)
|
||||
✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (227.262245ms)
|
||||
✔ unreachable board reports URL; CLI rejects unsupported options (1632.267153ms)
|
||||
ℹ tests 14
|
||||
ℹ suites 0
|
||||
ℹ pass 14
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 54170.048795
|
||||
@@ -1,2 +0,0 @@
|
||||
2026-10-09T13:05:51Z
|
||||
08:05:51 up 33 days, 9:40, 5 users, load average: 4.03, 7.84, 5.58
|
||||
@@ -1,61 +0,0 @@
|
||||
// Row 46 receipt probe. Runs the fixture's ignoreTerm child inside a
|
||||
// delegated systemd user scope, sends it SIGTERM, and records the scope's
|
||||
// `systemctl --user show`, `cgroup.procs` before and after TERM, and the
|
||||
// child's exit code and signal.
|
||||
//
|
||||
// node scope-receipt.mjs <mode> <delayMs> <runs>
|
||||
//
|
||||
// mode `race`: TERM goes `delayMs` after spawn, as the fixture does today.
|
||||
// mode `ready`: TERM goes `delayMs` after the child reports its handler is
|
||||
// installed, as the fixed fixture does.
|
||||
// The outer process launches one scope per run; the inner process
|
||||
// (`--inner`) is the scope's main process and prints one JSON line.
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// The fixture's child code for { ignoreTerm: true } with no pidLog
|
||||
// (packages/conversation/tests/fake-pi.mjs, spawnChild), plus a readiness
|
||||
// byte on stdout in `ready` mode.
|
||||
const childCode = (ready) =>
|
||||
"const note=()=>{};process.on('SIGTERM',()=>note('term'));" +
|
||||
(ready ? "process.stdout.write('r');" : "") +
|
||||
"setInterval(()=>{},1e9)";
|
||||
|
||||
const procs = (cg) => readFileSync(`/sys/fs/cgroup${cg}/cgroup.procs`, "utf8").split("\n").filter(Boolean).map(Number);
|
||||
|
||||
async function inner(mode, delayMs, unit) {
|
||||
const cg = readFileSync("/proc/self/cgroup", "utf8").trim().split("::")[1];
|
||||
const ready = mode === "ready";
|
||||
const t0 = performance.now();
|
||||
const child = spawn(process.execPath, ["-e", childCode(ready)], { stdio: ["ignore", ready ? "pipe" : "ignore", "ignore"] });
|
||||
const exited = new Promise((r) => child.on("exit", (code, signal) => r({ code, signal, atMs: Math.round(performance.now() - t0) })));
|
||||
let readyMs = null;
|
||||
if (ready) {
|
||||
await new Promise((r) => child.stdout.once("data", r));
|
||||
readyMs = Math.round(performance.now() - t0);
|
||||
}
|
||||
await sleep(delayMs);
|
||||
const show = spawnSync("systemctl", ["--user", "show", "-p", "Id,LoadState,ActiveState,SubState,InvocationID,ControlGroup,Delegate", `${unit}.scope`], { encoding: "utf8" }).stdout.trim().split("\n");
|
||||
const before = procs(cg);
|
||||
const termMs = Math.round(performance.now() - t0);
|
||||
child.kill("SIGTERM");
|
||||
const exit = await Promise.race([exited, sleep(500).then(() => null)]);
|
||||
const after = procs(cg);
|
||||
if (!exit) child.kill("SIGKILL");
|
||||
console.log(JSON.stringify({ mode, delayMs, readyMs, termMs, child: child.pid, self: process.pid, show, before, after, childInBefore: before.includes(child.pid), childInAfter: after.includes(child.pid), exit: exit ?? "alive 500 ms after TERM" }));
|
||||
}
|
||||
|
||||
async function outer(mode, delayMs, runs) {
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const unit = `dw-r46-${mode}-${delayMs}-${process.pid}-${i}`;
|
||||
const r = spawnSync("systemd-run", ["--user", "--scope", "--quiet", "-p", "Delegate=yes", `--unit=${unit}`, "--", process.execPath, import.meta.filename, "--inner", mode, String(delayMs), unit], { encoding: "utf8" });
|
||||
process.stdout.write(r.stdout || `{"unit":"${unit}","status":${r.status},"stderr":${JSON.stringify(r.stderr)}}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const a = process.argv.slice(2);
|
||||
if (a[0] === "--inner") await inner(a[1], Number(a[2]), a[3]);
|
||||
else await outer(a[0], Number(a[1]), Number(a[2] ?? 5));
|
||||
@@ -1,9 +0,0 @@
|
||||
{"t":1791551027129,"ev":"spawn","pid":3646877,"ignoreTerm":true,"setsid":true}
|
||||
{"t":1791551027142,"ev":"term","pid":3646862}
|
||||
{"t":1791551027142,"ev":"term","pid":3646877}
|
||||
{"t":1791551029516,"ev":"spawn","pid":3647263,"ignoreTerm":true,"setsid":false}
|
||||
{"t":1791551029528,"ev":"term","pid":3647248}
|
||||
{"t":1791551029528,"ev":"term","pid":3647263}
|
||||
{"t":1791551031873,"ev":"spawn","pid":3647581,"ignoreTerm":true,"setsid":false}
|
||||
{"t":1791551031886,"ev":"term","pid":3647556}
|
||||
{"t":1791551031887,"ev":"term","pid":3647581}
|
||||
@@ -1,13 +0,0 @@
|
||||
{"t":1791550812775,"ev":"spawn","pid":3607494,"ignoreTerm":true,"setsid":true}
|
||||
{"t":1791550812853,"ev":"ready","pid":3607494}
|
||||
{"t":1791550812878,"ev":"term","pid":3607446}
|
||||
{"t":1791550812878,"ev":"term","pid":3607494}
|
||||
{"t":1791550815693,"ev":"spawn","pid":3607807,"ignoreTerm":true,"setsid":false}
|
||||
{"t":1791550815773,"ev":"ready","pid":3607807}
|
||||
{"t":1791550815779,"ev":"term","pid":3607781}
|
||||
{"t":1791550815780,"ev":"term","pid":3607807}
|
||||
{"t":1791550819446,"ev":"spawn","pid":3608344,"ignoreTerm":true,"setsid":false}
|
||||
{"t":1791550819489,"ev":"ready","pid":3608344}
|
||||
{"t":1791550819556,"ev":"term","pid":3608301}
|
||||
{"t":1791550819556,"ev":"term","pid":3608344}
|
||||
{"t":1791550820058,"ev":"term","pid":3608344}
|
||||
@@ -1,13 +0,0 @@
|
||||
{"t":1791550802745,"ev":"spawn","pid":3605642,"ignoreTerm":true,"setsid":true}
|
||||
{"t":1791550802763,"ev":"ready","pid":3605642}
|
||||
{"t":1791550802798,"ev":"term","pid":3605597}
|
||||
{"t":1791550802798,"ev":"term","pid":3605642}
|
||||
{"t":1791550805434,"ev":"spawn","pid":3606162,"ignoreTerm":true,"setsid":false}
|
||||
{"t":1791550805456,"ev":"ready","pid":3606162}
|
||||
{"t":1791550805492,"ev":"term","pid":3606153}
|
||||
{"t":1791550805492,"ev":"term","pid":3606162}
|
||||
{"t":1791550808090,"ev":"spawn","pid":3606502,"ignoreTerm":true,"setsid":false}
|
||||
{"t":1791550808106,"ev":"ready","pid":3606502}
|
||||
{"t":1791550808199,"ev":"term","pid":3606487}
|
||||
{"t":1791550808200,"ev":"term","pid":3606502}
|
||||
{"t":1791550808539,"ev":"term","pid":3606502}
|
||||
@@ -1,43 +0,0 @@
|
||||
diff --git a/packages/conversation/src/shim.mjs b/packages/conversation/src/shim.mjs
|
||||
index 2adbe48b..d51c4a1f 100644
|
||||
--- a/packages/conversation/src/shim.mjs
|
||||
+++ b/packages/conversation/src/shim.mjs
|
||||
@@ -22,7 +22,7 @@
|
||||
// `populated 0`. A missing or unreadable file is unavailable, never empty.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
-import { closeSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
+import { appendFileSync, closeSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
|
||||
@@ -136,6 +136,7 @@ async function handle(req) {
|
||||
if (startOf(pid) !== startTicks) continue;
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
+ if (process.env.DW_TRACE) appendFileSync(process.env.DW_TRACE, JSON.stringify({ t: Date.now(), ev: 'term', pid }) + '\n');
|
||||
signalled.push(pid);
|
||||
} catch {
|
||||
// gone already
|
||||
diff --git a/packages/conversation/tests/fake-pi.mjs b/packages/conversation/tests/fake-pi.mjs
|
||||
index b3014f86..f30f5a4c 100644
|
||||
--- a/packages/conversation/tests/fake-pi.mjs
|
||||
+++ b/packages/conversation/tests/fake-pi.mjs
|
||||
@@ -615,13 +615,16 @@ const children = [];
|
||||
// K2); `forkLoop` forks every 5 ms (K12); `ignoreTerm` survives SIGTERM, so
|
||||
// only the kill phase ends it (K3, K10, K11). With `pidLog`, the fork loop
|
||||
// appends each child's pid and a `term` line when it gets SIGTERM (K12).
|
||||
+import * as __fs from 'node:fs';
|
||||
+const require0 = () => __fs;
|
||||
function spawnChild({ setsid = false, forkLoop = false, ignoreTerm = false, pidLog = null } = {}) {
|
||||
const note = pidLog ? `const note=(s)=>require('node:fs').appendFileSync(${JSON.stringify(pidLog)},s+'\\n');` : "const note=()=>{};";
|
||||
- const code = note + (ignoreTerm ? "process.on('SIGTERM',()=>note('term'));" : "") + (forkLoop
|
||||
+ const code = note + (ignoreTerm ? "process.on('SIGTERM',()=>note('term'));" + (process.env.DW_TRACE ? `require('node:fs').appendFileSync(${JSON.stringify(process.env.DW_TRACE)},JSON.stringify({t:Date.now(),ev:'ready',pid:process.pid})+'\\n');` : "") : "") + (forkLoop
|
||||
? "const {spawn}=require('node:child_process');setInterval(()=>{try{const c=spawn('sleep',['1000'],{stdio:'ignore'});if(c.pid)note(String(c.pid))}catch{}},5);setInterval(()=>{},1e9)"
|
||||
: "setInterval(()=>{},1e9)");
|
||||
const child = spawn(process.execPath, ["-e", code], { stdio: "ignore", detached: setsid });
|
||||
children.push(child.pid);
|
||||
+ if (process.env.DW_TRACE) { require0().appendFileSync(process.env.DW_TRACE, JSON.stringify({ t: Date.now(), ev: 'spawn', pid: child.pid, ignoreTerm, setsid }) + '\n'); child.on('exit', (code, sig) => require0().appendFileSync(process.env.DW_TRACE, JSON.stringify({ t: Date.now(), ev: 'child-exit', pid: child.pid, code, sig }) + '\n')); }
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Independent acceptance checklist, row 18
|
||||
|
||||
Darkwing reviews Filbert's implementation without editing its source candidate.
|
||||
Dewey reviews visible connector presentation. No live connector manipulation.
|
||||
|
||||
- Discovery accepts only safe matching binding name/seat from private regular
|
||||
files, never dereferences a token path and never serializes private fields.
|
||||
- Path traversal, symlinked binding/runtime/session paths and malformed records
|
||||
cannot cause arbitrary reads or an actionable/live row.
|
||||
- No owner, malformed owner, dead PID, missing identity, reused PID and boot
|
||||
mismatch are non-live. A positively matching live process is live.
|
||||
- STOP presence is visible as braked independently of process liveness. Its
|
||||
contents are not read or exposed; no STOP or lock is created or changed.
|
||||
- Ordinary completed messages remain idle. No false human attention regression.
|
||||
- Connector rows cannot borrow a native agent's registration for replies.
|
||||
Exercise replyToRow and HTTP using a fake executable hook; every connector
|
||||
attempt must be refused before that hook runs, including with forged tmux
|
||||
registration. Normal-agent reply tests must still pass.
|
||||
- Both existing board and WebUI distinguish the connector and brake state and
|
||||
omit reply controls. Preserve escaping, including hostile binding fixtures.
|
||||
- Discovery errors disclose no private JSON fields or raw contents. One bad
|
||||
binding must not silently manufacture a healthy row.
|
||||
- Candidate pins match before and after tests. Existing dirty attention changes
|
||||
remain intact; no unrelated source integration or live operation is inferred.
|
||||
|
||||
After source approval, measure the real row read-only. Offline/braked behavior
|
||||
uses isolated fixtures unless the operator separately approves a live-service
|
||||
transition. Board replacement is its own protected gate.
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"at": "2026-09-14T13:51:10.530662+00:00",
|
||||
"backendPid": 3769124,
|
||||
"health": "ok",
|
||||
"row": {
|
||||
"agent": "sage (discord: shared-signals)",
|
||||
"project": "fleet",
|
||||
"state": "idle",
|
||||
"alive": true,
|
||||
"connector": {
|
||||
"binding": "shared-signals",
|
||||
"braked": false,
|
||||
"ownerState": "live",
|
||||
"alive": true
|
||||
},
|
||||
"task": "Discord connector",
|
||||
"taskSource": "connector"
|
||||
},
|
||||
"replyStatus": 409,
|
||||
"replyError": "board replies are disabled for Discord connectors",
|
||||
"fiveAgentPaneIdentitiesUnchanged": true,
|
||||
"connectorServiceIdentityUnchanged": true
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
{"at": "2026-09-14T13:50:24.078636+00:00", "event": "owner-authorized-restart-intent", "oldPid": 3204655, "agents": {"default/darkwing": [["2733924", "12863634"]], "default/dewey": [["934346", "466065"]], "default/filbert": [["72183", "100870"]], "default/researcher": [["173699", "66404285"]], "mosaic-fleet/rocko": [["90599", "128275"]]}, "connectorService": [3022843, "67887873"], "manifest": "254403b89c0a2330da53e8dbad1cbeba3b1b06cf4f3efddc18451e04cb78f6de"}
|
||||
{"at": "2026-09-14T13:50:24.503231+00:00", "event": "replacement-started", "oldExitedGracefully": true, "newPid": 3769124, "log": "/tmp/discord-board-backend-ovk_cahk.log"}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"at": "2026-09-14T01:10:19.375709+00:00",
|
||||
"candidate": "/tmp/discord-board-r1-KbMrGQWF",
|
||||
"manifestSha256": "5c92acc90d202727d790f3fb8d74387db1c9e5c3e43d4f4b56b40e5ae503a56a",
|
||||
"verdict": "CHANGES REQUIRED",
|
||||
"independentSerializedTests": 320,
|
||||
"finding": {
|
||||
"id": "R1-B1",
|
||||
"severity": "P2",
|
||||
"file": "packages/control-board/src/discord.mjs",
|
||||
"issue": "STOP metadata access errors collapse to absence, falsely projecting not braked",
|
||||
"reproduction": "Synthetic journal directory contains STOP, chmod directory to 000 as uid 1000, inspectDiscord returns braked:false, ownerState:invalid, alive:false. Restore permissions and remove fixture.",
|
||||
"expected": "braked:null/unknown when STOP existence cannot be established; false only for verified absence",
|
||||
"required": "Distinguish missing metadata from access errors and add non-root unreadable-directory regression."
|
||||
},
|
||||
"ux": "Dewey APPROVE on exact R1; three independent serialized browser tests passed, source/automation limitations retained",
|
||||
"parallelQualification": "Two author concurrent frozen timeouts remain unresolved and are not green; serialized independent run passed."
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"candidate": "R2",
|
||||
"syntheticOnly": true,
|
||||
"taskContainsEnvelopeAuthorId": true,
|
||||
"taskContainsEnvelopeMessageId": true,
|
||||
"taskSource": "first-user-message"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"at": "2026-09-14T01:26:42.688410+00:00",
|
||||
"candidate": "/tmp/discord-board-r3-U9vVrlQu",
|
||||
"manifestSha256": "254403b89c0a2330da53e8dbad1cbeba3b1b06cf4f3efddc18451e04cb78f6de",
|
||||
"reviewer": "Darkwing",
|
||||
"backendVerdict": "APPROVE AS SOURCE",
|
||||
"verified": "Nine working/frozen pins, exact three-file R2-to-R3 delta, inherited attention pins and full serialized six-package suite 322/322",
|
||||
"findingsClosed": [
|
||||
"R1-B1: inaccessible STOP is unknown, non-root regression passes",
|
||||
"R2-B2: canonical routing envelope no longer becomes connector Task; ordinary fallback retained"
|
||||
],
|
||||
"limitations": [
|
||||
"No generalized transcript redaction",
|
||||
"R1 concurrent combined frozen timeouts unresolved/not green",
|
||||
"No live observation, backend restart, connector change or publication in this review"
|
||||
],
|
||||
"uxGate": "Await exact R3 confirmation from Dewey via agent-send"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"at": "2026-09-14T01:28:17.695Z",
|
||||
"sourceApproval": 26257,
|
||||
"readOnly": true,
|
||||
"agent": "sage (discord: shared-signals)",
|
||||
"project": "fleet",
|
||||
"state": "idle",
|
||||
"alive": true,
|
||||
"connector": {
|
||||
"binding": "shared-signals",
|
||||
"braked": false,
|
||||
"ownerState": "live",
|
||||
"alive": true
|
||||
},
|
||||
"task": "Discord connector",
|
||||
"taskSource": "connector",
|
||||
"registrationAbsent": true,
|
||||
"ownerMatchesService": true,
|
||||
"discoveryErrorCount": 0
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"at": "2026-09-14T00:50:35.339Z",
|
||||
"sourceSha256": "dfbb7ab9374c0ac9fafa0503f495abd938f499f5d6227233de03a60ea3022927",
|
||||
"fixture": "connector row with forged native registration",
|
||||
"status": 200,
|
||||
"fakeTransportCalls": 1,
|
||||
"realTransportCalls": 0,
|
||||
"gatePassed": false
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
# Discord engine: guaranteed test cleanup and the timeout gap in `busy` (#1509), R2 candidate
|
||||
|
||||
Sage assigned this on 2026-09-26 as 6b, source only. Rocko reviews. Base is
|
||||
HEAD 401cc850. Not committed. The live connector runs from this checkout, so
|
||||
Sage is holding its restart until this is approved and committed. Nobody
|
||||
should restart it from a working copy.
|
||||
|
||||
## Defects (DEFERRED Open, "Discord engine: leaked fake pi…")
|
||||
|
||||
(a) `engine.test.mjs` read the fake's `commands.jsonl` 20 ms after a prompt and
|
||||
got ENOENT under load. Seven tests stopped the engine outside `finally`, so a
|
||||
failed assertion left the fake pi running and the test file never exited.
|
||||
|
||||
(b) `busy` was `state.busy || pending.some((t) => !t.done)`. If a turn timed out
|
||||
before its `agent_start` was read, it was done while `state.busy` was still
|
||||
false. The next prompt then went straight to pi, which refused it as
|
||||
streaming.
|
||||
|
||||
## R1 and Rocko's finding
|
||||
|
||||
R1 held later prompts behind a failed turn. If pi had sent no `agent_start` for
|
||||
it within a grace period, R1 dropped that turn from the queue and sent the next
|
||||
prompt. Rocko rejected it (F1, High), in
|
||||
`agents/rocko/work/discord-engine-busy-r1-review-2026-09-26.md`, sha256
|
||||
047dbd8f.
|
||||
|
||||
Pi's events carry no prompt id. The engine attributes them to the front of its
|
||||
queue. Silence until the grace ends does not prove the old run will never come.
|
||||
If pi then runs it, its events land on the new prompt, which R1 had just put at
|
||||
the front. Rocko's reproducer got the old run's answer and its `old.md` tool
|
||||
record back as the new prompt's result. My R1 README said such events "find no
|
||||
live head and are dropped". That was wrong.
|
||||
|
||||
The R1 files stay here as `r1-manifest.sha256` and `r1.patch`.
|
||||
|
||||
## Change (R2)
|
||||
|
||||
`packages/discord/src/engine-pi.mjs`:
|
||||
- `engineBusy()` is `state.busy || state.pending.length > 0`. A failed turn
|
||||
still in the queue holds the next prompt back, and stays at the front, so any
|
||||
late events for it land on it. `prompt()`, `sendHeld()` and the `busy` getter
|
||||
use it. This part is unchanged from R1.
|
||||
- The bound is now a stop, not a drop. When a turn fails while it is still in
|
||||
the queue, `failTurn` starts a timer, `abortGraceMs` (default
|
||||
`ABORT_GRACE_MS`, 30 s, an engine option, not binding config). When it
|
||||
fires:
|
||||
- If pi has sent `agent_start` (`state.busy`), nothing happens. That run
|
||||
ends on its `agent_end` or a settle, as on HEAD.
|
||||
- Otherwise `wedge()` sets `state.wedged`, fails every held prompt with
|
||||
code `engine-wedged`, and stops pi: stdin closed, SIGTERM, then SIGKILL
|
||||
after 5 s. The failed turn stays at the front until the exit.
|
||||
- While wedged, nothing is written to that child. `write()`, `sendHeld()` and
|
||||
`prompt()` refuse, and a new prompt fails at once with `engine-down`. The exit
|
||||
runs the usual `failAll` and `onExit`.
|
||||
- `stop()`'s body moved into `stopChild()`, which both `stop()` and `wedge()`
|
||||
call.
|
||||
- `release()` clears the timer wherever a turn leaves the queue: `agent_end`,
|
||||
settle, a refused send, and process exit. As in R1, the settle handler removes
|
||||
turns before failing them.
|
||||
|
||||
What recovery looks like live: `cli.mjs` handles `onExit` with `shutdown(1)`.
|
||||
The unit's `Restart=on-failure` starts a new connector and a new pi 15 s later,
|
||||
within its limit of five tries in ten minutes. This change doesn't touch the
|
||||
unit or the restart policy. A wedge now costs one connector restart. R1 would
|
||||
have kept the same pi and risked a wrong answer.
|
||||
|
||||
`packages/discord/tests/fake-pi.mjs`:
|
||||
- `mute`: accepted and never run.
|
||||
- `stall <ms>`: accepted, then the fake reads nothing for `<ms>`, runs the
|
||||
stalled prompt, and only then reads what came in meanwhile. This is the
|
||||
order in Rocko's case.
|
||||
|
||||
`packages/discord/tests/engine.test.mjs`:
|
||||
- `withEngine()` stops the engine in `finally`. Every test that starts an
|
||||
engine uses it, or has its own `try/finally` in the exit test.
|
||||
- `commands()` returns `[]` until the fake creates its log. The held-prompt
|
||||
test waits for the first prompt with `until()` instead of a 20 ms sleep, and
|
||||
its first prompt is `slow 300`.
|
||||
- The manual-timer test fires the turn timer before any pi event is read. The
|
||||
next prompt must wait for the settle and get its own answer.
|
||||
- New or changed for R2:
|
||||
- `mute` with `abortGraceMs: 150`. The held prompt fails with
|
||||
`engine-wedged` after the grace, a later prompt fails with
|
||||
`engine-down`, `onExit` fires, and pi saw only `mute` and `abort`.
|
||||
- `stall 400` with the same grace, which is Rocko's case with a real
|
||||
child. The held prompt fails with `engine-wedged`, pi exits, and "after
|
||||
stall" never reaches pi.
|
||||
- Rocko's reproducer as an in-memory test, run twice. The old prompt's
|
||||
response comes either before its timeout or only with the late events.
|
||||
After the grace, the old run's start, tool pair, answer, end and settle
|
||||
arrive while pi is still exiting. The held prompt stays failed with
|
||||
`engine-wedged` and a later prompt fails with `engine-down`. Pi saw only
|
||||
`old` and `abort`, then SIGTERM, then SIGKILL at 5 s. Only the exit
|
||||
reaches `onExit`. The test reads recorded outcomes after a tick instead
|
||||
of awaiting, so a regression fails instead of hanging.
|
||||
- `late 400` with the same grace. Pi started that run, so the grace does
|
||||
not stop pi, and the next prompt gets its own answer when the run ends.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `engine.test.mjs`: 17/17.
|
||||
- R1's engine (d5bf24b5, from `r1.patch`) against these tests fails 4: `mute`,
|
||||
`stall`, and both in-memory runs. In that run a probe shows R1 answering
|
||||
"after stall" with "echo: stalled". An earlier draft of the in-memory test
|
||||
awaited the held prompt and hung on R1 until the 120 s cap. It now fails in
|
||||
milliseconds.
|
||||
- HEAD's engine against these tests fails 5: the manual-timer test and the
|
||||
same four.
|
||||
- Mutations of R2:
|
||||
- Without the `state.busy` check, the `late 400` test fails.
|
||||
- Without the `wedge()` call, 4 fail.
|
||||
- Without failing held prompts in `wedge()`, 4 fail.
|
||||
- Test union (control-board, webui, seat, mosaic, ledger, discord) at default
|
||||
concurrency on `git archive` of 401cc850 plus the three files: 406/406
|
||||
three times, 23 to 24 s each. No fake pi was left running.
|
||||
- Eight suites green on that snapshot: config 24, task 90, foundation 43,
|
||||
conductor 17, release 14, auth 15, discord 63, extension-package 18.
|
||||
|
||||
Logs: `/tmp/dw-6b-r2-conc-{1,2,3}.txt`. R1's evidence runs:
|
||||
`/tmp/dw-6b-conc-{1,2,3}.txt`, `/tmp/dw-6b-serial.txt`. HEAD's hang control:
|
||||
`/tmp/dw-1509-headctl-{1,2,3}.txt`, `/tmp/dw-1509-ef00-1.txt`. There, HEAD hit
|
||||
the 240 s cap at 199 ok under the union's load.
|
||||
|
||||
## Not covered
|
||||
|
||||
- A run pi started and never ends, even after abort, still holds prompts
|
||||
until pi settles or exits. Each held prompt fails at its own timeout ("while
|
||||
waiting for the engine"). HEAD behaves the same way through `state.busy`, and
|
||||
Rocko did not block on it. Only a pi restart clears it.
|
||||
- A wedge ends the connector process, and the recovery is systemd's restart.
|
||||
Nothing here changes the unit, and the restart limit still applies.
|
||||
- No live restart, and no change to the binding schema.
|
||||
|
||||
## Frozen files
|
||||
|
||||
`r2-manifest.sha256` holds the three R2 hashes. `r2.patch` is `git diff
|
||||
packages/discord` at freeze time.
|
||||
|
||||
## Review
|
||||
|
||||
Rocko, R1, 2026-09-26: request changes, F1 High, as described above. Report:
|
||||
`agents/rocko/work/discord-engine-busy-r1-review-2026-09-26.md`, sha256
|
||||
047dbd8f.
|
||||
|
||||
Rocko, R2, 2026-09-26: approved the three pinned files. Report:
|
||||
`agents/rocko/work/discord-engine-busy-r2-review-2026-09-26.md`, sha256
|
||||
ed5510a0. He checked the manifests before and after, ran 17/17 himself, and
|
||||
read the CLI shutdown path, `connector.stop` and the unit template. Sage asked
|
||||
him three operational questions:
|
||||
- A wedge exits 1, never 3. Exit 3 remains the supervised startup refusal.
|
||||
- The unit's start limit (5 starts in 600 s) is a rate limit. It does not
|
||||
bound repeated wedges. With the default 180 s turn timeout, the 30 s grace
|
||||
and the 15 s restart delay, a cycle takes at least 225 s. That stays under
|
||||
the limit, so a pi that wedges every time could restart indefinitely.
|
||||
Stopping for good after repeated wedges would need a separate policy. This
|
||||
change does not add one.
|
||||
- He recommends, as a nonblocking follow-up, that the connector journal
|
||||
record at startup: HEAD, dirty state scoped to runtime source, and a digest
|
||||
of the runtime files. A wedge restart loads whatever the checkout holds.
|
||||
|
||||
This section was added after approval, so the README hash no longer matches
|
||||
the one Rocko pinned (69350f29). The three source files are unchanged.
|
||||
@@ -1,3 +0,0 @@
|
||||
d5bf24b59c07c85067f4087c03b54ca8b4df923c1d591441dedd2e8a7ff2ae39 packages/discord/src/engine-pi.mjs
|
||||
f0abee9c243d46d66dd2271abc3fd89089c350ac6a66ab49131bce80adfcdc33 packages/discord/tests/engine.test.mjs
|
||||
fa1bf44e3f33eb970a714ada1c686abbc1932baaf418679276edf8813abbe6de packages/discord/tests/fake-pi.mjs
|
||||
@@ -1,414 +0,0 @@
|
||||
diff --git a/packages/discord/src/engine-pi.mjs b/packages/discord/src/engine-pi.mjs
|
||||
index 5c8fd0a9..9dcaeb42 100644
|
||||
--- a/packages/discord/src/engine-pi.mjs
|
||||
+++ b/packages/discord/src/engine-pi.mjs
|
||||
@@ -16,9 +16,12 @@
|
||||
// from `tool_execution_start`/`tool_execution_end` into the result so the
|
||||
// turn record shows what was read. An `agent_end` with `willRetry` is not
|
||||
// the end of the run. A timeout sends `abort` and fails that turn; the
|
||||
-// process stays. A malformed JSONL line from pi fails the current turn (its
|
||||
-// outcome is now unknowable) and the process stays. Process exit fails
|
||||
-// every pending turn and is reported through `onExit`.
|
||||
+// process stays. The failed turn holds later prompts back until its
|
||||
+// agent_end or a settle. If pi has not started it within ABORT_GRACE_MS, it
|
||||
+// is dropped and the next prompt goes out; a run pi did start holds them
|
||||
+// until it ends, as any run does. A malformed JSONL line from pi fails the
|
||||
+// current turn (its outcome is now unknowable) and the process stays.
|
||||
+// Process exit fails every pending turn and is reported through `onExit`.
|
||||
//
|
||||
// Framing follows pi's RPC doc: split on "\n" only, strip a trailing "\r".
|
||||
// Node readline is not used because it also splits on U+2028/U+2029.
|
||||
@@ -64,12 +67,19 @@ export function assistantText(message) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
+// How long a turn that failed here (timeout, protocol error) may wait for
|
||||
+// pi's agent_start before it stops holding the next prompt back. Without a
|
||||
+// bound, a prompt pi accepted but never ran would queue every later prompt
|
||||
+// until restart.
|
||||
+export const ABORT_GRACE_MS = 30000;
|
||||
+
|
||||
export function createEngine({
|
||||
command, args, cwd, env = {},
|
||||
spawn = nodeSpawn,
|
||||
setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout,
|
||||
log = () => {},
|
||||
onExit = () => {},
|
||||
+ abortGraceMs = ABORT_GRACE_MS,
|
||||
} = {}) {
|
||||
if (typeof command !== "string" || command.length === 0) throw new DiscordError("engine: command required", 1);
|
||||
if (!Array.isArray(args)) throw new DiscordError("engine: args required", 1);
|
||||
@@ -80,15 +90,35 @@ export function createEngine({
|
||||
|
||||
// A turn that fails on the client side (timeout, protocol error) stays in
|
||||
// the pending queue, marked done, until pi's own turn_end for it arrives.
|
||||
- // Otherwise that turn_end would be attributed to the next prompt.
|
||||
+ // Otherwise that turn_end would be attributed to the next prompt. It holds
|
||||
+ // later prompts back; if pi has not started it within abortGraceMs, it goes.
|
||||
function failTurn(turn, code, message) {
|
||||
if (turn.done) return;
|
||||
turn.done = true;
|
||||
if (turn.timer !== null) clearTimeoutImpl(turn.timer);
|
||||
turn.timer = null;
|
||||
+ if (state.pending.includes(turn)) {
|
||||
+ turn.grace = setTimeoutImpl(() => {
|
||||
+ turn.grace = null;
|
||||
+ // No agent_start by now: pi never started this run and will send no
|
||||
+ // agent_end for it, so it leaves the queue and cannot take the next
|
||||
+ // prompt's. A run pi did start keeps its place until it ends.
|
||||
+ if (!state.busy) {
|
||||
+ const i = state.pending.indexOf(turn);
|
||||
+ if (i !== -1) state.pending.splice(i, 1);
|
||||
+ }
|
||||
+ sendHeld();
|
||||
+ }, abortGraceMs);
|
||||
+ }
|
||||
turn.reject(new DiscordError(message, 1, { code }));
|
||||
}
|
||||
|
||||
+ // Call when a turn leaves the pending queue.
|
||||
+ function release(turn) {
|
||||
+ if (turn.grace !== null) clearTimeoutImpl(turn.grace);
|
||||
+ turn.grace = null;
|
||||
+ }
|
||||
+
|
||||
function settleTurn(turn, value) {
|
||||
if (turn.done) return;
|
||||
turn.done = true;
|
||||
@@ -99,7 +129,10 @@ export function createEngine({
|
||||
|
||||
function failAll(code, message) {
|
||||
const pending = state.pending.splice(0);
|
||||
- for (const t of pending) failTurn(t, code, message);
|
||||
+ for (const t of pending) {
|
||||
+ release(t);
|
||||
+ failTurn(t, code, message);
|
||||
+ }
|
||||
for (const h of state.held.splice(0)) failTurn(h.turn, code, message);
|
||||
for (const [, r] of state.responses) r.reject(new DiscordError(message, 1, { code }));
|
||||
state.responses.clear();
|
||||
@@ -174,6 +207,7 @@ export function createEngine({
|
||||
// Attribute the run to the head even if it failed client-side, so the
|
||||
// next prompt's agent_end is not taken for this one.
|
||||
const run = state.pending.shift();
|
||||
+ if (run) release(run);
|
||||
if (!run || run.done) return;
|
||||
const messages = Array.isArray(event.messages) ? event.messages.filter((m) => m && m.role === "assistant") : [];
|
||||
const message = messages.length > 0 ? messages[messages.length - 1] : run.last;
|
||||
@@ -193,13 +227,14 @@ export function createEngine({
|
||||
// this settle and still has no agent_end will never get one: fail it now
|
||||
// instead of waiting for its timeout. Turns whose prompt response has
|
||||
// not arrived yet belong to a later run and stay.
|
||||
+ const dropped = [];
|
||||
const keep = [];
|
||||
- for (const t of state.pending) {
|
||||
- if (t.done) continue;
|
||||
- if (t.accepted) failTurn(t, "engine-settled-without-turn", "engine settled without answering this prompt");
|
||||
- else keep.push(t);
|
||||
- }
|
||||
+ for (const t of state.pending) (t.done || t.accepted ? dropped : keep).push(t);
|
||||
state.pending = keep;
|
||||
+ for (const t of dropped) {
|
||||
+ release(t);
|
||||
+ failTurn(t, "engine-settled-without-turn", "engine settled without answering this prompt");
|
||||
+ }
|
||||
sendHeld();
|
||||
}
|
||||
}
|
||||
@@ -214,15 +249,23 @@ export function createEngine({
|
||||
// Never accepted: pi will not emit a turn_end for it, so remove it.
|
||||
const i = state.pending.indexOf(turn);
|
||||
if (i !== -1) state.pending.splice(i, 1);
|
||||
+ release(turn);
|
||||
failTurn(turn, (err.details && err.details.code) || "engine-refused", err.message);
|
||||
sendHeld();
|
||||
});
|
||||
}
|
||||
|
||||
+ // Pi is busy from our side while any sent prompt is still queued, even one
|
||||
+ // that already failed here: a turn that timed out before its agent_start
|
||||
+ // was read leaves state.busy false while pi runs it, and sending then would
|
||||
+ // be refused as streaming. It leaves the queue on its agent_end, on a
|
||||
+ // settle, on a refused send, or when its grace ends before pi started it.
|
||||
+ const engineBusy = () => state.busy || state.pending.length > 0;
|
||||
+
|
||||
// After a settle (or a refused send) the oldest held prompt goes out.
|
||||
function sendHeld() {
|
||||
if (state.exited !== null) return;
|
||||
- if (state.busy || state.pending.some((t) => !t.done)) return;
|
||||
+ if (engineBusy()) return;
|
||||
const next = state.held.shift();
|
||||
if (next) send(next.turn, next.command);
|
||||
}
|
||||
@@ -281,7 +324,7 @@ export function createEngine({
|
||||
// with DiscordError carrying details.code for the turn record.
|
||||
prompt(text, { timeoutMs = 180000 } = {}) {
|
||||
if (typeof text !== "string" || text.length === 0) throw new DiscordError("prompt text required", 1);
|
||||
- const turn = { resolve: null, reject: null, timer: null, done: false, accepted: false, tools: new Map(), turns: 0, last: null };
|
||||
+ const turn = { resolve: null, reject: null, timer: null, grace: null, done: false, accepted: false, tools: new Map(), turns: 0, last: null };
|
||||
const done = new Promise((resolve, reject) => {
|
||||
turn.resolve = resolve;
|
||||
turn.reject = reject;
|
||||
@@ -310,13 +353,13 @@ export function createEngine({
|
||||
failTurn(turn, "engine-down", "engine is not running");
|
||||
return done;
|
||||
}
|
||||
- if (state.busy || state.pending.some((t) => !t.done) || state.held.length > 0) state.held.push({ turn, command });
|
||||
+ if (engineBusy() || state.held.length > 0) state.held.push({ turn, command });
|
||||
else send(turn, command);
|
||||
return done;
|
||||
},
|
||||
|
||||
get busy() {
|
||||
- return state.busy || state.pending.some((t) => !t.done) || state.held.length > 0;
|
||||
+ return engineBusy() || state.held.length > 0;
|
||||
},
|
||||
get pendingCount() {
|
||||
return state.pending.filter((t) => !t.done).length + state.held.length;
|
||||
diff --git a/packages/discord/tests/engine.test.mjs b/packages/discord/tests/engine.test.mjs
|
||||
index 59674d1e..62dd6017 100644
|
||||
--- a/packages/discord/tests/engine.test.mjs
|
||||
+++ b/packages/discord/tests/engine.test.mjs
|
||||
@@ -28,7 +28,20 @@ function start(root, extra = {}) {
|
||||
log: (m) => logs.push(m), ...extra,
|
||||
});
|
||||
engine.start();
|
||||
- return { engine, logs, commands: () => readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) };
|
||||
+ // The fake creates its log on the first command; until then there are none.
|
||||
+ const commands = () => (existsSync(logPath) ? readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []);
|
||||
+ return { engine, logs, commands };
|
||||
+}
|
||||
+
|
||||
+// Every test stops its engine in finally: a fake pi left running after a
|
||||
+// failed assertion keeps the test file from exiting.
|
||||
+async function withEngine(extra, body) {
|
||||
+ const started = start(makeRoot(), extra);
|
||||
+ try {
|
||||
+ await body(started);
|
||||
+ } finally {
|
||||
+ await started.engine.stop();
|
||||
+ }
|
||||
}
|
||||
|
||||
test("engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file", () => {
|
||||
@@ -56,8 +69,7 @@ test("engine: with tools, buildPiArgs turns pi's own tools off, loads the extens
|
||||
assert.equal(rw[rw.indexOf("--tools") + 1], "list_dir,read_file,search,write_file,edit_file", "a writable root adds exactly the two write tools");
|
||||
});
|
||||
|
||||
-test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", async () => {
|
||||
- const { engine } = start(makeRoot());
|
||||
+test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", () => withEngine({}, async ({ engine }) => {
|
||||
const r = await engine.prompt("tools 3");
|
||||
assert.equal(r.text, "read 3 file(s)");
|
||||
assert.equal(r.turns, 2);
|
||||
@@ -71,45 +83,38 @@ test("engine: a run with tool turns settles once, on the answer, with every tool
|
||||
assert.equal(plain.turns, 1);
|
||||
await idle(engine);
|
||||
assert.equal(engine.busy, false);
|
||||
- await engine.stop();
|
||||
-});
|
||||
+}));
|
||||
|
||||
-test("engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end", async () => {
|
||||
- const { engine } = start(makeRoot());
|
||||
+test("engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end", () => withEngine({}, async ({ engine }) => {
|
||||
const r = await engine.prompt("toolonly");
|
||||
assert.equal(r.text, "", "no text: the connector turns this into engine-empty");
|
||||
assert.equal(r.tools.length, 1);
|
||||
const again = await engine.prompt("retry");
|
||||
assert.equal(again.text, "after retry");
|
||||
- await engine.stop();
|
||||
-});
|
||||
+}));
|
||||
|
||||
-test("engine: one prompt, one turn, text and usage come back", async () => {
|
||||
- const { engine } = start(makeRoot());
|
||||
- try {
|
||||
- const r = await engine.prompt("hello");
|
||||
- assert.equal(r.text, "echo: hello");
|
||||
- assert.deepEqual(r.usage, { input: 3, output: 2 });
|
||||
- await idle(engine);
|
||||
- assert.equal(engine.busy, false);
|
||||
- } finally {
|
||||
- await engine.stop();
|
||||
- }
|
||||
-});
|
||||
+test("engine: one prompt, one turn, text and usage come back", () => withEngine({}, async ({ engine }) => {
|
||||
+ const r = await engine.prompt("hello");
|
||||
+ assert.equal(r.text, "echo: hello");
|
||||
+ assert.deepEqual(r.usage, { input: 3, output: 2 });
|
||||
+ await idle(engine);
|
||||
+ assert.equal(engine.busy, false);
|
||||
+}));
|
||||
|
||||
-test("engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order", async () => {
|
||||
- const { engine, commands } = start(makeRoot());
|
||||
- const first = engine.prompt("slow 150");
|
||||
- await new Promise((r) => setTimeout(r, 20));
|
||||
+test("engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order", () => withEngine({}, async ({ engine, commands }) => {
|
||||
+ const first = engine.prompt("slow 300");
|
||||
assert.equal(engine.busy, true);
|
||||
const second = engine.prompt("second");
|
||||
assert.equal(engine.pendingCount, 2);
|
||||
- await new Promise((r) => setTimeout(r, 20));
|
||||
- assert.equal(commands().filter((c) => c.type === "prompt").length, 1, "the second prompt is not sent while pi is busy");
|
||||
+ const prompted = () => commands().filter((c) => c.type === "prompt");
|
||||
+ assert.ok(await until(() => prompted().length > 0), "the first prompt reached pi");
|
||||
+ assert.equal(prompted().length, 1, "the second prompt is not sent while pi is busy");
|
||||
+ // The fake refuses a prompt without streamingBehavior while it runs one, so
|
||||
+ // an answered second prompt also proves it was not sent early.
|
||||
const [r1, r2] = await Promise.all([first, second]);
|
||||
assert.equal(r1.text, "slow reply");
|
||||
assert.equal(r2.text, "echo: second");
|
||||
- const prompts = commands().filter((c) => c.type === "prompt");
|
||||
+ const prompts = prompted();
|
||||
assert.equal(prompts.length, 2);
|
||||
// Never a pi follow-up: pi would fold it into the first run and close both
|
||||
// answers with one agent_end (the live loss of 2026-09-17).
|
||||
@@ -117,11 +122,9 @@ test("engine: a prompt while streaming is held until pi settles, then sent as it
|
||||
assert.equal(prompts[1].streamingBehavior, undefined);
|
||||
await idle(engine);
|
||||
assert.equal(engine.busy, false);
|
||||
- await engine.stop();
|
||||
-});
|
||||
+}));
|
||||
|
||||
-test("engine: a held prompt that times out before pi settles fails on its own and is never sent", async () => {
|
||||
- const { engine, commands } = start(makeRoot());
|
||||
+test("engine: a held prompt that times out before pi settles fails on its own and is never sent", () => withEngine({}, async ({ engine, commands }) => {
|
||||
const first = engine.prompt("slow 200");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await assert.rejects(engine.prompt("late one", { timeoutMs: 50 }), (e) => e.details.code === "timeout" && /waiting for the engine/.test(e.message));
|
||||
@@ -130,50 +133,85 @@ test("engine: a held prompt that times out before pi settles fails on its own an
|
||||
await idle(engine);
|
||||
assert.deepEqual(commands().filter((c) => c.type === "prompt").map((c) => c.message), ["slow 200"]);
|
||||
assert.deepEqual(commands().filter((c) => c.type === "abort"), [], "a held turn is not aborted; pi never had it");
|
||||
- await engine.stop();
|
||||
-});
|
||||
+}));
|
||||
|
||||
-test("engine: timeout sends abort and fails only that turn; the process stays", async () => {
|
||||
- const { engine, commands, logs } = start(makeRoot());
|
||||
+test("engine: timeout sends abort and fails only that turn; the process stays", () => withEngine({}, async ({ engine, commands, logs }) => {
|
||||
await assert.rejects(engine.prompt("slow 5000", { timeoutMs: 100 }), (err) => err.details.code === "timeout");
|
||||
assert.ok(await until(() => commands().some((c) => c.type === "abort")), "abort reached pi");
|
||||
assert.ok(logs.some((l) => /timed out/.test(l)));
|
||||
const r = await engine.prompt("again");
|
||||
assert.equal(r.text, "echo: again");
|
||||
- await engine.stop();
|
||||
+}));
|
||||
+
|
||||
+test("engine: tool events from a run that outlived its timeout never land in the next prompt's record", () => withEngine({}, async ({ engine }) => {
|
||||
+ await assert.rejects(engine.prompt("late 200", { timeoutMs: 40 }), (err) => err.details.code === "timeout");
|
||||
+ const r = await engine.prompt("after late");
|
||||
+ assert.equal(r.text, "echo: after late");
|
||||
+ assert.deepEqual(r.tools, [], "the dead run's read is not this prompt's evidence");
|
||||
+ assert.equal(r.turns, 1, "the dead run's turns are not counted here");
|
||||
+}));
|
||||
+
|
||||
+// The turn timer is fired by hand, before the engine has read any event from
|
||||
+// pi, so the timed-out run is still pi's and state.busy is still false when
|
||||
+// the next prompt arrives. Under load a real timer does the same.
|
||||
+const TURN_MS = 60000;
|
||||
+const manualTurnTimer = (fire) => ({
|
||||
+ setTimeoutImpl: (fn, ms) => (ms === TURN_MS ? fire.push(fn) : setTimeout(fn, ms)),
|
||||
+ clearTimeoutImpl: (id) => { if (typeof id !== "number") clearTimeout(id); },
|
||||
});
|
||||
|
||||
-test("engine: tool events from a run that outlived its timeout never land in the next prompt's record", async () => {
|
||||
- const { engine } = start(makeRoot());
|
||||
- try {
|
||||
- await assert.rejects(engine.prompt("late 200", { timeoutMs: 40 }), (err) => err.details.code === "timeout");
|
||||
- const r = await engine.prompt("after late");
|
||||
+test("engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused", () => {
|
||||
+ const fire = [];
|
||||
+ return withEngine(manualTurnTimer(fire), async ({ engine, commands }) => {
|
||||
+ const late = engine.prompt("late 100", { timeoutMs: TURN_MS });
|
||||
+ fire.shift()();
|
||||
+ assert.equal(engine.busy, true, "pi is still running the prompt that timed out");
|
||||
+ const next = engine.prompt("after late", { timeoutMs: 5000 });
|
||||
+ assert.equal(engine.pendingCount, 1, "only the new prompt is live");
|
||||
+ await assert.rejects(late, (err) => err.details.code === "timeout");
|
||||
+ const r = await next;
|
||||
assert.equal(r.text, "echo: after late");
|
||||
assert.deepEqual(r.tools, [], "the dead run's read is not this prompt's evidence");
|
||||
- assert.equal(r.turns, 1, "the dead run's turns are not counted here");
|
||||
- } finally {
|
||||
- await engine.stop();
|
||||
- }
|
||||
+ assert.equal(r.turns, 1);
|
||||
+ assert.deepEqual(commands().map((c) => (c.type === "prompt" ? c.message : c.type)), ["late 100", "abort", "after late"]);
|
||||
+ await idle(engine);
|
||||
+ assert.equal(engine.busy, false);
|
||||
+ });
|
||||
});
|
||||
|
||||
-test("engine: a malformed JSONL line fails the turn, not the process", async () => {
|
||||
- const { engine, logs } = start(makeRoot());
|
||||
+// "mute" is accepted and never run, so no agent_start, agent_end or settle
|
||||
+// ever comes for it. Unbounded, it would hold every later prompt.
|
||||
+test("engine: a timed-out turn pi never started holds the next prompt only for the abort grace, then leaves the queue", () => withEngine({ abortGraceMs: 150 }, async ({ engine, commands }) => {
|
||||
+ await assert.rejects(engine.prompt("mute", { timeoutMs: 50 }), (err) => err.details.code === "timeout");
|
||||
+ assert.equal(engine.busy, true, "pi might still be running it");
|
||||
+ const started = Date.now();
|
||||
+ const r = await engine.prompt("after mute", { timeoutMs: 5000 });
|
||||
+ assert.equal(r.text, "echo: after mute");
|
||||
+ assert.ok(Date.now() - started >= 100, "held for the grace, not sent at once");
|
||||
+ assert.deepEqual(commands().map((c) => (c.type === "prompt" ? c.message : c.type)), ["mute", "abort", "after mute"]);
|
||||
+ await idle(engine);
|
||||
+ assert.equal(engine.busy, false);
|
||||
+}));
|
||||
+
|
||||
+test("engine: a malformed JSONL line fails the turn, not the process", () => withEngine({}, async ({ engine, logs }) => {
|
||||
await assert.rejects(engine.prompt("garbage"), (err) => err.details.code === "engine-protocol");
|
||||
assert.ok(logs.some((l) => /malformed/.test(l)));
|
||||
const r = await engine.prompt("still here");
|
||||
assert.equal(r.text, "echo: still here");
|
||||
- await engine.stop();
|
||||
-});
|
||||
+}));
|
||||
|
||||
test("engine: a turn that ends in error rejects with the error code; process exit fails pending turns", async () => {
|
||||
- const root = makeRoot();
|
||||
let exited = null;
|
||||
- const { engine } = start(root, { onExit: (e) => (exited = e) });
|
||||
- await assert.rejects(engine.prompt("error"), (err) => err.details.code === "engine-error" && /fake provider error/.test(err.message));
|
||||
- const pending = engine.prompt("slow 5000");
|
||||
- await new Promise((r) => setTimeout(r, 20));
|
||||
- await engine.stop();
|
||||
- await assert.rejects(pending, (err) => err.details.code === "engine-down");
|
||||
- assert.ok(exited);
|
||||
- await assert.rejects(engine.prompt("x"), /not running/);
|
||||
+ const { engine } = start(makeRoot(), { onExit: (e) => (exited = e) });
|
||||
+ try {
|
||||
+ await assert.rejects(engine.prompt("error"), (err) => err.details.code === "engine-error" && /fake provider error/.test(err.message));
|
||||
+ const pending = engine.prompt("slow 5000");
|
||||
+ await new Promise((r) => setTimeout(r, 20));
|
||||
+ await engine.stop();
|
||||
+ await assert.rejects(pending, (err) => err.details.code === "engine-down");
|
||||
+ assert.ok(exited);
|
||||
+ await assert.rejects(engine.prompt("x"), /not running/);
|
||||
+ } finally {
|
||||
+ await engine.stop();
|
||||
+ }
|
||||
});
|
||||
diff --git a/packages/discord/tests/fake-pi.mjs b/packages/discord/tests/fake-pi.mjs
|
||||
index 94919306..ecc04e3b 100644
|
||||
--- a/packages/discord/tests/fake-pi.mjs
|
||||
+++ b/packages/discord/tests/fake-pi.mjs
|
||||
@@ -8,6 +8,7 @@
|
||||
// then a second turn that answers "read <n> file(s)"
|
||||
// "toolonly" a run whose only turn calls a tool and never answers
|
||||
// "retry" an agent_end with willRetry, then the real answer
|
||||
+// "mute" accept the prompt and emit nothing, staying idle
|
||||
// "late <ms>" ignore abort; after <ms> emit a tool pair and a tool turn,
|
||||
// then answer "late reply", like a run that outlives its
|
||||
// client-side timeout
|
||||
@@ -30,6 +31,7 @@ function assistant(text, stopReason = "stop") {
|
||||
}
|
||||
|
||||
function run(text) {
|
||||
+ if (text === "mute") return;
|
||||
busy = true;
|
||||
out({ type: "agent_start" });
|
||||
out({ type: "turn_start" });
|
||||
@@ -1,3 +0,0 @@
|
||||
77077b7fbd5a933ffd352094eb073227c299ba47b7aea52d4e60fdc55cc7101e packages/discord/src/engine-pi.mjs
|
||||
47a998179c6eb46827f43ab2c6b0f6b062ef94fb47da402cfb9af8c4f180f38e packages/discord/tests/engine.test.mjs
|
||||
a8e54cc3f4b670eef2c06755b63e9e6bfeb44b1b1efde3aca9bfaa91583c3ef3 packages/discord/tests/fake-pi.mjs
|
||||
@@ -1,651 +0,0 @@
|
||||
diff --git a/packages/discord/src/engine-pi.mjs b/packages/discord/src/engine-pi.mjs
|
||||
index 5c8fd0a9..46ef1f88 100644
|
||||
--- a/packages/discord/src/engine-pi.mjs
|
||||
+++ b/packages/discord/src/engine-pi.mjs
|
||||
@@ -16,9 +16,14 @@
|
||||
// from `tool_execution_start`/`tool_execution_end` into the result so the
|
||||
// turn record shows what was read. An `agent_end` with `willRetry` is not
|
||||
// the end of the run. A timeout sends `abort` and fails that turn; the
|
||||
-// process stays. A malformed JSONL line from pi fails the current turn (its
|
||||
-// outcome is now unknowable) and the process stays. Process exit fails
|
||||
-// every pending turn and is reported through `onExit`.
|
||||
+// process stays. The failed turn holds later prompts back until its
|
||||
+// agent_end or a settle. If pi has not started it within ABORT_GRACE_MS, the
|
||||
+// engine stops pi instead of sending again: pi's events carry no prompt id,
|
||||
+// so a late run of the failed prompt would be taken for the next one's. A
|
||||
+// run pi did start holds later prompts until it ends, as any run does. A
|
||||
+// malformed JSONL line from pi fails the current turn (its outcome is now
|
||||
+// unknowable) and the process stays. Process exit fails every pending turn
|
||||
+// and is reported through `onExit`.
|
||||
//
|
||||
// Framing follows pi's RPC doc: split on "\n" only, strip a trailing "\r".
|
||||
// Node readline is not used because it also splits on U+2028/U+2029.
|
||||
@@ -64,31 +69,67 @@ export function assistantText(message) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
+// How long a turn that failed here (timeout, protocol error) may wait for
|
||||
+// pi's agent_start before the engine stops pi. Without a bound, a prompt pi
|
||||
+// accepted but never ran would hold every later prompt until restart.
|
||||
+export const ABORT_GRACE_MS = 30000;
|
||||
+
|
||||
export function createEngine({
|
||||
command, args, cwd, env = {},
|
||||
spawn = nodeSpawn,
|
||||
setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout,
|
||||
log = () => {},
|
||||
onExit = () => {},
|
||||
+ abortGraceMs = ABORT_GRACE_MS,
|
||||
} = {}) {
|
||||
if (typeof command !== "string" || command.length === 0) throw new DiscordError("engine: command required", 1);
|
||||
if (!Array.isArray(args)) throw new DiscordError("engine: args required", 1);
|
||||
|
||||
// pending: prompts sent to pi, oldest first. held: prompts waiting for pi
|
||||
// to settle before they are sent, oldest first.
|
||||
- const state = { child: null, buffer: "", pending: [], held: [], responses: new Map(), nextId: 1, busy: false, exited: null };
|
||||
+ // wedged: set when the engine gave up on pi and is stopping it. Nothing
|
||||
+ // is sent to that child again.
|
||||
+ const state = { child: null, buffer: "", pending: [], held: [], responses: new Map(), nextId: 1, busy: false, exited: null, wedged: false };
|
||||
|
||||
// A turn that fails on the client side (timeout, protocol error) stays in
|
||||
// the pending queue, marked done, until pi's own turn_end for it arrives.
|
||||
- // Otherwise that turn_end would be attributed to the next prompt.
|
||||
+ // Otherwise that turn_end would be attributed to the next prompt. It holds
|
||||
+ // later prompts back; if pi has not started it within abortGraceMs, the
|
||||
+ // engine stops pi.
|
||||
function failTurn(turn, code, message) {
|
||||
if (turn.done) return;
|
||||
turn.done = true;
|
||||
if (turn.timer !== null) clearTimeoutImpl(turn.timer);
|
||||
turn.timer = null;
|
||||
+ if (state.pending.includes(turn)) {
|
||||
+ turn.grace = setTimeoutImpl(() => {
|
||||
+ turn.grace = null;
|
||||
+ // A run pi started keeps its place until its agent_end or a settle.
|
||||
+ if (state.busy) return;
|
||||
+ // No agent_start yet. Pi may never run this prompt, or its events
|
||||
+ // may still be on the way; with no prompt id in them, nothing sent
|
||||
+ // now could be told apart from it. Stop pi: held prompts fail, and
|
||||
+ // the exit fails the rest and reaches onExit.
|
||||
+ log(`engine: no agent_start ${abortGraceMs} ms after a failed turn; stopping pi`);
|
||||
+ wedge();
|
||||
+ }, abortGraceMs);
|
||||
+ }
|
||||
turn.reject(new DiscordError(message, 1, { code }));
|
||||
}
|
||||
|
||||
+ function wedge() {
|
||||
+ if (state.wedged || state.exited !== null) return;
|
||||
+ state.wedged = true;
|
||||
+ for (const h of state.held.splice(0)) failTurn(h.turn, "engine-wedged", "engine stopped: pi did not start an aborted turn");
|
||||
+ stopChild();
|
||||
+ }
|
||||
+
|
||||
+ // Call when a turn leaves the pending queue.
|
||||
+ function release(turn) {
|
||||
+ if (turn.grace !== null) clearTimeoutImpl(turn.grace);
|
||||
+ turn.grace = null;
|
||||
+ }
|
||||
+
|
||||
function settleTurn(turn, value) {
|
||||
if (turn.done) return;
|
||||
turn.done = true;
|
||||
@@ -99,7 +140,10 @@ export function createEngine({
|
||||
|
||||
function failAll(code, message) {
|
||||
const pending = state.pending.splice(0);
|
||||
- for (const t of pending) failTurn(t, code, message);
|
||||
+ for (const t of pending) {
|
||||
+ release(t);
|
||||
+ failTurn(t, code, message);
|
||||
+ }
|
||||
for (const h of state.held.splice(0)) failTurn(h.turn, code, message);
|
||||
for (const [, r] of state.responses) r.reject(new DiscordError(message, 1, { code }));
|
||||
state.responses.clear();
|
||||
@@ -174,6 +218,7 @@ export function createEngine({
|
||||
// Attribute the run to the head even if it failed client-side, so the
|
||||
// next prompt's agent_end is not taken for this one.
|
||||
const run = state.pending.shift();
|
||||
+ if (run) release(run);
|
||||
if (!run || run.done) return;
|
||||
const messages = Array.isArray(event.messages) ? event.messages.filter((m) => m && m.role === "assistant") : [];
|
||||
const message = messages.length > 0 ? messages[messages.length - 1] : run.last;
|
||||
@@ -193,13 +238,14 @@ export function createEngine({
|
||||
// this settle and still has no agent_end will never get one: fail it now
|
||||
// instead of waiting for its timeout. Turns whose prompt response has
|
||||
// not arrived yet belong to a later run and stay.
|
||||
+ const dropped = [];
|
||||
const keep = [];
|
||||
- for (const t of state.pending) {
|
||||
- if (t.done) continue;
|
||||
- if (t.accepted) failTurn(t, "engine-settled-without-turn", "engine settled without answering this prompt");
|
||||
- else keep.push(t);
|
||||
- }
|
||||
+ for (const t of state.pending) (t.done || t.accepted ? dropped : keep).push(t);
|
||||
state.pending = keep;
|
||||
+ for (const t of dropped) {
|
||||
+ release(t);
|
||||
+ failTurn(t, "engine-settled-without-turn", "engine settled without answering this prompt");
|
||||
+ }
|
||||
sendHeld();
|
||||
}
|
||||
}
|
||||
@@ -214,21 +260,29 @@ export function createEngine({
|
||||
// Never accepted: pi will not emit a turn_end for it, so remove it.
|
||||
const i = state.pending.indexOf(turn);
|
||||
if (i !== -1) state.pending.splice(i, 1);
|
||||
+ release(turn);
|
||||
failTurn(turn, (err.details && err.details.code) || "engine-refused", err.message);
|
||||
sendHeld();
|
||||
});
|
||||
}
|
||||
|
||||
+ // Pi is busy from our side while any sent prompt is still queued, even one
|
||||
+ // that already failed here: a turn that timed out before its agent_start
|
||||
+ // was read leaves state.busy false while pi runs it, and sending then would
|
||||
+ // be refused as streaming. It leaves the queue on its agent_end, on a
|
||||
+ // settle, on a refused send, or at process exit.
|
||||
+ const engineBusy = () => state.busy || state.pending.length > 0;
|
||||
+
|
||||
// After a settle (or a refused send) the oldest held prompt goes out.
|
||||
function sendHeld() {
|
||||
- if (state.exited !== null) return;
|
||||
- if (state.busy || state.pending.some((t) => !t.done)) return;
|
||||
+ if (state.exited !== null || state.wedged) return;
|
||||
+ if (engineBusy()) return;
|
||||
const next = state.held.shift();
|
||||
if (next) send(next.turn, next.command);
|
||||
}
|
||||
|
||||
function write(command) {
|
||||
- if (!state.child || state.exited !== null) throw new DiscordError("engine is not running", 1, { code: "engine-down" });
|
||||
+ if (!state.child || state.exited !== null || state.wedged) throw new DiscordError("engine is not running", 1, { code: "engine-down" });
|
||||
state.child.stdin.write(JSON.stringify(command) + "\n");
|
||||
}
|
||||
|
||||
@@ -245,6 +299,30 @@ export function createEngine({
|
||||
});
|
||||
}
|
||||
|
||||
+ function stopChild({ graceMs = 5000 } = {}) {
|
||||
+ const child = state.child;
|
||||
+ if (!child || state.exited !== null) return Promise.resolve(state.exited);
|
||||
+ return new Promise((resolve) => {
|
||||
+ const timer = setTimeoutImpl(() => {
|
||||
+ try {
|
||||
+ child.kill("SIGKILL");
|
||||
+ } catch {
|
||||
+ // already gone
|
||||
+ }
|
||||
+ }, graceMs);
|
||||
+ child.once("exit", () => {
|
||||
+ clearTimeoutImpl(timer);
|
||||
+ resolve(state.exited);
|
||||
+ });
|
||||
+ try {
|
||||
+ child.stdin.end();
|
||||
+ child.kill("SIGTERM");
|
||||
+ } catch {
|
||||
+ // already gone
|
||||
+ }
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
return {
|
||||
start() {
|
||||
if (state.child) throw new DiscordError("engine already started", 1);
|
||||
@@ -281,7 +359,7 @@ export function createEngine({
|
||||
// with DiscordError carrying details.code for the turn record.
|
||||
prompt(text, { timeoutMs = 180000 } = {}) {
|
||||
if (typeof text !== "string" || text.length === 0) throw new DiscordError("prompt text required", 1);
|
||||
- const turn = { resolve: null, reject: null, timer: null, done: false, accepted: false, tools: new Map(), turns: 0, last: null };
|
||||
+ const turn = { resolve: null, reject: null, timer: null, grace: null, done: false, accepted: false, tools: new Map(), turns: 0, last: null };
|
||||
const done = new Promise((resolve, reject) => {
|
||||
turn.resolve = resolve;
|
||||
turn.reject = reject;
|
||||
@@ -306,44 +384,24 @@ export function createEngine({
|
||||
}
|
||||
failTurn(turn, "timeout", `turn timed out after ${timeoutMs} ms`);
|
||||
}, timeoutMs);
|
||||
- if (state.exited !== null) {
|
||||
+ if (state.exited !== null || state.wedged) {
|
||||
failTurn(turn, "engine-down", "engine is not running");
|
||||
return done;
|
||||
}
|
||||
- if (state.busy || state.pending.some((t) => !t.done) || state.held.length > 0) state.held.push({ turn, command });
|
||||
+ if (engineBusy() || state.held.length > 0) state.held.push({ turn, command });
|
||||
else send(turn, command);
|
||||
return done;
|
||||
},
|
||||
|
||||
get busy() {
|
||||
- return state.busy || state.pending.some((t) => !t.done) || state.held.length > 0;
|
||||
+ return engineBusy() || state.held.length > 0;
|
||||
},
|
||||
get pendingCount() {
|
||||
return state.pending.filter((t) => !t.done).length + state.held.length;
|
||||
},
|
||||
|
||||
- stop({ graceMs = 5000 } = {}) {
|
||||
- const child = state.child;
|
||||
- if (!child || state.exited !== null) return Promise.resolve(state.exited);
|
||||
- return new Promise((resolve) => {
|
||||
- const timer = setTimeoutImpl(() => {
|
||||
- try {
|
||||
- child.kill("SIGKILL");
|
||||
- } catch {
|
||||
- // already gone
|
||||
- }
|
||||
- }, graceMs);
|
||||
- child.once("exit", () => {
|
||||
- clearTimeoutImpl(timer);
|
||||
- resolve(state.exited);
|
||||
- });
|
||||
- try {
|
||||
- child.stdin.end();
|
||||
- child.kill("SIGTERM");
|
||||
- } catch {
|
||||
- // already gone
|
||||
- }
|
||||
- });
|
||||
+ stop(options) {
|
||||
+ return stopChild(options);
|
||||
},
|
||||
};
|
||||
}
|
||||
diff --git a/packages/discord/tests/engine.test.mjs b/packages/discord/tests/engine.test.mjs
|
||||
index 59674d1e..f3bd7525 100644
|
||||
--- a/packages/discord/tests/engine.test.mjs
|
||||
+++ b/packages/discord/tests/engine.test.mjs
|
||||
@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createEngine, buildPiArgs, PI_FIXED_ARGS, TOOLS_EXTENSION, READONLY_TOOLS_EXTENSION, assistantText } from "../src/engine-pi.mjs";
|
||||
import { existsSync } from "node:fs";
|
||||
+import { EventEmitter } from "node:events";
|
||||
+import { PassThrough } from "node:stream";
|
||||
import { makeRoot } from "./helpers.mjs";
|
||||
|
||||
const fakePi = join(import.meta.dirname, "fake-pi.mjs");
|
||||
@@ -28,7 +30,20 @@ function start(root, extra = {}) {
|
||||
log: (m) => logs.push(m), ...extra,
|
||||
});
|
||||
engine.start();
|
||||
- return { engine, logs, commands: () => readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) };
|
||||
+ // The fake creates its log on the first command; until then there are none.
|
||||
+ const commands = () => (existsSync(logPath) ? readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []);
|
||||
+ return { engine, logs, commands };
|
||||
+}
|
||||
+
|
||||
+// Every test stops its engine in finally: a fake pi left running after a
|
||||
+// failed assertion keeps the test file from exiting.
|
||||
+async function withEngine(extra, body) {
|
||||
+ const started = start(makeRoot(), extra);
|
||||
+ try {
|
||||
+ await body(started);
|
||||
+ } finally {
|
||||
+ await started.engine.stop();
|
||||
+ }
|
||||
}
|
||||
|
||||
test("engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file", () => {
|
||||
@@ -56,8 +71,7 @@ test("engine: with tools, buildPiArgs turns pi's own tools off, loads the extens
|
||||
assert.equal(rw[rw.indexOf("--tools") + 1], "list_dir,read_file,search,write_file,edit_file", "a writable root adds exactly the two write tools");
|
||||
});
|
||||
|
||||
-test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", async () => {
|
||||
- const { engine } = start(makeRoot());
|
||||
+test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", () => withEngine({}, async ({ engine }) => {
|
||||
const r = await engine.prompt("tools 3");
|
||||
assert.equal(r.text, "read 3 file(s)");
|
||||
assert.equal(r.turns, 2);
|
||||
@@ -71,45 +85,38 @@ test("engine: a run with tool turns settles once, on the answer, with every tool
|
||||
assert.equal(plain.turns, 1);
|
||||
await idle(engine);
|
||||
assert.equal(engine.busy, false);
|
||||
- await engine.stop();
|
||||
-});
|
||||
+}));
|
||||
|
||||
-test("engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end", async () => {
|
||||
- const { engine } = start(makeRoot());
|
||||
+test("engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end", () => withEngine({}, async ({ engine }) => {
|
||||
const r = await engine.prompt("toolonly");
|
||||
assert.equal(r.text, "", "no text: the connector turns this into engine-empty");
|
||||
assert.equal(r.tools.length, 1);
|
||||
const again = await engine.prompt("retry");
|
||||
assert.equal(again.text, "after retry");
|
||||
- await engine.stop();
|
||||
-});
|
||||
+}));
|
||||
|
||||
-test("engine: one prompt, one turn, text and usage come back", async () => {
|
||||
- const { engine } = start(makeRoot());
|
||||
- try {
|
||||
- const r = await engine.prompt("hello");
|
||||
- assert.equal(r.text, "echo: hello");
|
||||
- assert.deepEqual(r.usage, { input: 3, output: 2 });
|
||||
- await idle(engine);
|
||||
- assert.equal(engine.busy, false);
|
||||
- } finally {
|
||||
- await engine.stop();
|
||||
- }
|
||||
-});
|
||||
+test("engine: one prompt, one turn, text and usage come back", () => withEngine({}, async ({ engine }) => {
|
||||
+ const r = await engine.prompt("hello");
|
||||
+ assert.equal(r.text, "echo: hello");
|
||||
+ assert.deepEqual(r.usage, { input: 3, output: 2 });
|
||||
+ await idle(engine);
|
||||
+ assert.equal(engine.busy, false);
|
||||
+}));
|
||||
|
||||
-test("engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order", async () => {
|
||||
- const { engine, commands } = start(makeRoot());
|
||||
- const first = engine.prompt("slow 150");
|
||||
- await new Promise((r) => setTimeout(r, 20));
|
||||
+test("engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order", () => withEngine({}, async ({ engine, commands }) => {
|
||||
+ const first = engine.prompt("slow 300");
|
||||
assert.equal(engine.busy, true);
|
||||
const second = engine.prompt("second");
|
||||
assert.equal(engine.pendingCount, 2);
|
||||
- await new Promise((r) => setTimeout(r, 20));
|
||||
- assert.equal(commands().filter((c) => c.type === "prompt").length, 1, "the second prompt is not sent while pi is busy");
|
||||
+ const prompted = () => commands().filter((c) => c.type === "prompt");
|
||||
+ assert.ok(await until(() => prompted().length > 0), "the first prompt reached pi");
|
||||
+ assert.equal(prompted().length, 1, "the second prompt is not sent while pi is busy");
|
||||
+ // The fake refuses a prompt without streamingBehavior while it runs one, so
|
||||
+ // an answered second prompt also proves it was not sent early.
|
||||
const [r1, r2] = await Promise.all([first, second]);
|
||||
assert.equal(r1.text, "slow reply");
|
||||
assert.equal(r2.text, "echo: second");
|
||||
- const prompts = commands().filter((c) => c.type === "prompt");
|
||||
+ const prompts = prompted();
|
||||
assert.equal(prompts.length, 2);
|
||||
// Never a pi follow-up: pi would fold it into the first run and close both
|
||||
// answers with one agent_end (the live loss of 2026-09-17).
|
||||
@@ -117,11 +124,9 @@ test("engine: a prompt while streaming is held until pi settles, then sent as it
|
||||
assert.equal(prompts[1].streamingBehavior, undefined);
|
||||
await idle(engine);
|
||||
assert.equal(engine.busy, false);
|
||||
- await engine.stop();
|
||||
-});
|
||||
+}));
|
||||
|
||||
-test("engine: a held prompt that times out before pi settles fails on its own and is never sent", async () => {
|
||||
- const { engine, commands } = start(makeRoot());
|
||||
+test("engine: a held prompt that times out before pi settles fails on its own and is never sent", () => withEngine({}, async ({ engine, commands }) => {
|
||||
const first = engine.prompt("slow 200");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await assert.rejects(engine.prompt("late one", { timeoutMs: 50 }), (e) => e.details.code === "timeout" && /waiting for the engine/.test(e.message));
|
||||
@@ -130,50 +135,177 @@ test("engine: a held prompt that times out before pi settles fails on its own an
|
||||
await idle(engine);
|
||||
assert.deepEqual(commands().filter((c) => c.type === "prompt").map((c) => c.message), ["slow 200"]);
|
||||
assert.deepEqual(commands().filter((c) => c.type === "abort"), [], "a held turn is not aborted; pi never had it");
|
||||
- await engine.stop();
|
||||
-});
|
||||
+}));
|
||||
|
||||
-test("engine: timeout sends abort and fails only that turn; the process stays", async () => {
|
||||
- const { engine, commands, logs } = start(makeRoot());
|
||||
+test("engine: timeout sends abort and fails only that turn; the process stays", () => withEngine({}, async ({ engine, commands, logs }) => {
|
||||
await assert.rejects(engine.prompt("slow 5000", { timeoutMs: 100 }), (err) => err.details.code === "timeout");
|
||||
assert.ok(await until(() => commands().some((c) => c.type === "abort")), "abort reached pi");
|
||||
assert.ok(logs.some((l) => /timed out/.test(l)));
|
||||
const r = await engine.prompt("again");
|
||||
assert.equal(r.text, "echo: again");
|
||||
- await engine.stop();
|
||||
+}));
|
||||
+
|
||||
+test("engine: tool events from a run that outlived its timeout never land in the next prompt's record", () => withEngine({}, async ({ engine }) => {
|
||||
+ await assert.rejects(engine.prompt("late 200", { timeoutMs: 40 }), (err) => err.details.code === "timeout");
|
||||
+ const r = await engine.prompt("after late");
|
||||
+ assert.equal(r.text, "echo: after late");
|
||||
+ assert.deepEqual(r.tools, [], "the dead run's read is not this prompt's evidence");
|
||||
+ assert.equal(r.turns, 1, "the dead run's turns are not counted here");
|
||||
+}));
|
||||
+
|
||||
+// The turn timer is fired by hand, before the engine has read any event from
|
||||
+// pi, so the timed-out run is still pi's and state.busy is still false when
|
||||
+// the next prompt arrives. Under load a real timer does the same.
|
||||
+const TURN_MS = 60000;
|
||||
+const manualTurnTimer = (fire) => ({
|
||||
+ setTimeoutImpl: (fn, ms) => (ms === TURN_MS ? fire.push(fn) : setTimeout(fn, ms)),
|
||||
+ clearTimeoutImpl: (id) => { if (typeof id !== "number") clearTimeout(id); },
|
||||
});
|
||||
|
||||
-test("engine: tool events from a run that outlived its timeout never land in the next prompt's record", async () => {
|
||||
- const { engine } = start(makeRoot());
|
||||
- try {
|
||||
- await assert.rejects(engine.prompt("late 200", { timeoutMs: 40 }), (err) => err.details.code === "timeout");
|
||||
- const r = await engine.prompt("after late");
|
||||
+test("engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused", () => {
|
||||
+ const fire = [];
|
||||
+ return withEngine(manualTurnTimer(fire), async ({ engine, commands }) => {
|
||||
+ const late = engine.prompt("late 100", { timeoutMs: TURN_MS });
|
||||
+ fire.shift()();
|
||||
+ assert.equal(engine.busy, true, "pi is still running the prompt that timed out");
|
||||
+ const next = engine.prompt("after late", { timeoutMs: 5000 });
|
||||
+ assert.equal(engine.pendingCount, 1, "only the new prompt is live");
|
||||
+ await assert.rejects(late, (err) => err.details.code === "timeout");
|
||||
+ const r = await next;
|
||||
assert.equal(r.text, "echo: after late");
|
||||
assert.deepEqual(r.tools, [], "the dead run's read is not this prompt's evidence");
|
||||
- assert.equal(r.turns, 1, "the dead run's turns are not counted here");
|
||||
- } finally {
|
||||
- await engine.stop();
|
||||
- }
|
||||
+ assert.equal(r.turns, 1);
|
||||
+ assert.deepEqual(commands().map((c) => (c.type === "prompt" ? c.message : c.type)), ["late 100", "abort", "after late"]);
|
||||
+ await idle(engine);
|
||||
+ assert.equal(engine.busy, false);
|
||||
+ });
|
||||
+});
|
||||
+
|
||||
+// "mute" is accepted and never run, so no agent_start, agent_end or settle
|
||||
+// ever comes for it. Unbounded, it would hold every later prompt.
|
||||
+test("engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts", async () => {
|
||||
+ let exited = null;
|
||||
+ await withEngine({ abortGraceMs: 150, onExit: (e) => (exited = e) }, async ({ engine, commands, logs }) => {
|
||||
+ await assert.rejects(engine.prompt("mute", { timeoutMs: 50 }), (err) => err.details.code === "timeout");
|
||||
+ assert.equal(engine.busy, true, "pi might still be running it");
|
||||
+ const started = Date.now();
|
||||
+ await assert.rejects(engine.prompt("after mute", { timeoutMs: 5000 }), (err) => err.details.code === "engine-wedged");
|
||||
+ assert.ok(Date.now() - started >= 100, "held for the grace, not failed at once");
|
||||
+ await assert.rejects(engine.prompt("later"), (err) => err.details.code === "engine-down");
|
||||
+ assert.ok(await until(() => exited !== null), "pi exits and onExit hears of it");
|
||||
+ assert.ok(logs.some((l) => /stopping pi/.test(l)));
|
||||
+ assert.deepEqual(commands().map((c) => (c.type === "prompt" ? c.message : c.type)), ["mute", "abort"]);
|
||||
+ });
|
||||
+});
|
||||
+
|
||||
+// Rocko's 6b R1 case: pi is stuck before agent_start, then runs the old
|
||||
+// prompt and only afterwards reads the next one. The events carry no prompt
|
||||
+// id, so a prompt sent after the grace would get the old run's answer.
|
||||
+test("engine: a timed-out turn pi starts only after the grace never answers a later prompt", async () => {
|
||||
+ let exited = null;
|
||||
+ await withEngine({ abortGraceMs: 150, onExit: (e) => (exited = e) }, async ({ engine, commands }) => {
|
||||
+ await assert.rejects(engine.prompt("stall 400", { timeoutMs: 50 }), (err) => err.details.code === "timeout");
|
||||
+ await assert.rejects(engine.prompt("after stall", { timeoutMs: 5000 }), (err) => err.details.code === "engine-wedged");
|
||||
+ assert.ok(await until(() => exited !== null), "pi exits and onExit hears of it");
|
||||
+ await new Promise((res) => setTimeout(res, 400));
|
||||
+ assert.ok(!commands().some((c) => c.message === "after stall"), "nothing was sent after the grace");
|
||||
+ });
|
||||
});
|
||||
|
||||
-test("engine: a malformed JSONL line fails the turn, not the process", async () => {
|
||||
- const { engine, logs } = start(makeRoot());
|
||||
+// The same case in memory, after Rocko's reproducer: the old run's events
|
||||
+// arrive after the grace while pi is still exiting. They land on the failed
|
||||
+// turn, nothing more is written to pi, and only the exit ends the engine.
|
||||
+// Pi's response to the old prompt comes either before its timeout or only
|
||||
+// with the late events.
|
||||
+for (const lateResponse of [false, true]) test(`engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (${lateResponse ? "late" : "early"} prompt response)`, async () => {
|
||||
+ const timers = [];
|
||||
+ const written = [];
|
||||
+ const kills = [];
|
||||
+ const child = new EventEmitter();
|
||||
+ child.stdout = new PassThrough();
|
||||
+ child.stderr = new PassThrough();
|
||||
+ child.stdin = { write: (s) => { written.push(JSON.parse(s)); return true; }, end: () => {} };
|
||||
+ child.kill = (signal) => { kills.push(signal); return true; };
|
||||
+ let exited = null;
|
||||
+ const engine = createEngine({
|
||||
+ command: "memory-only", args: [], spawn: () => child, abortGraceMs: 150, onExit: (e) => (exited = e),
|
||||
+ setTimeoutImpl: (fn, ms) => { const t = { fn, ms, active: true }; timers.push(t); return t; },
|
||||
+ clearTimeoutImpl: (t) => { t.active = false; },
|
||||
+ });
|
||||
+ const emit = (x) => child.stdout.write(JSON.stringify(x) + "\n");
|
||||
+ const fire = (ms) => { const t = timers.find((x) => x.ms === ms && x.active); assert.ok(t, `timer ${ms}`); t.active = false; t.fn(); };
|
||||
+ const message = (text) => ({ role: "assistant", content: [{ type: "text", text }], stopReason: "stop" });
|
||||
+ const tick = () => new Promise((res) => setImmediate(res));
|
||||
+ // Checked after a tick instead of awaited, so a regression fails here
|
||||
+ // rather than hanging on a promise nothing will settle.
|
||||
+ const outcome = (p) => {
|
||||
+ const o = { state: "pending", code: null, text: null };
|
||||
+ p.then((v) => Object.assign(o, { state: "resolved", text: v.text }), (e) => Object.assign(o, { state: "rejected", code: e.details && e.details.code }));
|
||||
+ return o;
|
||||
+ };
|
||||
+ engine.start();
|
||||
+ const first = engine.prompt("old", { timeoutMs: 50 });
|
||||
+ const accept = () => emit({ type: "response", id: written[0].id, command: "prompt", success: true });
|
||||
+ if (!lateResponse) accept();
|
||||
+ await tick();
|
||||
+ fire(50);
|
||||
+ await assert.rejects(first, (err) => err.details.code === "timeout");
|
||||
+ const next = outcome(engine.prompt("new", { timeoutMs: 2000 }));
|
||||
+ fire(150);
|
||||
+ await tick();
|
||||
+ assert.deepEqual(next, { state: "rejected", code: "engine-wedged", text: null });
|
||||
+ assert.deepEqual(kills, ["SIGTERM"]);
|
||||
+ if (lateResponse) accept();
|
||||
+ emit({ type: "agent_start" });
|
||||
+ emit({ type: "tool_execution_start", toolCallId: "old-call", toolName: "read_file", args: { root: "docs", path: "old.md" } });
|
||||
+ emit({ type: "tool_execution_end", toolCallId: "old-call", toolName: "read_file", result: { details: { root: "docs", path: "old.md", ok: true } } });
|
||||
+ emit({ type: "turn_end", message: message("OLD RUN ANSWER") });
|
||||
+ emit({ type: "agent_end", messages: [message("OLD RUN ANSWER")] });
|
||||
+ emit({ type: "agent_settled" });
|
||||
+ await tick();
|
||||
+ const after = outcome(engine.prompt("after settle", { timeoutMs: 2000 }));
|
||||
+ await tick();
|
||||
+ assert.deepEqual(after, { state: "rejected", code: "engine-down", text: null });
|
||||
+ assert.deepEqual(next, { state: "rejected", code: "engine-wedged", text: null }, "the old answer did not reach the new prompt");
|
||||
+ assert.deepEqual(written.map((c) => (c.type === "prompt" ? c.message : c.type)), ["old", "abort"], "no prompt reached pi after the grace");
|
||||
+ assert.equal(exited, null);
|
||||
+ fire(5000);
|
||||
+ assert.deepEqual(kills, ["SIGTERM", "SIGKILL"]);
|
||||
+ child.emit("exit", null, "SIGKILL");
|
||||
+ assert.deepEqual(exited, { code: null, signal: "SIGKILL" });
|
||||
+});
|
||||
+
|
||||
+test("engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends", async () => {
|
||||
+ let exited = null;
|
||||
+ await withEngine({ abortGraceMs: 150, onExit: (e) => (exited = e) }, async ({ engine, commands }) => {
|
||||
+ await assert.rejects(engine.prompt("late 400", { timeoutMs: 50 }), (err) => err.details.code === "timeout");
|
||||
+ const r = await engine.prompt("after late", { timeoutMs: 5000 });
|
||||
+ assert.equal(r.text, "echo: after late");
|
||||
+ assert.deepEqual(r.tools, []);
|
||||
+ assert.equal(exited, null, "pi was not stopped");
|
||||
+ assert.deepEqual(commands().map((c) => (c.type === "prompt" ? c.message : c.type)), ["late 400", "abort", "after late"]);
|
||||
+ });
|
||||
+});
|
||||
+
|
||||
+test("engine: a malformed JSONL line fails the turn, not the process", () => withEngine({}, async ({ engine, logs }) => {
|
||||
await assert.rejects(engine.prompt("garbage"), (err) => err.details.code === "engine-protocol");
|
||||
assert.ok(logs.some((l) => /malformed/.test(l)));
|
||||
const r = await engine.prompt("still here");
|
||||
assert.equal(r.text, "echo: still here");
|
||||
- await engine.stop();
|
||||
-});
|
||||
+}));
|
||||
|
||||
test("engine: a turn that ends in error rejects with the error code; process exit fails pending turns", async () => {
|
||||
- const root = makeRoot();
|
||||
let exited = null;
|
||||
- const { engine } = start(root, { onExit: (e) => (exited = e) });
|
||||
- await assert.rejects(engine.prompt("error"), (err) => err.details.code === "engine-error" && /fake provider error/.test(err.message));
|
||||
- const pending = engine.prompt("slow 5000");
|
||||
- await new Promise((r) => setTimeout(r, 20));
|
||||
- await engine.stop();
|
||||
- await assert.rejects(pending, (err) => err.details.code === "engine-down");
|
||||
- assert.ok(exited);
|
||||
- await assert.rejects(engine.prompt("x"), /not running/);
|
||||
+ const { engine } = start(makeRoot(), { onExit: (e) => (exited = e) });
|
||||
+ try {
|
||||
+ await assert.rejects(engine.prompt("error"), (err) => err.details.code === "engine-error" && /fake provider error/.test(err.message));
|
||||
+ const pending = engine.prompt("slow 5000");
|
||||
+ await new Promise((r) => setTimeout(r, 20));
|
||||
+ await engine.stop();
|
||||
+ await assert.rejects(pending, (err) => err.details.code === "engine-down");
|
||||
+ assert.ok(exited);
|
||||
+ await assert.rejects(engine.prompt("x"), /not running/);
|
||||
+ } finally {
|
||||
+ await engine.stop();
|
||||
+ }
|
||||
});
|
||||
diff --git a/packages/discord/tests/fake-pi.mjs b/packages/discord/tests/fake-pi.mjs
|
||||
index 94919306..bf94c013 100644
|
||||
--- a/packages/discord/tests/fake-pi.mjs
|
||||
+++ b/packages/discord/tests/fake-pi.mjs
|
||||
@@ -8,9 +8,13 @@
|
||||
// then a second turn that answers "read <n> file(s)"
|
||||
// "toolonly" a run whose only turn calls a tool and never answers
|
||||
// "retry" an agent_end with willRetry, then the real answer
|
||||
+// "mute" accept the prompt and emit nothing, staying idle
|
||||
// "late <ms>" ignore abort; after <ms> emit a tool pair and a tool turn,
|
||||
// then answer "late reply", like a run that outlives its
|
||||
// client-side timeout
|
||||
+// "stall <ms>" accept the prompt, then read nothing for <ms> (pi stuck
|
||||
+// before agent_start); then run it, answering "echo:
|
||||
+// stalled", and only then read what came in meanwhile
|
||||
// anything else answer "echo: <text>" immediately
|
||||
// A prompt received while busy without streamingBehavior is refused, as pi
|
||||
// does. A prompt with streamingBehavior followUp is folded into the running
|
||||
@@ -30,6 +34,7 @@ function assistant(text, stopReason = "stop") {
|
||||
}
|
||||
|
||||
function run(text) {
|
||||
+ if (text === "mute") return;
|
||||
busy = true;
|
||||
out({ type: "agent_start" });
|
||||
out({ type: "turn_start" });
|
||||
@@ -109,11 +114,16 @@ function run(text) {
|
||||
let current = null;
|
||||
|
||||
let buffer = "";
|
||||
+let stalled = false;
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
+ drain();
|
||||
+});
|
||||
+
|
||||
+function drain() {
|
||||
let idx;
|
||||
- while ((idx = buffer.indexOf("\n")) !== -1) {
|
||||
+ while (!stalled && (idx = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
@@ -125,7 +135,15 @@ process.stdin.on("data", (chunk) => {
|
||||
continue;
|
||||
}
|
||||
out({ id: cmd.id, type: "response", command: "prompt", success: true });
|
||||
- if (busy) queue.push(cmd.message);
|
||||
+ const sm = /^stall (\d+)$/.exec(cmd.message);
|
||||
+ if (sm) {
|
||||
+ stalled = true;
|
||||
+ setTimeout(() => {
|
||||
+ run("stalled");
|
||||
+ stalled = false;
|
||||
+ drain();
|
||||
+ }, Number(sm[1]));
|
||||
+ } else if (busy) queue.push(cmd.message);
|
||||
else run(cmd.message);
|
||||
} else if (cmd.type === "abort") {
|
||||
out({ id: cmd.id, type: "response", command: "abort", success: true });
|
||||
@@ -139,5 +157,5 @@ process.stdin.on("data", (chunk) => {
|
||||
out({ id: cmd.id, type: "response", command: cmd.type, success: true, data: {} });
|
||||
}
|
||||
}
|
||||
-});
|
||||
+}
|
||||
process.stdin.on("end", () => process.exit(0));
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"observedAt": "2026-09-13T19:56:18.961314+00:00",
|
||||
"issue": 1510,
|
||||
"ownerAuthorizedLiveSmoke": true,
|
||||
"researcher": [
|
||||
{
|
||||
"session": ".pi/state/researcher/sessions/2026-09-13T19-52-28-745Z_01a09c54-0b48-7154-addd-8fdce875aa4a.jsonl",
|
||||
"entryId": "64b15fd5",
|
||||
"timestamp": "2026-09-13T19:52:43.079Z",
|
||||
"response": "RESEARCHER_NATIVE_SMOKE_OK",
|
||||
"entrySha256": "d9cbaa5ea640d2b858a86b2fa24d3240b351d9383ffaee9721dd86fcd080c329"
|
||||
}
|
||||
],
|
||||
"rocko": {
|
||||
"newLaunch": "refused by existing native launch lock",
|
||||
"existingPid": 3707667,
|
||||
"cwd": "/mnt/storage/src/mosaic-stack",
|
||||
"nativeContextVerified": true,
|
||||
"sonnetFlagVerified": true,
|
||||
"socket": "mosaic-fleet",
|
||||
"newModelResponseTested": false
|
||||
},
|
||||
"existingProcessesRestarted": false,
|
||||
"homeLaunchersModified": false
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"issue": 1510,
|
||||
"candidate": "/tmp/internal-team-r1-i9t21yzr",
|
||||
"manifestSha256": "23a27014ce6f04ce8187d8495b2efe62b814c3c7d041b027f99b1ec4d490709d",
|
||||
"files": [
|
||||
"AGENTS.md",
|
||||
"agents/README.md",
|
||||
"agents/researcher/SOUL.md",
|
||||
"agents/researcher/CONTEXT.md",
|
||||
"agents/researcher/README.md",
|
||||
"agents/researcher/launch.sh",
|
||||
"agents/researcher/validate-sessions.mjs",
|
||||
"scripts/test-darkwing-launch.mjs",
|
||||
"docs/plans/2026-09-13_internal-development-bootstrap.md"
|
||||
],
|
||||
"tests": "node --test scripts/test-darkwing-launch.mjs scripts/test-rocko-launch.mjs",
|
||||
"passed": 6,
|
||||
"state": "ready for independent review"
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
# Ledger: T3 header counts as agent (#1506), R1 candidate
|
||||
|
||||
Sage assigned this on 2026-09-26 after commit A (af4203ca). Filbert reviews.
|
||||
Not committed.
|
||||
|
||||
## Defect
|
||||
|
||||
`messageKind` in `packages/ledger/src/ledger.mjs` knew only the tmux preamble
|
||||
`[host:session -> host:session]`. A prompt that opens with the T3 header
|
||||
`[from: sage (1ef1e4f8-…) -> to: filbert (9cb9731e-…) class=actionable]`
|
||||
counted as human, so Table 2's Human column and the human-per-closed ratio
|
||||
rise once seats talk over T3. DEFERRED Open entry "Ledger counts T3 agent
|
||||
messages as human".
|
||||
|
||||
## Change
|
||||
|
||||
- `messageKind` also matches the T3 header on the first line. The sender is the
|
||||
`from:` role. `control-board` is board, any other role is agent. Anything short
|
||||
of the full header stays human. That includes the header on a later line, a
|
||||
leading space, a missing thread id, `class=` with capitals, `]` followed by a
|
||||
non-space, and `From:` capitalized. The tmux branch is unchanged.
|
||||
- Two tests: a Table 2 fixture with two headered prompts and one plain prompt
|
||||
for seat `bob`, expecting agent 2 and human 1. Also a direct classification table.
|
||||
- README counting rule names both forms.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `node --test --test-reporter=tap packages/ledger/tests/`: 22/22 on the
|
||||
working tree.
|
||||
- The same test file against HEAD's `ledger.mjs` (full `git archive HEAD` tree):
|
||||
20/22. The two failures are the two new tests, so they catch the defect.
|
||||
An earlier archive of `packages/ledger` alone also failed three gitea-helper
|
||||
tests. Those tests need `scripts/gitea-api.sh`, which the partial archive left out.
|
||||
- Suites on the working tree: config 24, task 90, foundation 43, conductor 17,
|
||||
release 14, auth 15, discord 63. None of them runs the ledger tests.
|
||||
|
||||
## Frozen files
|
||||
|
||||
`r1-manifest.sha256` holds the three file hashes; `r1.patch` is `git diff
|
||||
packages/ledger` at freeze time.
|
||||
|
||||
## Known limit, not fixed here
|
||||
|
||||
The fix changes zero current counts. Table 2 reads only
|
||||
`.pi/state/<seat>/sessions/*.jsonl`, and no file there contains a T3 header
|
||||
for any seat. Filbert checked this in R1:
|
||||
- `grep -rF '[from: ' .pi/state/*/sessions/` finds nothing.
|
||||
- The candidate `messageKind` gives Dewey 52 agent, 7 board and 9 human, and
|
||||
Sage 193 agent and 72 human. That covers every user message in Pi logs
|
||||
modified since 2026-09-20. Every non-human first line is the tmux form.
|
||||
- Filbert's three T3 messages to Dewey on 2026-09-26 are not in
|
||||
`.pi/state/dewey`.
|
||||
|
||||
Dewey's and Sage's Pi logs are current, but they only carry tmux traffic. T3
|
||||
traffic goes to the harness transcripts: Claude under `~/.claude/projects`,
|
||||
Codex under `~/.codex/sessions`. The ledger reads neither, so a T3-routed
|
||||
prompt to any seat counts nowhere, as agent or as human. The Human column
|
||||
can't see T3 traffic at all. For Darkwing and Filbert, whose newest Pi logs
|
||||
end 2026-09-14, and for Rocko, who has no Pi sessions directory, zero means an
|
||||
empty source, not zero human prompts. The fix is correct for a source that
|
||||
carries T3 headers. Sage asked for a brief on a read-only T3 thread source;
|
||||
Gate F waits on it.
|
||||
|
||||
Filbert also found two misclassifications in older logs. Neither is touched
|
||||
here:
|
||||
- `[rev-code-02 -> dragon-lin:sage class=actionable]` has no host on the
|
||||
sender, so it counts as human.
|
||||
- One Dewey prompt opens with a quote character before the tmux preamble, so
|
||||
it counts as human.
|
||||
|
||||
## Review
|
||||
|
||||
Filbert, R1, 2026-09-26: approved the three frozen files. He verified the
|
||||
manifest and patch, got 22/22 on the tree and 20/22 against HEAD's source, and
|
||||
matched the regex to `docs/guides/T3-AGENT-COMMS.md`. He accepts the body on
|
||||
the header's line, which the tmux branch also allows. He corrected the
|
||||
known-limit text above.
|
||||
Loaded 100 of 15077 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user