Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87c12a2726 | ||
|
|
2cfcb63cd8 | ||
|
|
2101c9b446 |
+15
-23
@@ -1,24 +1,16 @@
|
|||||||
# Non-secret runtime settings for the mosaic-poc-agent container.
|
# Mosaic Stack standalone deployment (compose `stack` profile)
|
||||||
# Copy to .env if you want to override the defaults in compose.yaml.
|
# Copy to .env and adjust. Port overrides exist because the defaults
|
||||||
#
|
# collide with common host services (and with the dev compose itself).
|
||||||
# NEVER put credentials in this file. Authentication is supplied at
|
PG_HOST_PORT=5433
|
||||||
# runtime only, via one of the two documented paths:
|
VALKEY_HOST_PORT=6380
|
||||||
# 1. read-only mounted pi auth file (default: ~/.pi/agent/auth.json,
|
GATEWAY_HOST_PORT=14242
|
||||||
# override the host path with PI_AUTH_FILE)
|
# Registry image override (defaults to a local build of docker/gateway.Dockerfile):
|
||||||
# 2. provider API key environment variable (ZAI_API_KEY or
|
# GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d
|
||||||
# ANTHROPIC_API_KEY), passed through by compose.yaml when set
|
|
||||||
|
|
||||||
# Model provider (built-in pi provider name)
|
# Optional explicit dogfood overlay (docker-compose.dogfood.yml).
|
||||||
PI_PROVIDER=zai
|
# All three paths are required when that overlay is used. Use a dedicated
|
||||||
|
# next-based worktree, its canonical clone's .git directory, and the external
|
||||||
# Model ID within the provider
|
# home of the unprivileged code-dogfood-01 functional seat.
|
||||||
PI_MODEL=glm-5.3-flash
|
# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487
|
||||||
|
# MOSAIC_DOGFOOD_COMMON_GIT_DIR=/home/example/src/mosaic-stack/.git
|
||||||
# Optional: alternative host path of the pi credential file mounted
|
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/code-dogfood-01
|
||||||
# read-only at /home/node/.pi/agent/auth.json in the container
|
|
||||||
#PI_AUTH_FILE=/home/jwoltje/.pi/agent/auth.json
|
|
||||||
|
|
||||||
# Optional: documented env-var auth alternative (secret! set in your
|
|
||||||
# shell or a gitignored .env, never commit)
|
|
||||||
#ZAI_API_KEY=
|
|
||||||
#ANTHROPIC_API_KEY=
|
|
||||||
|
|||||||
+27
-6
@@ -1,8 +1,29 @@
|
|||||||
# build/deps
|
logs/
|
||||||
node_modules/
|
node_modules
|
||||||
|
dist
|
||||||
# runtime credentials — never commit, never copy into the image
|
.turbo
|
||||||
|
.next
|
||||||
|
coverage
|
||||||
.env
|
.env
|
||||||
secrets/
|
.env.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
.pnpm-store
|
||||||
|
__pycache__/
|
||||||
|
docs/.obsidian
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
||||||
|
.mosaic-test-work/
|
||||||
|
|
||||||
|
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
|
||||||
|
# loading it, then unlink. They are untracked but were not ignored, so turbo's
|
||||||
|
# package traversal hashed them and intermittently failed CI with "Package
|
||||||
|
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
||||||
|
# file vanished mid-scan. Ignoring them removes the race.
|
||||||
|
*.timestamp-*.mjs
|
||||||
|
|
||||||
|
# Playwright run artifacts (#1445, P6 E2E gate)
|
||||||
|
apps/web/test-results/
|
||||||
|
apps/web/playwright-report/
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"projectVersion": 1,
|
|
||||||
"id": "stack",
|
|
||||||
"vars": {
|
|
||||||
"tracker.project": 32,
|
|
||||||
"git.workingBranch": "refactor",
|
|
||||||
"git.protectedBranches": [
|
|
||||||
"main",
|
|
||||||
"next"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 "$@"
|
|
||||||
@@ -1,217 +1,186 @@
|
|||||||
# AGENTS.md — Mosaic Stack rebuild (`mosaicstack/stack`, branch `refactor`)
|
# Agent Guidelines — Mosaic Stack
|
||||||
|
|
||||||
Operational context for any agent session working in this repository.
|
## Required Load Order
|
||||||
Read top to bottom; it is deliberately short — depth lives in the files it
|
|
||||||
points to, not here.
|
|
||||||
|
|
||||||
## 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`,
|
## Project Context
|
||||||
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`.
|
|
||||||
|
|
||||||
A rebuild of Mosaic Stack: a file-based, fail-closed
|
Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture.
|
||||||
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.
|
|
||||||
|
|
||||||
## Non-negotiable invariants (the canon)
|
### Stack
|
||||||
|
|
||||||
1. **Root is bootstrap-only.** First-class system configuration lives at the
|
- **API:** NestJS with Fastify (`apps/gateway`)
|
||||||
repository root; everything else gets a dedicated directory (`roles/`,
|
- **Web:** Next.js 16 with React 19 (`apps/web`)
|
||||||
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
|
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
|
||||||
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
|
- **Authentication:** BetterAuth (`packages/auth`)
|
||||||
config — created only by `scripts/bootstrap.sh`, never overwritten,
|
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
|
||||||
fail-closed on any problem. Repo-scoped role authority lives in
|
- **Queue:** Valkey 8 (`packages/queue`)
|
||||||
`roles/*.json` (versioned, reviewed commits only).
|
- **Build:** pnpm workspaces and Turborepo
|
||||||
3. **Secrets** never enter the repository or container images; auth is
|
- **CI:** Woodpecker CI
|
||||||
runtime-only (read-only mount or environment variable).
|
- **Observability:** OpenTelemetry and Jaeger
|
||||||
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.
|
|
||||||
|
|
||||||
## Autonomous operation within an agreed plan
|
### Package Map
|
||||||
|
|
||||||
Autonomy starts after alignment, not before it. For a new substantial assignment,
|
| Package | Purpose | Key Dependencies |
|
||||||
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
|
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||||
outcome, acceptance evidence, boundaries, and any gated actions. Recommend a
|
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||||
concrete plan instead of presenting an open-ended menu. A direct request or
|
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||||
existing approved plan that already settles those points is sufficient alignment;
|
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
|
||||||
do not ask for ceremonial reconfirmation.
|
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
||||||
|
| `packages/brain` | Structured data layer | @mosaicstack/db |
|
||||||
|
| `packages/queue` | Valkey task queue and MCP | ioredis |
|
||||||
|
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||||
|
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
|
||||||
|
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||||
|
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||||
|
|
||||||
Once the plan is established, carry it to verified completion without prompting
|
## Architecture and Code Conventions
|
||||||
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.
|
|
||||||
|
|
||||||
### Decide and continue
|
1. Gateway is the single API surface; all clients connect through it.
|
||||||
|
2. Pi SDK is ESM-only; gateway and CLI code must remain ESM.
|
||||||
|
3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports.
|
||||||
|
4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts.
|
||||||
|
5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`).
|
||||||
|
6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not emit decorator metadata.
|
||||||
|
7. Keep DTOs in `*.dto.ts` files at module boundaries.
|
||||||
|
8. BetterAuth owns authentication tables; their schema is defined in `@mosaicstack/db`.
|
||||||
|
9. Create a task-specific scratchpad for non-trivial work.
|
||||||
|
|
||||||
- Resolve naming, implementation approach, layout, and similar non-breaking
|
## Development Workflow
|
||||||
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.
|
|
||||||
|
|
||||||
### Re-align or stop only at a real boundary
|
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
|
||||||
|
|
||||||
Finish all independent work first, then ask one focused question only when:
|
```bash
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm preflight
|
||||||
|
|
||||||
1. Two plausible readings materially change the outcome and the choice is costly
|
# Optional local queue service only; do not start the full Compose stack.
|
||||||
to reverse.
|
docker compose up -d valkey
|
||||||
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.
|
|
||||||
|
|
||||||
Repository gates still apply. In particular, a successful implementation or a
|
The pre-push hook requires:
|
||||||
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.
|
|
||||||
|
|
||||||
## Session protocol (mandatory)
|
```bash
|
||||||
|
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
|
```
|
||||||
|
|
||||||
- **Register** your session in `docs/SESSIONS.md` — one append-only line
|
Software delivery also requires the applicable tests. Common repository commands are:
|
||||||
(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).
|
|
||||||
|
|
||||||
## Internal development bootstrap
|
```bash
|
||||||
|
pnpm typecheck # TypeScript checks across the workspace
|
||||||
|
pnpm lint # ESLint across the workspace
|
||||||
|
pnpm test # Checkout tests and package Vitest suites
|
||||||
|
pnpm format:check # Prettier check
|
||||||
|
pnpm build # Build all packages and applications
|
||||||
|
```
|
||||||
|
|
||||||
Jason's current direction is repository-native development in
|
## Branch Model and Merge Process — `main` and `next` (CANONICAL)
|
||||||
`/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
|
**Every contribution targets `next` first. No exceptions.** Features, fixes, tests,
|
||||||
one specific nonempty request only when Jason must provide a decision or input.
|
docs, and policy changes all take the same route; urgency changes queue priority,
|
||||||
Put that line at column zero, before other text. Do not use it for routine
|
never the route. Agents never commit to or merge into `main`.
|
||||||
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
|
| Branch | Role | Who merges into it |
|
||||||
|
| ------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| `next` | Integration trunk — the only PR target for contributions | The designated merge-gate agent, after all gates pass. Never the PR author. |
|
||||||
|
| `main` | Stable/release line — receives promotion merges from `next` only | Jason only (or an agent he explicitly delegates for a named promotion). |
|
||||||
|
|
||||||
- **Conductor**: a system-scoped role — not an agent, not a daemon. Holds
|
### Contribution sequencing (in order, no skipping)
|
||||||
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
|
1. **Issue first.** Work is tracked in a Gitea issue before a branch exists. The
|
||||||
|
issue number appears in the branch name and the PR body.
|
||||||
|
2. **Branch from the current `origin/next` head.** Name it
|
||||||
|
`feat/…`, `fix/…`, `docs/…`, or `test/…` with the issue number
|
||||||
|
(e.g. `docs/1214-branch-process`). Record the base SHA in the PR body.
|
||||||
|
3. **Develop with evidence.** Applicable tests accompany the change. Hooks are
|
||||||
|
never bypassed (`--no-verify` is prohibited). Stage explicit paths — never
|
||||||
|
`git add -A`.
|
||||||
|
4. **Open the PR against `next`.** The body states: scope, base SHA,
|
||||||
|
verification commands with results, and any known pre-existing failures on
|
||||||
|
the base — documented, not retried to green and not absorbed silently.
|
||||||
|
5. **CI must be terminal-green on the exact head.** All bounded Woodpecker
|
||||||
|
steps succeed (`verify-terminal-green` contract). Pipelines for fork PRs
|
||||||
|
start `blocked`; a maintainer approves the run — approving CI is not
|
||||||
|
approving the PR.
|
||||||
|
6. **Independent review. Self-merge is prohibited** — for every agent, on every
|
||||||
|
PR, including trivial ones. Where the change touches protected or
|
||||||
|
contract-bearing content, the reviewer verifies the exact head
|
||||||
|
(exact-byte/exact-blob comparison), not a description of it. An `AMEND`
|
||||||
|
verdict returns the PR to its author; the reviewer's gate stays held until
|
||||||
|
a fresh exact head passes.
|
||||||
|
7. **Merge into `next`** happens only after CI green + review pass, pinned to
|
||||||
|
the reviewed head SHA (a post-review push voids the review).
|
||||||
|
8. **Promotion `next` → `main`** is a deliberate, Jason-owned reconciliation
|
||||||
|
merge — not part of any contribution's lifecycle. Contributors are done at
|
||||||
|
step 7.
|
||||||
|
|
||||||
`scripts/bootstrap.sh` (idempotent) · `build.sh` · `hello.sh` ·
|
### Responsibilities
|
||||||
`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:
|
- **Contributor** — base pinning, green CI, evidence in the PR body,
|
||||||
`docs/TOOLS.md` (read on demand; do not rely on this summary for detail).
|
responding to AMEND verdicts, never merging own work.
|
||||||
|
- **Reviewer / merge gate** — independent verification on the exact head;
|
||||||
|
holds and lifts gates; executes the merge into `next`.
|
||||||
|
- **Orchestrator / adjudicator** — cross-PR sequencing, disposition when PRs
|
||||||
|
collide, conflict adjudication.
|
||||||
|
- **Jason** — `next` → `main` promotions, merge-authority grants, collaborator
|
||||||
|
and token provisioning. Agents cannot grant themselves or each other any of
|
||||||
|
these.
|
||||||
|
|
||||||
## Data map (canon)
|
### Hotfixes and divergence
|
||||||
|
|
||||||
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
|
- A hotfix follows the same path: branch from `next`, PR to `next`, gates,
|
||||||
auto-written).
|
merge, then an expedited Jason-owned promotion if `main` needs it urgently.
|
||||||
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
|
Committing the fix to `main` directly is prohibited even under pressure.
|
||||||
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
|
- **Never land work on `main` that is not on `next`.** This has happened
|
||||||
- `sessions/` — pi JSONL session trees, one directory per named session
|
(issue #1152's goal controller reached `main` without reaching `next`) and
|
||||||
- `workspaces/` — agent file effects (persistent or `:run` ephemeral)
|
every later PR paid for it. If it happens anyway: transplant the work onto
|
||||||
- `state/` — release pointer + append-only activation/auto-apply logs
|
a `next`-based branch with provenance-preserving commits
|
||||||
- Ownership is per-directory; nothing shares state. Directory map and
|
(`git cherry-pick -x` or explicit SHA references in the messages), PR it
|
||||||
lifecycle rules: README.md "Data map" section.
|
through the normal gates, and let promotion re-align `main`. Do not
|
||||||
|
hand-patch `main` to compensate.
|
||||||
|
- Force-pushing a branch you do not own is prohibited; rebasing your own PR
|
||||||
|
branch is fine before review, and voids any review already given.
|
||||||
|
|
||||||
## Pointers (depth lives here)
|
## Database and Local Runtime Safety
|
||||||
|
|
||||||
- `docs/plans/2026-09-27_goals-review.md` — north star and goal order (Jason ratified 2026-09-27)
|
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||||
- `docs/plans/QUEUE.md` — THE task list, rendered from `docs/plans/queue.json`
|
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||||
(`scripts/mosaic queue next <seat>` reads it; `packages/queue/README.md` has the verbs)
|
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
|
||||||
- `docs/plans/CURRENT.md` — narrative log behind the queue rows
|
- Do not start Gateway/Web or run root `pnpm dev` as a local PGlite route. The current dotenv loader can inherit a daemon PostgreSQL DSN; KBN-101-02 must make that path fail closed first.
|
||||||
- `docs/plans/ROADMAP.md` — agreed milestone path (M16+)
|
- Migration artifact generation is offline and does not authorize PostgreSQL access:
|
||||||
- `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
|
```bash
|
||||||
|
pnpm --filter @mosaicstack/db db:generate
|
||||||
|
```
|
||||||
|
|
||||||
Compacted, restarted, or new? Nothing that matters is lost: this file +
|
## docs/TASKS.md — Schema (CANONICAL)
|
||||||
`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
|
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.**
|
||||||
|
|
||||||
`@earendil-works/pi-coding-agent` is pinned exactly (see `package.json` /
|
| Value | When to use | Budget |
|
||||||
`RELEASE`); never install unversioned. Release identity: `RELEASE` file
|
| --------- | ----------------------------------------------------------- | -------------------------- |
|
||||||
(0.0.X until declared stable); image tags derive from it.
|
| `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 |
|
||||||
|
|
||||||
|
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
||||||
|
|
||||||
|
**Full schema:**
|
||||||
|
|
||||||
|
```
|
||||||
|
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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.
|
|
||||||
-4144
File diff suppressed because it is too large
Load Diff
@@ -1 +1,5 @@
|
|||||||
@AGENTS.md
|
# Claude Compatibility Pointer
|
||||||
|
|
||||||
|
@AGENTS.md
|
||||||
|
|
||||||
|
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
|
||||||
|
|||||||
@@ -1,43 +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 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,460 @@
|
|||||||
# Mosaic Stack — new foundation
|
# Mosaic Stack
|
||||||
|
|
||||||
The active rebuild is at this repository's root. The original Mosaic Stack v1
|
Self-hosted, multi-user AI agent platform. One config, every runtime, same standards.
|
||||||
source is archived under `v1/`; it is not the implementation being developed here.
|
|
||||||
|
|
||||||
- Canonical checkout: `/mnt/storage/src/mosaic-stack`
|
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.
|
||||||
- Repository: `mosaicstack/stack`
|
|
||||||
- Working branch: `refactor`
|
|
||||||
- Former `~/src/mosaic-stack-dev-test`: compatibility symlink to this same checkout
|
|
||||||
|
|
||||||
Both original Git histories and pending development work are preserved. See the
|
## Quick Install
|
||||||
[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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/bootstrap.sh # create-if-absent; validates existing config, never rewrites
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Minimal shape (`configVersion` 1):
|
Or use the direct URL:
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/run-task.sh validate tasks/hello-marker.json # strict validation, writes nothing
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/release.sh package # build + tag the release image
|
bash <(curl -fsSL …) --yes # Accept all defaults
|
||||||
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
|
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`ensure` is invoked automatically by the human-facing launchers (`hello`,
|
This installs both components:
|
||||||
`verify`, `agent`): the system determines what is installed and aligns
|
|
||||||
itself — the user never runs release commands manually.
|
|
||||||
|
|
||||||
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
|
| Component | What | Where |
|
||||||
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
|
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
|
||||||
|
| **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.
|
### Install lanes
|
||||||
|
|
||||||
## Runtime adapters (M4)
|
| Lane | Command | Use when | Source |
|
||||||
|
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||||
|
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
||||||
|
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||||
|
|
||||||
The harness boundary is formalized: everything upstream (config, contracts, missions, tasks, run records) is harness-agnostic; everything inside an adapter belongs to one runtime.
|
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
||||||
|
|
||||||
```text
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/mosaic-task.mjs list # runs with task/workspace/session columns
|
mosaic wizard # Full guided setup (gateway install → verify)
|
||||||
node scripts/mosaic-task.mjs show <runId> # full record + snapshots + artifacts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
- Node.js ≥ 22
|
||||||
|
- npm (for global @mosaicstack/mosaic install)
|
||||||
Inside the container:
|
- One or more runtimes:
|
||||||
|
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||||
```text
|
- [Codex](https://github.com/openai/codex)
|
||||||
/opt/mosaic/contracts immutable contract files
|
- [OpenCode](https://opencode.ai)
|
||||||
/var/lib/mosaic generated runtime state (mounted from configured dataRoot)
|
- [Pi](https://pi.dev)
|
||||||
/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.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
### Launching Agent Sessions
|
||||||
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):
|
|
||||||
|
|
||||||
```bash
|
```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
|
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.
|
||||||
`docs/containerization.md`) is used, in this order:
|
|
||||||
|
|
||||||
1. **Read-only mounted credential file** (default): the host pi auth file
|
Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi,
|
||||||
`~/.pi/agent/auth.json` is bind-mounted read-only to
|
`/goal set <statement>` starts a bounded persistent loop that checks every turn and successful
|
||||||
`/home/node/.pi/agent/auth.json`. The host file holds a static API-key
|
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
|
||||||
entry for the built-in `zai` provider, so no token refresh writes are needed.
|
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
|
||||||
2. **Runtime environment variable** (documented alternative): set `ZAI_API_KEY`
|
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
|
||||||
or `ANTHROPIC_API_KEY` in the environment or in a gitignored `.env`; compose
|
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
|
||||||
passes them through. Pi's documented precedence applies.
|
into `~/.pi/agent/extensions/`.
|
||||||
|
|
||||||
Credentials are never committed, never copied into the image, and never printed.
|
### TUI & Gateway
|
||||||
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.
|
|
||||||
|
|
||||||
## Boundaries honored
|
```bash
|
||||||
|
mosaic tui # Interactive TUI connected to the gateway
|
||||||
|
mosaic gateway login # Authenticate with a gateway instance
|
||||||
|
mosaic sessions list # List active agent sessions
|
||||||
|
```
|
||||||
|
|
||||||
- No mounts of `~/.mosaic` or `~/.config/mosaic`; no Docker socket mount.
|
### Gateway Management
|
||||||
- Source stays in this project directory; generated state only in
|
|
||||||
`/home/jwoltje/.mosaic-dev` (host) and `/var/lib/mosaic` (container).
|
```bash
|
||||||
- No database, web server, queue, second container, orchestration, Git
|
mosaic gateway install # Install and configure the gateway service
|
||||||
integration, persistent sessions, or policy machinery.
|
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 skill list # Audit Claude skill registrations and conflicts
|
||||||
|
mosaic skill register <name> # Register one canonical skill with Claude Code
|
||||||
|
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||||
|
mosaic update # Update CLI/framework and auto-register canonical skills
|
||||||
|
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 [--simulate] # fails closed (FORGE_NO_EXECUTOR) with no executor wired; --simulate for typed simulated runs
|
||||||
|
mosaic forge status
|
||||||
|
mosaic forge resume [--simulate] # same fail-closed rule as forge run
|
||||||
|
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
|
||||||
|
# Schema migration is unavailable in this release. The current storage wrapper shells
|
||||||
|
# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1,
|
||||||
|
# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
|
||||||
|
# Future schema migration is non-operative: external bootstrap → TLS/roles → runner
|
||||||
|
# --run → runner --verify → readiness. Tier copy uses only the separately held secure
|
||||||
|
# migrate-tier route.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
## Standalone container deployment
|
||||||
|
|
||||||
|
The `stack` profile runs PostgreSQL, Valkey, the gateway, and the bundled webUI. Copy
|
||||||
|
`.env.example` to `.env`, generate `BETTER_AUTH_SECRET`, then start the profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
printf 'BETTER_AUTH_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
|
||||||
|
docker compose --profile stack up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The optional dogfood overlay gives one dedicated in-stack agent a writable stack
|
||||||
|
worktree and its own read-only credential slot. It does not mount the fleet brain or
|
||||||
|
any other seat. Prepare a `next`-based worktree and an unprivileged
|
||||||
|
`code-dogfood-01` functional seat outside the container, then set these paths in
|
||||||
|
`.env`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
MOSAIC_DOGFOOD_WORKTREE=/path/to/mosaic-stack-worktrees/dogfood-1487
|
||||||
|
MOSAIC_DOGFOOD_COMMON_GIT_DIR=/path/to/mosaic-stack/.git
|
||||||
|
MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/code-dogfood-01
|
||||||
|
```
|
||||||
|
|
||||||
|
The common Git directory must match the worktree's `.git` pointer. The seat home
|
||||||
|
must contain only that seat's credential at
|
||||||
|
`secrets/gitea-mosaicstack-code-dogfood-01.token`. Never place the token value in
|
||||||
|
`.env`. Start the overlay with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
-f docker-compose.dogfood.yml \
|
||||||
|
--profile stack up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The overlay removes the general shell tool for every session, including admins.
|
||||||
|
File tools stay inside the mounted checkout. Two dedicated delivery tools stage
|
||||||
|
explicit paths, run the CI queue guard, push through `git-credential-mosaic`, and
|
||||||
|
open PRs through `pr-create.sh`. They resolve only the `code-dogfood-01` slot and fail
|
||||||
|
if it is absent. The overlay enables Docker's init process so the R4 helper can
|
||||||
|
establish the gateway's seat lineage below PID 1.
|
||||||
|
|
||||||
|
This deployment route is separate from the local source-development restrictions
|
||||||
|
below.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js ≥ 22
|
||||||
|
- pnpm 10.6+
|
||||||
|
- Docker & Docker Compose
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone [email protected]:mosaicstack/stack.git
|
||||||
|
cd stack
|
||||||
|
|
||||||
|
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||||
|
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
||||||
|
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Verify dependencies and generated state before running source-quality gates.
|
||||||
|
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||||
|
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||||
|
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||||
|
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
||||||
|
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
||||||
|
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
||||||
|
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
||||||
|
# trust anchor outside worktree authority.
|
||||||
|
pnpm preflight
|
||||||
|
|
||||||
|
# Optional local queue service only. This does not start PostgreSQL.
|
||||||
|
docker compose up -d valkey
|
||||||
|
|
||||||
|
# The current Gateway/Web local process is held; see docs/guides/dev-guide.md.
|
||||||
|
# Do not start it until KBN-101-02 makes inherited dotenv/DSN state fail closed.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Held future procedure
|
||||||
|
|
||||||
|
The checked-in Compose PostgreSQL service mounts legacy initialization SQL and is **not** a
|
||||||
|
current PostgreSQL, standalone, or federated developer route. Do not start it with Compose,
|
||||||
|
invoke initialization SQL, or treat the planned migrator as currently executable.
|
||||||
|
|
||||||
|
**Held future activation procedure — non-operative and no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05
|
||||||
|
land:** external bootstrap → TLS/roles → `mosaic-db-migrator --run` →
|
||||||
|
`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
|
||||||
|
this README—will provide the reviewed commands and secret-consumer interface.
|
||||||
|
|
||||||
|
For local data-layer work, PGlite needs no PostgreSQL service. The optional Compose command above
|
||||||
|
starts only Valkey; OTEL Collector and Jaeger may likewise be started individually if needed,
|
||||||
|
without starting PostgreSQL. A Gateway/Web local process is not currently a safe PGlite route:
|
||||||
|
its unguarded dotenv loader may inherit a daemon PostgreSQL DSN. Do not use root `pnpm dev` or a
|
||||||
|
Gateway start command until KBN-101-02 makes that state fail closed.
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm preflight # Checkout/dependency/generated-state validation
|
||||||
|
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`
|
||||||
|
- **Legacy N-1 CI status only — active, uncertified, and non-authorizing as an operator route:** the checked-in job currently invokes `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` against an isolated disposable PostgreSQL CI database. It performs direct DDL in that CI database, is not approved ordinary behavior or an operator route, and remains a known exception pending KBN-101-06 removal/replacement by the certified runner-backed CI path.
|
||||||
|
- `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 (shipped with the framework package)
|
||||||
|
├── 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 --next # Prerelease lane: source build from next
|
||||||
|
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||||
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||||
|
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||||
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||||
|
|
||||||
|
## 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,54 +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 an adapter requires: a new directory, the contract implementation, and
|
|
||||||
adding the name to the allowlist in `scripts/mosaic-config.mjs`.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -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,96 +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; }
|
|
||||||
[ -r "$MOSAIC_SYSTEM_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).
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
# --no-* no ambient context/skills/extensions/templates/themes
|
|
||||||
# SESSION_FLAGS ephemeral | persistent | forked (per env)
|
|
||||||
# TOOLS_FLAG per capabilities
|
|
||||||
# --offline no startup network operations (update checks/telemetry)
|
|
||||||
PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
|
|
||||||
set -- \
|
|
||||||
--offline \
|
|
||||||
--no-extensions \
|
|
||||||
$SKILLS_FLAG \
|
|
||||||
--no-prompt-templates \
|
|
||||||
--no-themes \
|
|
||||||
--no-context-files \
|
|
||||||
$TOOLS_FLAG \
|
|
||||||
$SESSION_FLAGS \
|
|
||||||
--provider "$PI_PROVIDER" \
|
|
||||||
--model "$PI_MODEL" \
|
|
||||||
--system-prompt "$PROMPT_CONTENT"
|
|
||||||
# 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,42 +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 (Claude Code, Opus 5.5, thread b84bb264, lead decision 69); launcher Claude Code, Sonnet model | `agents/rocko/launch.sh` |
|
|
||||||
| Filbert | General development, investigation, testing, and review | T3 session (Claude Code, Opus 5.5); 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 | Pi, configured Mosaic model | `agents/researcher/launch.sh` |
|
|
||||||
| 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,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));
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user