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.
|
||||
# Copy to .env if you want to override the defaults in compose.yaml.
|
||||
#
|
||||
# NEVER put credentials in this file. Authentication is supplied at
|
||||
# runtime only, via one of the two documented paths:
|
||||
# 1. read-only mounted pi auth file (default: ~/.pi/agent/auth.json,
|
||||
# override the host path with PI_AUTH_FILE)
|
||||
# 2. provider API key environment variable (ZAI_API_KEY or
|
||||
# ANTHROPIC_API_KEY), passed through by compose.yaml when set
|
||||
# Mosaic Stack standalone deployment (compose `stack` profile)
|
||||
# Copy to .env and adjust. Port overrides exist because the defaults
|
||||
# collide with common host services (and with the dev compose itself).
|
||||
PG_HOST_PORT=5433
|
||||
VALKEY_HOST_PORT=6380
|
||||
GATEWAY_HOST_PORT=14242
|
||||
# Registry image override (defaults to a local build of docker/gateway.Dockerfile):
|
||||
# GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d
|
||||
|
||||
# Model provider (built-in pi provider name)
|
||||
PI_PROVIDER=zai
|
||||
|
||||
# Model ID within the provider
|
||||
PI_MODEL=glm-5.3-flash
|
||||
|
||||
# Optional: alternative host path of the pi credential file mounted
|
||||
# read-only at /home/node/.pi/agent/auth.json in the container
|
||||
#PI_AUTH_FILE=/home/jwoltje/.pi/agent/auth.json
|
||||
|
||||
# Optional: documented env-var auth alternative (secret! set in your
|
||||
# shell or a gitignored .env, never commit)
|
||||
#ZAI_API_KEY=
|
||||
#ANTHROPIC_API_KEY=
|
||||
# Optional explicit dogfood overlay (docker-compose.dogfood.yml).
|
||||
# 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
|
||||
# home of the unprivileged code-dogfood-01 functional seat.
|
||||
# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487
|
||||
# MOSAIC_DOGFOOD_COMMON_GIT_DIR=/home/example/src/mosaic-stack/.git
|
||||
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/code-dogfood-01
|
||||
|
||||
+27
-6
@@ -1,8 +1,29 @@
|
||||
# build/deps
|
||||
node_modules/
|
||||
|
||||
# runtime credentials — never commit, never copy into the image
|
||||
logs/
|
||||
node_modules
|
||||
dist
|
||||
.turbo
|
||||
.next
|
||||
coverage
|
||||
.env
|
||||
secrets/
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
__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.
|
||||
Read top to bottom; it is deliberately short — depth lives in the files it
|
||||
points to, not here.
|
||||
## Required Load Order
|
||||
|
||||
## What this repository is
|
||||
1. `~/.config/mosaic/SOUL.md`
|
||||
2. `~/.config/mosaic/STANDARDS.md`
|
||||
3. `~/.config/mosaic/AGENTS.md`
|
||||
4. `~/.config/mosaic/guides/E2E-DELIVERY.md`
|
||||
5. `AGENTS.md` (this file)
|
||||
6. Runtime-specific guide: `~/.config/mosaic/runtime/<runtime>/RUNTIME.md`
|
||||
|
||||
Canonical checkout: `/mnt/storage/src/mosaic-stack`, origin `mosaicstack/stack`,
|
||||
working branch `refactor` (Jason-authorized conversion, issue #1495).
|
||||
The new foundation is at the root. `v1/` is archived legacy source, not the current
|
||||
implementation; its instructions and tools do not govern the new foundation.
|
||||
`~/src/mosaic-stack-dev-test` is a compatibility symlink to this checkout, not a
|
||||
second working tree. Both original Git histories are retained. Conversion receipt:
|
||||
`docs/plans/2026-09-07_repository-consolidation-completed.md`.
|
||||
## Project Context
|
||||
|
||||
A rebuild of Mosaic Stack: a file-based, fail-closed
|
||||
orchestration foundation that dispatches sandboxed headless pi workers to do
|
||||
real work, with immutable run records as evidence. Thirteen-plus tagged
|
||||
milestones (`git tag -l`) from `poc-container-hello-v0` to today; suites
|
||||
green at every step. Not production software — a proven foundation.
|
||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture.
|
||||
|
||||
## Non-negotiable invariants (the canon)
|
||||
### Stack
|
||||
|
||||
1. **Root is bootstrap-only.** First-class system configuration lives at the
|
||||
repository root; everything else gets a dedicated directory (`roles/`,
|
||||
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
|
||||
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
|
||||
config — created only by `scripts/bootstrap.sh`, never overwritten,
|
||||
fail-closed on any problem. Repo-scoped role authority lives in
|
||||
`roles/*.json` (versioned, reviewed commits only).
|
||||
3. **Secrets** never enter the repository or container images; auth is
|
||||
runtime-only (read-only mount or environment variable).
|
||||
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
|
||||
tasks are declarative JSON with strict schemas.
|
||||
5. **Run records** under `<dataRoot>/runs/` are write-once evidence — never
|
||||
rewritten, only pruned via `prune` with a receipt.
|
||||
6. **Fail closed**: missing or invalid config/policy refuses the operation.
|
||||
Never improvise around a refusal; diagnose it.
|
||||
7. **Policy**: missions govern tasks (least-privilege intersection — a task
|
||||
narrows, never widens). Role authority is declared in `roles/` and changes
|
||||
only via reviewed commits.
|
||||
8. **Git**: commit only after applicable suites are green. Work on the
|
||||
owner-authorized `refactor` branch; never force-push. Push remains an explicit
|
||||
act. Do not merge into `next` or `main` without separate authorization.
|
||||
`scripts/conductor-apply.sh` commits locally; it does not authorize a push.
|
||||
9. **Append-only logs**: BUILD-LOG.md (phases), `activation-log.jsonl`,
|
||||
`.pruned.log`, docs/SESSIONS.md. Corrections are new entries, never edits.
|
||||
- **API:** NestJS with Fastify (`apps/gateway`)
|
||||
- **Web:** Next.js 16 with React 19 (`apps/web`)
|
||||
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
|
||||
- **Authentication:** BetterAuth (`packages/auth`)
|
||||
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
|
||||
- **Queue:** Valkey 8 (`packages/queue`)
|
||||
- **Build:** pnpm workspaces and Turborepo
|
||||
- **CI:** Woodpecker CI
|
||||
- **Observability:** OpenTelemetry and Jaeger
|
||||
|
||||
## Autonomous operation within an agreed plan
|
||||
### Package Map
|
||||
|
||||
Autonomy starts after alignment, not before it. For a new substantial assignment,
|
||||
recover the applicable mission, goal, task, `CURRENT.md` state, and prior owner
|
||||
decisions, then work with the user to establish a plan of action: the intended
|
||||
outcome, acceptance evidence, boundaries, and any gated actions. Recommend a
|
||||
concrete plan instead of presenting an open-ended menu. A direct request or
|
||||
existing approved plan that already settles those points is sufficient alignment;
|
||||
do not ask for ceremonial reconfirmation.
|
||||
| Package | Purpose | Key Dependencies |
|
||||
| ------------------ | ----------------------------- | -------------------------------- |
|
||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
|
||||
| `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
|
||||
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.
|
||||
## Architecture and Code Conventions
|
||||
|
||||
### 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
|
||||
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.
|
||||
## Development Workflow
|
||||
|
||||
### 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
|
||||
to reverse.
|
||||
2. The next action would exceed the agreed scope or authority, introduce an
|
||||
unapproved breaking public/API/schema/data/policy change, or alter a security
|
||||
boundary.
|
||||
3. Credentials or access are missing and no in-scope path remains.
|
||||
4. The action is destructive, irreversible, production-affecting, incurs spend,
|
||||
or communicates externally on the user's behalf without explicit authority.
|
||||
5. Objectives or owner decisions genuinely conflict and repository evidence
|
||||
cannot resolve them.
|
||||
6. A fail-closed policy refusal or another agent's overlapping ownership prevents
|
||||
safe progress. Diagnose and report it; never route around it.
|
||||
# Optional local queue service only; do not start the full Compose stack.
|
||||
docker compose up -d valkey
|
||||
```
|
||||
|
||||
Repository gates still apply. In particular, a successful implementation or a
|
||||
broad request to “finish” does not by itself authorize push, merge, deployment,
|
||||
release, production changes, policy/role expansion, or access to secrets. Perform
|
||||
such an action only when the established plan explicitly includes it. If blocked,
|
||||
report the exact boundary, what is complete, the recommended resolution, and the
|
||||
specific action that will resume; do not use “waiting for confirmation” as a
|
||||
substitute for a real blocker.
|
||||
The pre-push hook requires:
|
||||
|
||||
## Session protocol (mandatory)
|
||||
```bash
|
||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
```
|
||||
|
||||
- **Register** your session in `docs/SESSIONS.md` — one append-only line
|
||||
(date, actor, scope, outcome). Never rewrite or remove entries.
|
||||
- **Cadence**: run `scripts/mosaic queue next <your seat>` first. It names
|
||||
the row to resume, review or start, or says there is nothing. The goal order
|
||||
in `docs/plans/2026-09-27_goals-review.md` sets priority, not CURRENT.md.
|
||||
Open only the brief that row links to. Execute it through every authorized
|
||||
stage (implement → test → verify against acceptance criteria; commit, push,
|
||||
or close only when the established plan authorizes each) → move the row with
|
||||
`scripts/mosaic queue move` (never by editing QUEUE.md) → register in
|
||||
SESSIONS.md.
|
||||
- "next" means one action. A batch mandate ("run the queue") repeats the
|
||||
loop until green or truly blocked under the boundary rules above.
|
||||
- Substantial work gets a Gitea issue and a BUILD-LOG phase entry
|
||||
(before/after, with corrections recorded honestly).
|
||||
Software delivery also requires the applicable tests. Common repository commands are:
|
||||
|
||||
## 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
|
||||
`/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.
|
||||
## Branch Model and Merge Process — `main` and `next` (CANONICAL)
|
||||
|
||||
For control-board attention, start a completed reply with `Input needed: ` and
|
||||
one specific nonempty request only when Jason must provide a decision or input.
|
||||
Put that line at column zero, before other text. Do not use it for routine
|
||||
completion or a wait on another agent. Ordinary completed replies are idle.
|
||||
Use code fences or blockquotes when showing this convention as an example.
|
||||
The signal is advisory status, never permission for a protected action. Seen
|
||||
acknowledges a request; it does not resolve it. See `packages/control-board/README.md`.
|
||||
**Every contribution targets `next` first. No exceptions.** Features, fixes, tests,
|
||||
docs, and policy changes all take the same route; urgency changes queue priority,
|
||||
never the route. Agents never commit to or merge into `main`.
|
||||
|
||||
## 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
|
||||
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.
|
||||
### Contribution sequencing (in order, no skipping)
|
||||
|
||||
## 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` ·
|
||||
`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`.
|
||||
### Responsibilities
|
||||
|
||||
Full reference — usage, fields, exit codes, safety notes:
|
||||
`docs/TOOLS.md` (read on demand; do not rely on this summary for detail).
|
||||
- **Contributor** — base pinning, green CI, evidence in the PR body,
|
||||
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
|
||||
auto-written).
|
||||
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
|
||||
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
|
||||
- `sessions/` — pi JSONL session trees, one directory per named session
|
||||
- `workspaces/` — agent file effects (persistent or `:run` ephemeral)
|
||||
- `state/` — release pointer + append-only activation/auto-apply logs
|
||||
- Ownership is per-directory; nothing shares state. Directory map and
|
||||
lifecycle rules: README.md "Data map" section.
|
||||
- A hotfix follows the same path: branch from `next`, PR to `next`, gates,
|
||||
merge, then an expedited Jason-owned promotion if `main` needs it urgently.
|
||||
Committing the fix to `main` directly is prohibited even under pressure.
|
||||
- **Never land work on `main` that is not on `next`.** This has happened
|
||||
(issue #1152's goal controller reached `main` without reaching `next`) and
|
||||
every later PR paid for it. If it happens anyway: transplant the work onto
|
||||
a `next`-based branch with provenance-preserving commits
|
||||
(`git cherry-pick -x` or explicit SHA references in the messages), PR it
|
||||
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)
|
||||
- `docs/plans/QUEUE.md` — THE task list, rendered from `docs/plans/queue.json`
|
||||
(`scripts/mosaic queue next <seat>` reads it; `packages/queue/README.md` has the verbs)
|
||||
- `docs/plans/CURRENT.md` — narrative log behind the queue rows
|
||||
- `docs/plans/ROADMAP.md` — agreed milestone path (M16+)
|
||||
- `docs/plans/CONDUCTOR.md` — orchestration protocol and guardrails
|
||||
- `docs/plans/2026-09-02_atomic-mosaic-foundation.md` — architecture, invariants
|
||||
- `docs/plans/2026-09-03_autonomous-run.md` — batch-run tracker
|
||||
- `BUILD-LOG.md` — append-only build/verification history with corrections
|
||||
- `LAYERS.md` — implemented vs deferred layers
|
||||
- `docs/SESSIONS.md` — session registry
|
||||
- `adapters/README.md` — the harness adapter contract
|
||||
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
|
||||
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
|
||||
- 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.
|
||||
- Migration artifact generation is offline and does not authorize PostgreSQL access:
|
||||
|
||||
## Recovery rule
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:generate
|
||||
```
|
||||
|
||||
Compacted, restarted, or new? Nothing that matters is lost: this file +
|
||||
`scripts/mosaic queue next <seat>` + `docs/plans/CURRENT.md` +
|
||||
`git log --oneline -10` + the suites reconstruct the full state. **Never
|
||||
guess** — verify with the suites; the run records and logs hold the receipts.
|
||||
## docs/TASKS.md — Schema (CANONICAL)
|
||||
|
||||
## 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` /
|
||||
`RELEASE`); never install unversioned. Release identity: `RELEASE` file
|
||||
(0.0.X until declared stable); image tags derive from it.
|
||||
| Value | When to use | Budget |
|
||||
| --------- | ----------------------------------------------------------- | -------------------------- |
|
||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
||||
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
||||
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
||||
|
||||
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.
|
||||
-4094
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
|
||||
source is archived under `v1/`; it is not the implementation being developed here.
|
||||
Self-hosted, multi-user AI agent platform. One config, every runtime, same standards.
|
||||
|
||||
- Canonical checkout: `/mnt/storage/src/mosaic-stack`
|
||||
- Repository: `mosaicstack/stack`
|
||||
- Working branch: `refactor`
|
||||
- Former `~/src/mosaic-stack-dev-test`: compatibility symlink to this same checkout
|
||||
Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi — injecting consistent system prompts, guardrails, skills, and mission context into every session. A NestJS gateway provides the API surface, a Next.js dashboard gives you the UI, and a plugin system connects Discord, Telegram, and more.
|
||||
|
||||
Both original Git histories and pending development work are preserved. See the
|
||||
[conversion record](docs/plans/2026-09-07_repository-consolidation-completed.md)
|
||||
and [current next action](docs/plans/CURRENT.md). Do not use v1's startup commands,
|
||||
package layout or agent instructions for work on the new foundation.
|
||||
|
||||
## Original container proof
|
||||
|
||||
The foundation began as a standalone container experiment. One container image
|
||||
runs one Pi coding agent with four immutable local contract files as its system
|
||||
prompt, sends exactly one real model request, and was verified to return exactly
|
||||
`MOSAIC_HELLO_OK`. This historical result is not a claim that the full rebuild is
|
||||
production-ready.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
BRIEF.md requirements for the original container proof
|
||||
BUILD-LOG.md append-only build/verification log
|
||||
LAYERS.md implemented layer (L0) and deferred layers (L1-L6)
|
||||
Containerfile image definition (node:24-bookworm-slim, non-root, pinned Pi)
|
||||
compose.yaml one service: mosaic-agent (one-shot; configured via env)
|
||||
package.json pins @earendil-works/pi-coding-agent at exactly 0.84.4
|
||||
package-lock.json resolved lockfile used by npm ci in the image
|
||||
.env.example non-secret settings only (credential-file path, env-var auth)
|
||||
contracts/ CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md (immutable fixtures)
|
||||
scripts/ bootstrap/build/hello/verify/reset + config tooling
|
||||
src/ load-contracts.sh, run-agent.sh (run inside the container)
|
||||
docs/plans/ architecture and milestone plans
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The sole discovery entry point is:
|
||||
|
||||
```text
|
||||
~/.config/mosaic-dev/config.json
|
||||
```
|
||||
|
||||
Created only by the explicit, idempotent bootstrap:
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
scripts/bootstrap.sh # create-if-absent; validates existing config, never rewrites
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
```
|
||||
|
||||
Minimal shape (`configVersion` 1):
|
||||
|
||||
```json
|
||||
{
|
||||
"configVersion": 1,
|
||||
"environment": "development",
|
||||
"dataRoot": "/home/jwoltje/.mosaic-dev",
|
||||
"execution": {
|
||||
"backend": "docker",
|
||||
"provider": "zai",
|
||||
"model": "glm-5.3-flash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules enforced by `scripts/mosaic-config.mjs`:
|
||||
|
||||
- Unknown keys, unsupported versions/backends, and malformed JSON exit nonzero; nothing is modified.
|
||||
- `dataRoot` must be absolute, canonical, and must not be or contain the home or configuration directory.
|
||||
- Validation failures never touch config, state, or images.
|
||||
- `scripts/test-config.sh` runs the sandboxed config selftests (no Docker required).
|
||||
|
||||
Run paths (`build/hello/verify/reset`) fail closed when configuration is missing or invalid; they never invent it.
|
||||
|
||||
## Missions & tasks (M2)
|
||||
|
||||
Missions and tasks are validated JSON data (strict schemas, version-pinned). The M2 layer is host-side only: mission directives are recorded for provenance but do not yet reach the runtime system prompt (capability/policy layer comes later).
|
||||
|
||||
```text
|
||||
missions/hello.json objective + directives (missionVersion 1)
|
||||
tasks/hello-marker.json prompt + optional mission ref + expectExact + timeout
|
||||
<dataRoot>/runs/r-<id>/ immutable run record: task.json, mission.json,
|
||||
stderr.txt, result.json (all write-once)
|
||||
```
|
||||
|
||||
Usage:
|
||||
Or use the direct URL:
|
||||
|
||||
```bash
|
||||
scripts/run-task.sh validate tasks/hello-marker.json # strict validation, writes nothing
|
||||
scripts/run-task.sh run tasks/hello-marker.json # execute; result recorded under dataRoot/runs
|
||||
scripts/mosaic-task.mjs list # list runs and statuses
|
||||
scripts/test-task.sh # selftests (schema negatives + live runs)
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
A run exits 0 only when its expectation is met (`expectExact` match); mismatches, nonzero agent exits, and timeouts record `status: failed` in `result.json` and exit 1. Each run gets a unique directory — rerunning never rewrites history.
|
||||
|
||||
## Release model (M3)
|
||||
|
||||
`RELEASE` single-sources the release version (0.0.X until declared stable); the image tag derives from it plus the pinned Pi version. Activation is health-gated and every event is recorded:
|
||||
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
||||
|
||||
```bash
|
||||
scripts/release.sh package # build + tag the release image
|
||||
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
|
||||
scripts/release.sh activate --fault-injection # prove the refusal path (drills only)
|
||||
scripts/release.sh rollback # health-gated return to the previous release
|
||||
scripts/release.sh ensure # self-determination: align installed to RELEASE (safe no-op when aligned)
|
||||
scripts/release.sh status # release, tag, active pointer, recent log
|
||||
scripts/test-release.sh # release selftests
|
||||
bash <(curl -fsSL …) --yes # Accept all defaults
|
||||
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
|
||||
```
|
||||
|
||||
`ensure` is invoked automatically by the human-facing launchers (`hello`,
|
||||
`verify`, `agent`): the system determines what is installed and aligns
|
||||
itself — the user never runs release commands manually.
|
||||
This installs both components:
|
||||
|
||||
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
|
||||
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
|
||||
| Component | What | Where |
|
||||
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||
|
||||
A failed health check never activates; the previously active release remains deployed. Updating the software therefore cannot corrupt the running installation: package beside, gate, then flip. Verified by the update/refusal/rollback drills in BUILD-LOG Phase 7.
|
||||
### 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
|
||||
adapters/<name>/adapter.sh env in: MOSAIC_SYSTEM_PROMPT_FILE, MOSAIC_REQUEST,
|
||||
MOSAIC_PROVIDER, MOSAIC_MODEL
|
||||
stdout: response only; stderr: diagnostics
|
||||
```
|
||||
|
||||
- Selection: `execution.adapter` in config.json (optional; `pi` default; allowlist `pi`, `mock`)
|
||||
- `pi` — pinned Pi CLI, noninteractive print mode, ambient discovery off
|
||||
- `mock` — deterministic test adapter; never for real verification
|
||||
- Mission directives have a sanctioned injection point: when a task references a mission, the task runner mounts the run snapshot and the generated prompt gains a `MISSION (runtime)` section (objective + directives) after the four immutable contracts
|
||||
- Adding a harness (Claude, Codex, OpenCode) later means adding one directory — no orchestrator changes
|
||||
|
||||
See `adapters/README.md` for the full contract.
|
||||
|
||||
## Workspaces, capabilities, sessions (M5/M6)
|
||||
|
||||
Optional task fields extend what an agent can do — all defaulting to the previous behavior:
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "demo", // ":run" ephemeral, or persistent dataRoot/workspaces/<name>
|
||||
"capabilities": { "tools": ["bash", "read"] }, // pi tool allowlist; absent = no tools
|
||||
"session": "demo" // persistent session at dataRoot/sessions/<name>
|
||||
}
|
||||
```
|
||||
|
||||
- The adapter runs inside the workspace; files it writes are host-visible (`dataRoot/workspaces/<name>`).
|
||||
- Sessions persist via pi's documented `--session-dir`; a follow-up run in the same session resumes the conversation (`-c`) and can recall prior context. Distinct names never share state. Ephemeral (`--no-session`) remains the default when no session is declared.
|
||||
- Selection authority: config for adapter/provider/model; the task file for workspace/capabilities/session.
|
||||
|
||||
Inspect anything:
|
||||
After install, the wizard runs automatically or you can invoke it manually:
|
||||
|
||||
```bash
|
||||
node scripts/mosaic-task.mjs list # runs with task/workspace/session columns
|
||||
node scripts/mosaic-task.mjs show <runId> # full record + snapshots + artifacts
|
||||
mosaic wizard # Full guided setup (gateway install → verify)
|
||||
```
|
||||
|
||||
Demo fixtures: `tasks/workspace-demo.json`, `tasks/session-demo-1.json` + `tasks/session-demo-2.json`.
|
||||
### Requirements
|
||||
|
||||
See `docs/plans/2026-09-02_atomic-mosaic-foundation.md` for the full plan.
|
||||
|
||||
Inside the container:
|
||||
|
||||
```text
|
||||
/opt/mosaic/contracts immutable contract files
|
||||
/var/lib/mosaic generated runtime state (mounted from configured dataRoot)
|
||||
/workspace agent workspace
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. `scripts/build.sh` builds the release image (`mosaic-poc-agent:<pi>-r<release>`,
|
||||
tag derived from `RELEASE` + the pinned Pi version) with Docker Compose.
|
||||
2. On each run, `/opt/mosaic/src/load-contracts.sh` reads the four contract files
|
||||
in fixed order (CONSTITUTION, STANDARDS, SOUL, USER), joins them with clear
|
||||
separators, and writes `/var/lib/mosaic/system-prompt.md`.
|
||||
3. `/opt/mosaic/src/run-agent.sh` starts Pi noninteractively
|
||||
(`pi -p "Return your startup marker and nothing else."`) with
|
||||
`--system-prompt "$(cat /var/lib/mosaic/system-prompt.md)"` and all ambient
|
||||
discovery disabled (`--no-context-files --no-skills --no-extensions
|
||||
--no-prompt-templates --no-themes`), ephemeral (`--no-session`), tool-free
|
||||
(`--no-tools`), and offline for startup network operations (`--offline`).
|
||||
4. `scripts/verify.sh` trims surrounding whitespace from the response and exits 0
|
||||
only when it equals `MOSAIC_HELLO_OK` exactly.
|
||||
- Node.js ≥ 22
|
||||
- npm (for global @mosaicstack/mosaic install)
|
||||
- One or more runtimes:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Codex](https://github.com/openai/codex)
|
||||
- [OpenCode](https://opencode.ai)
|
||||
- [Pi](https://pi.dev)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
scripts/bootstrap.sh # create config.json if absent (idempotent)
|
||||
scripts/build.sh # build the image
|
||||
scripts/hello.sh # one-shot request; prints the model response
|
||||
scripts/verify.sh # full gated test; exit 0 only on exact MOSAIC_HELLO_OK
|
||||
scripts/run-task.sh # run a mission/task file (see Missions & tasks)
|
||||
scripts/release.sh # package / activate / rollback / status (see Release model)
|
||||
scripts/test-config.sh # fast config-layer selftests (no Docker)
|
||||
scripts/test-task.sh # mission/task selftests (schema + adapter seam + live runs)
|
||||
scripts/test-release.sh # release selftests
|
||||
scripts/reset.sh # delete the configured data root (safety-checked)
|
||||
```
|
||||
|
||||
Prove the failure path (acceptance criterion 9):
|
||||
### Launching Agent Sessions
|
||||
|
||||
```bash
|
||||
EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh # must exit nonzero
|
||||
mosaic pi # Launch Pi with Mosaic injection
|
||||
mosaic claude # Launch Claude Code with Mosaic injection
|
||||
mosaic codex # Launch Codex with Mosaic injection
|
||||
mosaic opencode # Launch OpenCode with Mosaic injection
|
||||
|
||||
mosaic yolo claude # Claude with dangerous-permissions mode
|
||||
mosaic yolo pi # Pi in yolo mode
|
||||
```
|
||||
|
||||
## Authentication
|
||||
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
|
||||
|
||||
Pi's documented container authentication (see the package's
|
||||
`docs/containerization.md`) is used, in this order:
|
||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
||||
|
||||
1. **Read-only mounted credential file** (default): the host pi auth file
|
||||
`~/.pi/agent/auth.json` is bind-mounted read-only to
|
||||
`/home/node/.pi/agent/auth.json`. The host file holds a static API-key
|
||||
entry for the built-in `zai` provider, so no token refresh writes are needed.
|
||||
2. **Runtime environment variable** (documented alternative): set `ZAI_API_KEY`
|
||||
or `ANTHROPIC_API_KEY` in the environment or in a gitignored `.env`; compose
|
||||
passes them through. Pi's documented precedence applies.
|
||||
Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi,
|
||||
`/goal set <statement>` starts a bounded persistent loop that checks every turn and successful
|
||||
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
|
||||
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
|
||||
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
|
||||
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
|
||||
into `~/.pi/agent/extensions/`.
|
||||
|
||||
Credentials are never committed, never copied into the image, and never printed.
|
||||
Mosaic-managed named accounts (`agent.sh --auth`) live under the data root
|
||||
(`auth/<account>.json`, 0600) — the stack never writes into `~/.pi`.
|
||||
`.env.example` contains non-secret settings only.
|
||||
### TUI & Gateway
|
||||
|
||||
## 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.
|
||||
- Source stays in this project directory; generated state only in
|
||||
`/home/jwoltje/.mosaic-dev` (host) and `/var/lib/mosaic` (container).
|
||||
- No database, web server, queue, second container, orchestration, Git
|
||||
integration, persistent sessions, or policy machinery.
|
||||
### Gateway Management
|
||||
|
||||
```bash
|
||||
mosaic gateway install # Install and configure the gateway service
|
||||
mosaic gateway verify # Post-install health check
|
||||
mosaic gateway login # Authenticate and store a session token
|
||||
mosaic gateway config rotate-token # Rotate your API token
|
||||
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
|
||||
```
|
||||
|
||||
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
|
||||
|
||||
### Configuration
|
||||
|
||||
Mosaic supports three storage tiers: `local` (PGlite, single-host), `standalone` (PostgreSQL, single-host), and `federated` (PostgreSQL + pgvector + Valkey, multi-host). See [Federated Tier Setup](docs/federation/SETUP.md) for multi-user and production deployments, or [Migrating to Federated](docs/guides/migrate-tier.md) to upgrade from existing tiers.
|
||||
|
||||
```bash
|
||||
mosaic config show # Print full config as JSON
|
||||
mosaic config get <key> # Read a specific key
|
||||
mosaic config set <key> <val># Write a key
|
||||
mosaic config edit # Open config in $EDITOR
|
||||
mosaic config path # Print config file path
|
||||
```
|
||||
|
||||
### Management
|
||||
|
||||
```bash
|
||||
mosaic doctor # Health audit — detect drift and missing files
|
||||
mosaic sync # Sync skills from canonical source
|
||||
mosaic 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