Compare commits
653
Commits
+23
-15
@@ -1,16 +1,24 @@
|
|||||||
# Mosaic Stack standalone deployment (compose `stack` profile)
|
# Non-secret runtime settings for the mosaic-poc-agent container.
|
||||||
# Copy to .env and adjust. Port overrides exist because the defaults
|
# Copy to .env if you want to override the defaults in compose.yaml.
|
||||||
# collide with common host services (and with the dev compose itself).
|
#
|
||||||
PG_HOST_PORT=5433
|
# NEVER put credentials in this file. Authentication is supplied at
|
||||||
VALKEY_HOST_PORT=6380
|
# runtime only, via one of the two documented paths:
|
||||||
GATEWAY_HOST_PORT=14242
|
# 1. read-only mounted pi auth file (default: ~/.pi/agent/auth.json,
|
||||||
# Registry image override (defaults to a local build of docker/gateway.Dockerfile):
|
# override the host path with PI_AUTH_FILE)
|
||||||
# GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d
|
# 2. provider API key environment variable (ZAI_API_KEY or
|
||||||
|
# ANTHROPIC_API_KEY), passed through by compose.yaml when set
|
||||||
|
|
||||||
# Optional explicit dogfood overlay (docker-compose.dogfood.yml).
|
# Model provider (built-in pi provider name)
|
||||||
# All three paths are required when that overlay is used. Use a dedicated
|
PI_PROVIDER=zai
|
||||||
# next-based worktree, its canonical clone's .git directory, and the external
|
|
||||||
# home of the unprivileged code-dogfood-01 functional seat.
|
# Model ID within the provider
|
||||||
# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487
|
PI_MODEL=glm-5.3-flash
|
||||||
# MOSAIC_DOGFOOD_COMMON_GIT_DIR=/home/example/src/mosaic-stack/.git
|
|
||||||
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/code-dogfood-01
|
# 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=
|
||||||
|
|||||||
+6
-27
@@ -1,29 +1,8 @@
|
|||||||
logs/
|
# build/deps
|
||||||
node_modules
|
node_modules/
|
||||||
dist
|
|
||||||
.turbo
|
# runtime credentials — never commit, never copy into the image
|
||||||
.next
|
|
||||||
coverage
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
secrets/
|
||||||
*.tsbuildinfo
|
|
||||||
.pnpm-store
|
|
||||||
__pycache__/
|
|
||||||
docs/.obsidian
|
|
||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
# generated runtime state lives in /home/jwoltje/.mosaic-dev (outside this project)
|
||||||
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/
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"projectVersion": 1,
|
||||||
|
"id": "stack",
|
||||||
|
"vars": {
|
||||||
|
"tracker.project": 32,
|
||||||
|
"git.workingBranch": "refactor",
|
||||||
|
"git.protectedBranches": [
|
||||||
|
"main",
|
||||||
|
"next"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
extensions/
|
||||||
|
extensions.installed.sha256
|
||||||
|
.extensions-*
|
||||||
|
state/
|
||||||
|
evidence/
|
||||||
|
native-test-*.log
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/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,186 +1,217 @@
|
|||||||
# Agent Guidelines — Mosaic Stack
|
# AGENTS.md — Mosaic Stack rebuild (`mosaicstack/stack`, branch `refactor`)
|
||||||
|
|
||||||
## Required Load Order
|
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.
|
||||||
|
|
||||||
1. `~/.config/mosaic/SOUL.md`
|
## What this repository is
|
||||||
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`
|
|
||||||
|
|
||||||
## Project Context
|
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`.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Stack
|
## Non-negotiable invariants (the canon)
|
||||||
|
|
||||||
- **API:** NestJS with Fastify (`apps/gateway`)
|
1. **Root is bootstrap-only.** First-class system configuration lives at the
|
||||||
- **Web:** Next.js 16 with React 19 (`apps/web`)
|
repository root; everything else gets a dedicated directory (`roles/`,
|
||||||
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
|
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
|
||||||
- **Authentication:** BetterAuth (`packages/auth`)
|
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
|
||||||
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
|
config — created only by `scripts/bootstrap.sh`, never overwritten,
|
||||||
- **Queue:** Valkey 8 (`packages/queue`)
|
fail-closed on any problem. Repo-scoped role authority lives in
|
||||||
- **Build:** pnpm workspaces and Turborepo
|
`roles/*.json` (versioned, reviewed commits only).
|
||||||
- **CI:** Woodpecker CI
|
3. **Secrets** never enter the repository or container images; auth is
|
||||||
- **Observability:** OpenTelemetry and Jaeger
|
runtime-only (read-only mount or environment variable).
|
||||||
|
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
|
||||||
|
tasks are declarative JSON with strict schemas.
|
||||||
|
5. **Run records** under `<dataRoot>/runs/` are write-once evidence — never
|
||||||
|
rewritten, only pruned via `prune` with a receipt.
|
||||||
|
6. **Fail closed**: missing or invalid config/policy refuses the operation.
|
||||||
|
Never improvise around a refusal; diagnose it.
|
||||||
|
7. **Policy**: missions govern tasks (least-privilege intersection — a task
|
||||||
|
narrows, never widens). Role authority is declared in `roles/` and changes
|
||||||
|
only via reviewed commits.
|
||||||
|
8. **Git**: commit only after applicable suites are green. Work on the
|
||||||
|
owner-authorized `refactor` branch; never force-push. Push remains an explicit
|
||||||
|
act. Do not merge into `next` or `main` without separate authorization.
|
||||||
|
`scripts/conductor-apply.sh` commits locally; it does not authorize a push.
|
||||||
|
9. **Append-only logs**: BUILD-LOG.md (phases), `activation-log.jsonl`,
|
||||||
|
`.pruned.log`, docs/SESSIONS.md. Corrections are new entries, never edits.
|
||||||
|
|
||||||
### Package Map
|
## Autonomous operation within an agreed plan
|
||||||
|
|
||||||
| Package | Purpose | Key Dependencies |
|
Autonomy starts after alignment, not before it. For a new substantial assignment,
|
||||||
| ------------------ | ----------------------------- | -------------------------------- |
|
recover the applicable mission, goal, task, `CURRENT.md` state, and prior owner
|
||||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
decisions, then work with the user to establish a plan of action: the intended
|
||||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
outcome, acceptance evidence, boundaries, and any gated actions. Recommend a
|
||||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
concrete plan instead of presenting an open-ended menu. A direct request or
|
||||||
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
|
existing approved plan that already settles those points is sufficient alignment;
|
||||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
do not ask for ceremonial reconfirmation.
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## Architecture and Code Conventions
|
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.
|
||||||
|
|
||||||
1. Gateway is the single API surface; all clients connect through it.
|
### Decide and continue
|
||||||
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.
|
|
||||||
|
|
||||||
## Development Workflow
|
- 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.
|
||||||
|
|
||||||
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
|
### Re-align or stop only at a real boundary
|
||||||
|
|
||||||
```bash
|
Finish all independent work first, then ask one focused question only when:
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm preflight
|
|
||||||
|
|
||||||
# Optional local queue service only; do not start the full Compose stack.
|
1. Two plausible readings materially change the outcome and the choice is costly
|
||||||
docker compose up -d valkey
|
to reverse.
|
||||||
```
|
2. The next action would exceed the agreed scope or authority, introduce an
|
||||||
|
unapproved breaking public/API/schema/data/policy change, or alter a security
|
||||||
|
boundary.
|
||||||
|
3. Credentials or access are missing and no in-scope path remains.
|
||||||
|
4. The action is destructive, irreversible, production-affecting, incurs spend,
|
||||||
|
or communicates externally on the user's behalf without explicit authority.
|
||||||
|
5. Objectives or owner decisions genuinely conflict and repository evidence
|
||||||
|
cannot resolve them.
|
||||||
|
6. A fail-closed policy refusal or another agent's overlapping ownership prevents
|
||||||
|
safe progress. Diagnose and report it; never route around it.
|
||||||
|
|
||||||
The pre-push hook requires:
|
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.
|
||||||
|
|
||||||
```bash
|
## Session protocol (mandatory)
|
||||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
|
||||||
```
|
|
||||||
|
|
||||||
Software delivery also requires the applicable tests. Common repository commands are:
|
- **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).
|
||||||
|
|
||||||
```bash
|
## Internal development bootstrap
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Branch Model and Merge Process — `main` and `next` (CANONICAL)
|
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.
|
||||||
|
|
||||||
**Every contribution targets `next` first. No exceptions.** Features, fixes, tests,
|
For control-board attention, start a completed reply with `Input needed: ` and
|
||||||
docs, and policy changes all take the same route; urgency changes queue priority,
|
one specific nonempty request only when Jason must provide a decision or input.
|
||||||
never the route. Agents never commit to or merge into `main`.
|
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`.
|
||||||
|
|
||||||
| Branch | Role | Who merges into it |
|
## Role model
|
||||||
| ------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
||||||
| `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). |
|
|
||||||
|
|
||||||
### Contribution sequencing (in order, no skipping)
|
- **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.
|
||||||
|
|
||||||
1. **Issue first.** Work is tracked in a Gitea issue before a branch exists. The
|
## Command surface
|
||||||
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.
|
|
||||||
|
|
||||||
### Responsibilities
|
`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`.
|
||||||
|
|
||||||
- **Contributor** — base pinning, green CI, evidence in the PR body,
|
Full reference — usage, fields, exit codes, safety notes:
|
||||||
responding to AMEND verdicts, never merging own work.
|
`docs/TOOLS.md` (read on demand; do not rely on this summary for detail).
|
||||||
- **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.
|
|
||||||
|
|
||||||
### Hotfixes and divergence
|
## Data map (canon)
|
||||||
|
|
||||||
- A hotfix follows the same path: branch from `next`, PR to `next`, gates,
|
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
|
||||||
merge, then an expedited Jason-owned promotion if `main` needs it urgently.
|
auto-written).
|
||||||
Committing the fix to `main` directly is prohibited even under pressure.
|
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
|
||||||
- **Never land work on `main` that is not on `next`.** This has happened
|
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
|
||||||
(issue #1152's goal controller reached `main` without reaching `next`) and
|
- `sessions/` — pi JSONL session trees, one directory per named session
|
||||||
every later PR paid for it. If it happens anyway: transplant the work onto
|
- `workspaces/` — agent file effects (persistent or `:run` ephemeral)
|
||||||
a `next`-based branch with provenance-preserving commits
|
- `state/` — release pointer + append-only activation/auto-apply logs
|
||||||
(`git cherry-pick -x` or explicit SHA references in the messages), PR it
|
- Ownership is per-directory; nothing shares state. Directory map and
|
||||||
through the normal gates, and let promotion re-align `main`. Do not
|
lifecycle rules: README.md "Data map" section.
|
||||||
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.
|
|
||||||
|
|
||||||
## Database and Local Runtime Safety
|
## Pointers (depth lives here)
|
||||||
|
|
||||||
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
- `docs/plans/2026-09-27_goals-review.md` — north star and goal order (Jason ratified 2026-09-27)
|
||||||
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
- `docs/plans/QUEUE.md` — THE task list, rendered from `docs/plans/queue.json`
|
||||||
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
|
(`scripts/mosaic queue next <seat>` reads it; `packages/queue/README.md` has the verbs)
|
||||||
- Do not start Gateway/Web or run root `pnpm dev` as a local PGlite route. The current dotenv loader can inherit a daemon PostgreSQL DSN; KBN-101-02 must make that path fail closed first.
|
- `docs/plans/CURRENT.md` — narrative log behind the queue rows
|
||||||
- Migration artifact generation is offline and does not authorize PostgreSQL access:
|
- `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)
|
||||||
|
|
||||||
```bash
|
## Recovery rule
|
||||||
pnpm --filter @mosaicstack/db db:generate
|
|
||||||
```
|
|
||||||
|
|
||||||
## docs/TASKS.md — Schema (CANONICAL)
|
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.
|
||||||
|
|
||||||
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.**
|
## Version pin
|
||||||
|
|
||||||
| Value | When to use | Budget |
|
`@earendil-works/pi-coding-agent` is pinned exactly (see `package.json` /
|
||||||
| --------- | ----------------------------------------------------------- | -------------------------- |
|
`RELEASE`); never install unversioned. Release identity: `RELEASE` file
|
||||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
(0.0.X until declared stable); image tags derive from it.
|
||||||
| `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`
|
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# 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.
|
||||||
+4172
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1 @@
|
|||||||
# Claude Compatibility Pointer
|
|
||||||
|
|
||||||
@AGENTS.md
|
@AGENTS.md
|
||||||
|
|
||||||
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 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"]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 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,460 +1,238 @@
|
|||||||
# Mosaic Stack
|
# Mosaic Stack — new foundation
|
||||||
|
|
||||||
Self-hosted, multi-user AI agent platform. One config, every runtime, same standards.
|
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.
|
||||||
|
|
||||||
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.
|
- 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
|
||||||
|
|
||||||
## Quick Install
|
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.
|
||||||
|
|
||||||
```bash
|
## Original container proof
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the direct URL:
|
## Configuration
|
||||||
|
|
||||||
```bash
|
The sole discovery entry point is:
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
|
```text
|
||||||
|
~/.config/mosaic-dev/config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
Created only by the explicit, idempotent bootstrap:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL …) --yes # Accept all defaults
|
scripts/bootstrap.sh # create-if-absent; validates existing config, never rewrites
|
||||||
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs both components:
|
Minimal shape (`configVersion` 1):
|
||||||
|
|
||||||
| Component | What | Where |
|
```json
|
||||||
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
|
{
|
||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
"configVersion": 1,
|
||||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
"environment": "development",
|
||||||
|
"dataRoot": "/home/jwoltje/.mosaic-dev",
|
||||||
|
"execution": {
|
||||||
|
"backend": "docker",
|
||||||
|
"provider": "zai",
|
||||||
|
"model": "glm-5.3-flash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Install lanes
|
Rules enforced by `scripts/mosaic-config.mjs`:
|
||||||
|
|
||||||
| Lane | Command | Use when | Source |
|
- 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.
|
||||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
- Validation failures never touch config, state, or images.
|
||||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
- `scripts/test-config.sh` runs the sandboxed config selftests (no Docker required).
|
||||||
| 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 |
|
|
||||||
|
|
||||||
`--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.
|
Run paths (`build/hello/verify/reset`) fail closed when configuration is missing or invalid; they never invent it.
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
## Missions & tasks (M2)
|
||||||
|
|
||||||
|
Missions and tasks are validated JSON data (strict schemas, version-pinned). The M2 layer is host-side only: mission directives are recorded for provenance but do not yet reach the runtime system prompt (capability/policy layer comes later).
|
||||||
|
|
||||||
|
```text
|
||||||
|
missions/hello.json objective + directives (missionVersion 1)
|
||||||
|
tasks/hello-marker.json prompt + optional mission ref + expectExact + timeout
|
||||||
|
<dataRoot>/runs/r-<id>/ immutable run record: task.json, mission.json,
|
||||||
|
stderr.txt, result.json (all write-once)
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic wizard # Full guided setup (gateway install → verify)
|
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)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Requirements
|
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.
|
||||||
|
|
||||||
- Node.js ≥ 22
|
## Release model (M3)
|
||||||
- npm (for global @mosaicstack/mosaic install)
|
|
||||||
- One or more runtimes:
|
`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:
|
||||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
|
||||||
- [Codex](https://github.com/openai/codex)
|
```bash
|
||||||
- [OpenCode](https://opencode.ai)
|
scripts/release.sh package # build + tag the release image
|
||||||
- [Pi](https://pi.dev)
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
|
||||||
|
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
|
||||||
|
|
||||||
|
A failed health check never activates; the previously active release remains deployed. Updating the software therefore cannot corrupt the running installation: package beside, gate, then flip. Verified by the update/refusal/rollback drills in BUILD-LOG Phase 7.
|
||||||
|
|
||||||
|
## Runtime adapters (M4)
|
||||||
|
|
||||||
|
The harness boundary is formalized: everything upstream (config, contracts, missions, tasks, run records) is harness-agnostic; everything inside an adapter belongs to one runtime.
|
||||||
|
|
||||||
|
```text
|
||||||
|
adapters/<name>/adapter.sh env in: MOSAIC_SYSTEM_PROMPT_FILE, MOSAIC_REQUEST,
|
||||||
|
MOSAIC_PROVIDER, MOSAIC_MODEL
|
||||||
|
stdout: response only; stderr: diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
- Selection: `execution.adapter` in config.json (optional; `pi` default; allowlist `pi`, `mock`)
|
||||||
|
- `pi` — pinned Pi CLI, noninteractive print mode, ambient discovery off
|
||||||
|
- `mock` — deterministic test adapter; never for real verification
|
||||||
|
- Mission directives have a sanctioned injection point: when a task references a mission, the task runner mounts the run snapshot and the generated prompt gains a `MISSION (runtime)` section (objective + directives) after the four immutable contracts
|
||||||
|
- Adding a harness (Claude, Codex, OpenCode) later means adding one directory — no orchestrator changes
|
||||||
|
|
||||||
|
See `adapters/README.md` for the full contract.
|
||||||
|
|
||||||
|
## Workspaces, capabilities, sessions (M5/M6)
|
||||||
|
|
||||||
|
Optional task fields extend what an agent can do — all defaulting to the previous behavior:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workspace": "demo", // ":run" ephemeral, or persistent dataRoot/workspaces/<name>
|
||||||
|
"capabilities": { "tools": ["bash", "read"] }, // pi tool allowlist; absent = no tools
|
||||||
|
"session": "demo" // persistent session at dataRoot/sessions/<name>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- The adapter runs inside the workspace; files it writes are host-visible (`dataRoot/workspaces/<name>`).
|
||||||
|
- Sessions persist via pi's documented `--session-dir`; a follow-up run in the same session resumes the conversation (`-c`) and can recall prior context. Distinct names never share state. Ephemeral (`--no-session`) remains the default when no session is declared.
|
||||||
|
- Selection authority: config for adapter/provider/model; the task file for workspace/capabilities/session.
|
||||||
|
|
||||||
|
Inspect anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/mosaic-task.mjs list # runs with task/workspace/session columns
|
||||||
|
node scripts/mosaic-task.mjs show <runId> # full record + snapshots + artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
Demo fixtures: `tasks/workspace-demo.json`, `tasks/session-demo-1.json` + `tasks/session-demo-2.json`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Launching Agent Sessions
|
```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):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic pi # Launch Pi with Mosaic injection
|
EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh # must exit nonzero
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
|
## Authentication
|
||||||
|
|
||||||
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.
|
Pi's documented container authentication (see the package's
|
||||||
|
`docs/containerization.md`) is used, in this order:
|
||||||
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
|
1. **Read-only mounted credential file** (default): the host pi auth file
|
||||||
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
|
`~/.pi/agent/auth.json` is bind-mounted read-only to
|
||||||
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
|
`/home/node/.pi/agent/auth.json`. The host file holds a static API-key
|
||||||
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
|
entry for the built-in `zai` provider, so no token refresh writes are needed.
|
||||||
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
|
2. **Runtime environment variable** (documented alternative): set `ZAI_API_KEY`
|
||||||
into `~/.pi/agent/extensions/`.
|
or `ANTHROPIC_API_KEY` in the environment or in a gitignored `.env`; compose
|
||||||
|
passes them through. Pi's documented precedence applies.
|
||||||
### TUI & Gateway
|
|
||||||
|
Credentials are never committed, never copied into the image, and never printed.
|
||||||
```bash
|
Mosaic-managed named accounts (`agent.sh --auth`) live under the data root
|
||||||
mosaic tui # Interactive TUI connected to the gateway
|
(`auth/<account>.json`, 0600) — the stack never writes into `~/.pi`.
|
||||||
mosaic gateway login # Authenticate with a gateway instance
|
`.env.example` contains non-secret settings only.
|
||||||
mosaic sessions list # List active agent sessions
|
|
||||||
```
|
## Boundaries honored
|
||||||
|
|
||||||
### Gateway Management
|
- No mounts of `~/.mosaic` or `~/.config/mosaic`; no Docker socket mount.
|
||||||
|
- Source stays in this project directory; generated state only in
|
||||||
```bash
|
`/home/jwoltje/.mosaic-dev` (host) and `/var/lib/mosaic` (container).
|
||||||
mosaic gateway install # Install and configure the gateway service
|
- No database, web server, queue, second container, orchestration, Git
|
||||||
mosaic gateway verify # Post-install health check
|
integration, persistent sessions, or policy machinery.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/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:-}"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/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 "$@"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
===== 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.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 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.
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/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 "$@"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
fd10b62c10bff4736b9b4e809b283fe4c5b549fa53fe1121a7bb06258147f0e9 packages/conversation/tests/fake-pi.mjs
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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));
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// 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`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{"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"}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
✖ 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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
✖ 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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
✖ 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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
✔ 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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
2026-10-09T13:05:51Z
|
||||||
|
08:05:51 up 33 days, 9:40, 5 users, load average: 4.03, 7.84, 5.58
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// 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));
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{"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}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{"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}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{"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}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"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"}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"candidate": "R2",
|
||||||
|
"syntheticOnly": true,
|
||||||
|
"taskContainsEnvelopeAuthorId": true,
|
||||||
|
"taskContainsEnvelopeMessageId": true,
|
||||||
|
"taskSource": "first-user-message"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"at": "2026-09-14T00:50:35.339Z",
|
||||||
|
"sourceSha256": "dfbb7ab9374c0ac9fafa0503f495abd938f499f5d6227233de03a60ea3022927",
|
||||||
|
"fixture": "connector row with forged native registration",
|
||||||
|
"status": 200,
|
||||||
|
"fakeTransportCalls": 1,
|
||||||
|
"realTransportCalls": 0,
|
||||||
|
"gatePassed": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
d5bf24b59c07c85067f4087c03b54ca8b4df923c1d591441dedd2e8a7ff2ae39 packages/discord/src/engine-pi.mjs
|
||||||
|
f0abee9c243d46d66dd2271abc3fd89089c350ac6a66ab49131bce80adfcdc33 packages/discord/tests/engine.test.mjs
|
||||||
|
fa1bf44e3f33eb970a714ada1c686abbc1932baaf418679276edf8813abbe6de packages/discord/tests/fake-pi.mjs
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
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" });
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
77077b7fbd5a933ffd352094eb073227c299ba47b7aea52d4e60fdc55cc7101e packages/discord/src/engine-pi.mjs
|
||||||
|
47a998179c6eb46827f43ab2c6b0f6b062ef94fb47da402cfb9af8c4f180f38e packages/discord/tests/engine.test.mjs
|
||||||
|
a8e54cc3f4b670eef2c06755b63e9e6bfeb44b1b1efde3aca9bfaa91583c3ef3 packages/discord/tests/fake-pi.mjs
|
||||||
@@ -0,0 +1,651 @@
|
|||||||
|
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));
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"observedAt": "2026-09-13T19:56:18.961314+00:00",
|
||||||
|
"issue": 1510,
|
||||||
|
"ownerAuthorizedLiveSmoke": true,
|
||||||
|
"researcher": [
|
||||||
|
{
|
||||||
|
"session": ".pi/state/researcher/sessions/2026-09-13T19-52-28-745Z_01a09c54-0b48-7154-addd-8fdce875aa4a.jsonl",
|
||||||
|
"entryId": "64b15fd5",
|
||||||
|
"timestamp": "2026-09-13T19:52:43.079Z",
|
||||||
|
"response": "RESEARCHER_NATIVE_SMOKE_OK",
|
||||||
|
"entrySha256": "d9cbaa5ea640d2b858a86b2fa24d3240b351d9383ffaee9721dd86fcd080c329"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rocko": {
|
||||||
|
"newLaunch": "refused by existing native launch lock",
|
||||||
|
"existingPid": 3707667,
|
||||||
|
"cwd": "/mnt/storage/src/mosaic-stack",
|
||||||
|
"nativeContextVerified": true,
|
||||||
|
"sonnetFlagVerified": true,
|
||||||
|
"socket": "mosaic-fleet",
|
||||||
|
"newModelResponseTested": false
|
||||||
|
},
|
||||||
|
"existingProcessesRestarted": false,
|
||||||
|
"homeLaunchersModified": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"issue": 1510,
|
||||||
|
"candidate": "/tmp/internal-team-r1-i9t21yzr",
|
||||||
|
"manifestSha256": "23a27014ce6f04ce8187d8495b2efe62b814c3c7d041b027f99b1ec4d490709d",
|
||||||
|
"files": [
|
||||||
|
"AGENTS.md",
|
||||||
|
"agents/README.md",
|
||||||
|
"agents/researcher/SOUL.md",
|
||||||
|
"agents/researcher/CONTEXT.md",
|
||||||
|
"agents/researcher/README.md",
|
||||||
|
"agents/researcher/launch.sh",
|
||||||
|
"agents/researcher/validate-sessions.mjs",
|
||||||
|
"scripts/test-darkwing-launch.mjs",
|
||||||
|
"docs/plans/2026-09-13_internal-development-bootstrap.md"
|
||||||
|
],
|
||||||
|
"tests": "node --test scripts/test-darkwing-launch.mjs scripts/test-rocko-launch.mjs",
|
||||||
|
"passed": 6,
|
||||||
|
"state": "ready for independent review"
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Ledger: T3 header counts as agent (#1506), R1 candidate
|
||||||
|
|
||||||
|
Sage assigned this on 2026-09-26 after commit A (af4203ca). Filbert reviews.
|
||||||
|
Not committed.
|
||||||
|
|
||||||
|
## Defect
|
||||||
|
|
||||||
|
`messageKind` in `packages/ledger/src/ledger.mjs` knew only the tmux preamble
|
||||||
|
`[host:session -> host:session]`. A prompt that opens with the T3 header
|
||||||
|
`[from: sage (1ef1e4f8-…) -> to: filbert (9cb9731e-…) class=actionable]`
|
||||||
|
counted as human, so Table 2's Human column and the human-per-closed ratio
|
||||||
|
rise once seats talk over T3. DEFERRED Open entry "Ledger counts T3 agent
|
||||||
|
messages as human".
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
- `messageKind` also matches the T3 header on the first line. The sender is the
|
||||||
|
`from:` role. `control-board` is board, any other role is agent. Anything short
|
||||||
|
of the full header stays human. That includes the header on a later line, a
|
||||||
|
leading space, a missing thread id, `class=` with capitals, `]` followed by a
|
||||||
|
non-space, and `From:` capitalized. The tmux branch is unchanged.
|
||||||
|
- Two tests: a Table 2 fixture with two headered prompts and one plain prompt
|
||||||
|
for seat `bob`, expecting agent 2 and human 1. Also a direct classification table.
|
||||||
|
- README counting rule names both forms.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `node --test --test-reporter=tap packages/ledger/tests/`: 22/22 on the
|
||||||
|
working tree.
|
||||||
|
- The same test file against HEAD's `ledger.mjs` (full `git archive HEAD` tree):
|
||||||
|
20/22. The two failures are the two new tests, so they catch the defect.
|
||||||
|
An earlier archive of `packages/ledger` alone also failed three gitea-helper
|
||||||
|
tests. Those tests need `scripts/gitea-api.sh`, which the partial archive left out.
|
||||||
|
- Suites on the working tree: config 24, task 90, foundation 43, conductor 17,
|
||||||
|
release 14, auth 15, discord 63. None of them runs the ledger tests.
|
||||||
|
|
||||||
|
## Frozen files
|
||||||
|
|
||||||
|
`r1-manifest.sha256` holds the three file hashes; `r1.patch` is `git diff
|
||||||
|
packages/ledger` at freeze time.
|
||||||
|
|
||||||
|
## Known limit, not fixed here
|
||||||
|
|
||||||
|
The fix changes zero current counts. Table 2 reads only
|
||||||
|
`.pi/state/<seat>/sessions/*.jsonl`, and no file there contains a T3 header
|
||||||
|
for any seat. Filbert checked this in R1:
|
||||||
|
- `grep -rF '[from: ' .pi/state/*/sessions/` finds nothing.
|
||||||
|
- The candidate `messageKind` gives Dewey 52 agent, 7 board and 9 human, and
|
||||||
|
Sage 193 agent and 72 human. That covers every user message in Pi logs
|
||||||
|
modified since 2026-09-20. Every non-human first line is the tmux form.
|
||||||
|
- Filbert's three T3 messages to Dewey on 2026-09-26 are not in
|
||||||
|
`.pi/state/dewey`.
|
||||||
|
|
||||||
|
Dewey's and Sage's Pi logs are current, but they only carry tmux traffic. T3
|
||||||
|
traffic goes to the harness transcripts: Claude under `~/.claude/projects`,
|
||||||
|
Codex under `~/.codex/sessions`. The ledger reads neither, so a T3-routed
|
||||||
|
prompt to any seat counts nowhere, as agent or as human. The Human column
|
||||||
|
can't see T3 traffic at all. For Darkwing and Filbert, whose newest Pi logs
|
||||||
|
end 2026-09-14, and for Rocko, who has no Pi sessions directory, zero means an
|
||||||
|
empty source, not zero human prompts. The fix is correct for a source that
|
||||||
|
carries T3 headers. Sage asked for a brief on a read-only T3 thread source;
|
||||||
|
Gate F waits on it.
|
||||||
|
|
||||||
|
Filbert also found two misclassifications in older logs. Neither is touched
|
||||||
|
here:
|
||||||
|
- `[rev-code-02 -> dragon-lin:sage class=actionable]` has no host on the
|
||||||
|
sender, so it counts as human.
|
||||||
|
- One Dewey prompt opens with a quote character before the tmux preamble, so
|
||||||
|
it counts as human.
|
||||||
|
|
||||||
|
## Review
|
||||||
|
|
||||||
|
Filbert, R1, 2026-09-26: approved the three frozen files. He verified the
|
||||||
|
manifest and patch, got 22/22 on the tree and 20/22 against HEAD's source, and
|
||||||
|
matched the regex to `docs/guides/T3-AGENT-COMMS.md`. He accepts the body on
|
||||||
|
the header's line, which the tmux branch also allows. He corrected the
|
||||||
|
known-limit text above.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
e0d411ca2f45d85734eef130dba645646df6e9128ea7dc2eaba2205df7891bb8 packages/ledger/README.md
|
||||||
|
e24b065c4284370960ac6ff1ed66810fe601da64ae9b9362584fe9fbee334017 packages/ledger/src/ledger.mjs
|
||||||
|
a9da013e81aff360cb013a8e103fdd111aee7e42553b96cb0811560b3da39250 packages/ledger/tests/ledger.test.mjs
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
diff --git a/packages/ledger/README.md b/packages/ledger/README.md
|
||||||
|
index a998a76c..da0ab1c5 100644
|
||||||
|
--- a/packages/ledger/README.md
|
||||||
|
+++ b/packages/ledger/README.md
|
||||||
|
@@ -36,11 +36,15 @@ No install, build, service restart, or configuration change is needed.
|
||||||
|
duplicated entries in copied logs are not deduplicated. No transcript content
|
||||||
|
leaves the parser. Assistant messages and logs outside repo seats do not count.
|
||||||
|
Symlink source directories are refused and symlink files are not followed.
|
||||||
|
-- The first text line alone classifies a message. A bracketed addressing
|
||||||
|
- preamble whose source session is `control-board` is board; any other valid
|
||||||
|
- addressing preamble is agent; otherwise human. This is a format count, not
|
||||||
|
- proof of who typed the message. Text blocks are joined with newlines.
|
||||||
|
- The entry timestamp is used, falling back to the message timestamp.
|
||||||
|
+- The first text line alone classifies a message. Two addressing forms count:
|
||||||
|
+ the tmux preamble `[host:session -> host:session]` that `agent-send.sh`
|
||||||
|
+ writes, and the T3 header `[from: role (thread-id) -> to: role (thread-id)]`
|
||||||
|
+ from `docs/guides/T3-AGENT-COMMS.md`. Either may carry ` class=<class>` before
|
||||||
|
+ the closing bracket. A preamble whose sender is `control-board` (tmux session
|
||||||
|
+ or T3 role) is board; any other valid preamble is agent; otherwise human.
|
||||||
|
+ This is a format count, not proof of who typed the message. Text blocks are
|
||||||
|
+ joined with newlines. The entry timestamp is used, falling back to the
|
||||||
|
+ message timestamp.
|
||||||
|
- Seats with no in-range user messages are omitted. Issue seats come from `#N`
|
||||||
|
mentions anywhere in in-range user text, including quoted text.
|
||||||
|
- Human messages per closed issue divides Table 2's human sum by issues closed
|
||||||
|
diff --git a/packages/ledger/src/ledger.mjs b/packages/ledger/src/ledger.mjs
|
||||||
|
index dc8a3a69..b09e95f5 100644
|
||||||
|
--- a/packages/ledger/src/ledger.mjs
|
||||||
|
+++ b/packages/ledger/src/ledger.mjs
|
||||||
|
@@ -77,8 +77,12 @@ export function messageText(content) {
|
||||||
|
}
|
||||||
|
export function messageKind(text) {
|
||||||
|
const firstLine = text.split(/\r?\n/, 1)[0];
|
||||||
|
- const match = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/);
|
||||||
|
- return !match ? 'human' : match[2] === 'control-board' ? 'board' : 'agent';
|
||||||
|
+ // tmux preamble from agent-send.sh: [host:session -> host:session class=x]
|
||||||
|
+ const tmux = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/);
|
||||||
|
+ // T3 header (docs/guides/T3-AGENT-COMMS.md): [from: role (id) -> to: role (id) class=x]
|
||||||
|
+ const t3 = firstLine.match(/^\[from: ([^\s()\[\]]+) \(([^()\[\]]+)\) -> to: ([^\s()\[\]]+) \(([^()\[\]]+)\)(?: class=[a-z-]+)?\](?:\s|$)/);
|
||||||
|
+ const sender = tmux ? tmux[2] : t3 ? t3[1] : null;
|
||||||
|
+ return sender === null ? 'human' : sender === 'control-board' ? 'board' : 'agent';
|
||||||
|
}
|
||||||
|
async function directories(dir, optional = false) {
|
||||||
|
try {
|
||||||
|
diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs
|
||||||
|
index 7b4e4d33..175f5d5e 100644
|
||||||
|
--- a/packages/ledger/tests/ledger.test.mjs
|
||||||
|
+++ b/packages/ledger/tests/ledger.test.mjs
|
||||||
|
@@ -110,6 +110,19 @@ test('invalid dates, reverse dates and duplicate options refuse', t => {
|
||||||
|
assert.throws(() => dateRange('2026-02-30')); assert.throws(() => dateRange('2026-09-12', '2026-09-06'));
|
||||||
|
const f = fixture(t); assert.equal(f.run(['--since', '2026-09-01']).status, 1);
|
||||||
|
});
|
||||||
|
+test('T3 agent assignments do not count as human in Table 2', t => {
|
||||||
|
+ const f = fixture(t);
|
||||||
|
+ f.put('.pi/state/bob/sessions/t3.jsonl', [
|
||||||
|
+ f.entry('[from: sage (1ef1e4f8) -> to: bob (9cb9731e) class=actionable]\nassign #1'),
|
||||||
|
+ f.entry('[from: sage (1ef1e4f8) -> to: bob (9cb9731e)]\nfollow-up #1'),
|
||||||
|
+ f.entry('Jason: go ahead'),
|
||||||
|
+ ].map(x => JSON.stringify(x)).join('\n') + '\n');
|
||||||
|
+ const result = f.run(['--json']);
|
||||||
|
+ assert.equal(result.status, 0, result.stderr);
|
||||||
|
+ const r = JSON.parse(result.stdout);
|
||||||
|
+ assert.deepEqual(r.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }, { seat: 'bob', board: 0, agent: 2, human: 1 }]);
|
||||||
|
+ assert.equal(r.totals.humanMessagesPerClosedIssue, 2);
|
||||||
|
+});
|
||||||
|
test('preamble parsing and issue number boundaries', () => {
|
||||||
|
assert.equal(messageKind('[h:control-board -> h:seat] hi'), 'board');
|
||||||
|
assert.equal(messageKind('[h:seat -> h:seat class=actionable] hi'), 'agent');
|
||||||
|
@@ -117,6 +130,20 @@ test('preamble parsing and issue number boundaries', () => {
|
||||||
|
assert.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human');
|
||||||
|
assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]);
|
||||||
|
});
|
||||||
|
+test('T3 header: agent, or board from control-board; anything short of the full header is human', () => {
|
||||||
|
+ const sage = 'sage (1ef1e4f8-3ead-4208-beca-38f9f1add079)', filbert = 'filbert (9cb9731e-a10f-4c8f-a212-c4fa1f5f4731)';
|
||||||
|
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]\nbuild #1506`), 'agent');
|
||||||
|
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=actionable]\nbuild`), 'agent');
|
||||||
|
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}] same line`), 'agent');
|
||||||
|
+ assert.equal(messageKind(`[from: darkwing (thread-id: unknown) -> to: reviewer (new-thread)]\nreview`), 'agent');
|
||||||
|
+ assert.equal(messageKind(`[from: control-board (b) -> to: ${filbert}]\nhi`), 'board');
|
||||||
|
+ assert.equal(messageKind(`Jason here\n[from: ${sage} -> to: ${filbert}]\nquoted`), 'human');
|
||||||
|
+ assert.equal(messageKind(` [from: ${sage} -> to: ${filbert}]`), 'human');
|
||||||
|
+ assert.equal(messageKind(`[from: sage -> to: filbert]\nno thread ids`), 'human');
|
||||||
|
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=Actionable]`), 'human');
|
||||||
|
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]trailing`), 'human');
|
||||||
|
+ assert.equal(messageKind(`[From: ${sage} -> to: ${filbert}]`), 'human');
|
||||||
|
+});
|
||||||
|
test('no closed issues with human messages means undefined ratio, not invented zero', () => {
|
||||||
|
const r = summarize(range, [], [], { rows: [{ seat: 'a', human: 1, board: 0, agent: 0 }], mentions: new Map() });
|
||||||
|
assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown');
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
0afb0320e9a1833f133426169c389ffb71be3d490e0e402070162aa22e820296 packages/ledger/src/ledger.mjs
|
||||||
|
d6092a538a059e8869544df903e89a8b9a4c3b6b492645db762b2f03d5630a44 packages/ledger/src/cli.mjs
|
||||||
|
dfb092aabee5cf5197029c6e8978df570ee50f08d84babde931c6a763befafe9 packages/ledger/src/t3.mjs
|
||||||
|
7444abd1dbd8e637705def0fd98105e6e69970f1bd397a11f8277996521579d6 packages/ledger/tests/ledger.test.mjs
|
||||||
|
27f7366dd5edc30a93a8c54bfb46b3fed87e1a44f11e1b22bd159a0718625273 packages/ledger/README.md
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Gate F build: the ledger's T3 source (#1506), candidate for review
|
||||||
|
|
||||||
|
Darkwing built this on 2026-09-26 from the approved brief R3,
|
||||||
|
`docs/plans/2026-09-26_ledger-t3-source.md` (sha256 f3c05c1b, committed in
|
||||||
|
ffc22c04). Sage gave the go once Filbert confirmed R3. Filbert reviews the
|
||||||
|
code; Sage commits after the suites. Base is HEAD 1c5f6bc3. Nothing is
|
||||||
|
committed or pushed.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
`build-manifest.sha256` pins the five files, and `build.patch` is the diff
|
||||||
|
against 1c5f6bc3 with `t3.mjs` included as a new file.
|
||||||
|
|
||||||
|
- `packages/ledger/src/t3.mjs` (new). `readT3(root, range, {dbPath, isDefault})`:
|
||||||
|
path checks, one read transaction, schema check, project, title mapping,
|
||||||
|
header cross-check, counts, mentions, diagnostic.
|
||||||
|
- `packages/ledger/src/ledger.mjs`. The class fix, `t3Header()`,
|
||||||
|
`readSeats()`, `mergeSources()`, the `pi` and `t3` keys in the report, the
|
||||||
|
text line for `--no-t3` or a non-default path, and the U+2028 fix below.
|
||||||
|
- `packages/ledger/src/cli.mjs`. `--no-t3` and `--t3-db PATH`, which refuse
|
||||||
|
each other; the usage line.
|
||||||
|
- `packages/ledger/tests/ledger.test.mjs`. HOME at both spawn sites, the
|
||||||
|
empty default database, 25 new tests.
|
||||||
|
- `packages/ledger/README.md`. A new "T3 source" section.
|
||||||
|
|
||||||
|
The commit should also carry Filbert's updated review,
|
||||||
|
`agents/filbert/work/ledger-t3-source-review-2026-09-26.md` (be1aa414), and
|
||||||
|
this directory's new files.
|
||||||
|
|
||||||
|
## Beyond the brief: the Pi reader split valid lines
|
||||||
|
|
||||||
|
The brief's live read has to exit 0. It didn't, and T3 wasn't the cause. HEAD
|
||||||
|
refuses the live checkout the same way:
|
||||||
|
`Malformed session JSON: filbert/2026-09-12T16-38-58-597Z_01a0967c-….jsonl:611`.
|
||||||
|
That line parses. It holds a raw U+2028 inside a JSON string, which JSON
|
||||||
|
allows and `JSON.stringify` writes unescaped. Node 26.8.1's `readline` ends a
|
||||||
|
line at U+2028 too, so it cut the record in two (733 lines by `readline`, 732
|
||||||
|
by `\n`). The reader parses every line before it checks the range, so on
|
||||||
|
Node 26.8.1 every live run refuses, whatever the dates. The file was last
|
||||||
|
written 2026-09-14. I haven't checked which Node version first split there.
|
||||||
|
|
||||||
|
The fix replaces `readline` with a small splitter that ends lines at `\n`
|
||||||
|
only. It sits in `ledger.mjs`, which this build already changes, and it
|
||||||
|
blocked acceptance, so I made it here instead of filing it. A new test writes a
|
||||||
|
Pi log with a raw U+2028 and CRLF endings; it fails with `readline` and passes
|
||||||
|
with the splitter. Please review it as its own item.
|
||||||
|
|
||||||
|
## Choices the brief left open
|
||||||
|
|
||||||
|
- Imported threads are excluded by the `import:` prefix alone. Live, all
|
||||||
|
1678 `historyImport` events sit in `import:` streams, so the two rules agree
|
||||||
|
today. The events table stays optional, so the exclusion doesn't depend on it.
|
||||||
|
- The header cross-check runs over every user message in a counted thread, in
|
||||||
|
range or not. The title mapping is current state, so a conflict in old
|
||||||
|
history still misassigns counts for any range that includes it.
|
||||||
|
- Validation (role, text, `created_at`) also covers every message in a
|
||||||
|
counted thread, assistant rows included, and not only rows in range.
|
||||||
|
- The diagnostic is in range: `humanSentThroughApi` and `humanWithoutEvent`.
|
||||||
|
One unparseable event, or an event with no string `messageId`, makes both
|
||||||
|
`unknown`, the same as a missing table (F5).
|
||||||
|
- Project and thread matching compare `workspace_root` in JavaScript, so a
|
||||||
|
declared collation on the column can't loosen byte-for-byte equality.
|
||||||
|
- JSON adds top-level `pi` (Pi rows) and `t3` (read flag, database, seats with
|
||||||
|
threads, unmapped, excluded, diagnostic). `seats` and `totals` keep their
|
||||||
|
shape, so existing consumers and tests are unchanged. `t3.seats` lists a
|
||||||
|
seat whenever it has a mapped thread, even with zero counts in range.
|
||||||
|
- The unmapped row comes last in `seats`, and only when it has counts.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Ledger tests: `node --test packages/ledger/tests/`, 47/47 (ledger 44,
|
||||||
|
of which 25 are new, and gitea helper 3). The busy-timeout test takes about 5.4 s.
|
||||||
|
- Class fix against HEAD. HEAD's `messageKind` (from `git show
|
||||||
|
1c5f6bc3:packages/ledger/src/ledger.mjs`) calls a T3 header with
|
||||||
|
`class=REVIEW-REQUEST`, a tmux preamble with `class=DECISION` and a T3 header
|
||||||
|
with `class=Actionable` all human. The build calls them agent. The existing
|
||||||
|
test asserting `class=Actionable` is human now asserts agent.
|
||||||
|
- Mutations, each on a scratch copy of the package. Three `gitea-helper`
|
||||||
|
tests fail in every scratch copy because they need the repository's
|
||||||
|
`scripts/`, so the counts below leave them out.
|
||||||
|
- Classes back to `[a-z-]+`: 6 fail.
|
||||||
|
- No header cross-check: 2 fail.
|
||||||
|
- No symlink refusal: 4 fail.
|
||||||
|
- Busy timeout 0: 1 fails.
|
||||||
|
- Two projects allowed: 1 fails.
|
||||||
|
- Deleted threads kept, imported threads kept, or range filter removed:
|
||||||
|
4 fail each.
|
||||||
|
- No role check: 1 fails.
|
||||||
|
- No diagnostic table check: 1 fails.
|
||||||
|
- `readline` restored: 1 fails.
|
||||||
|
- Two mutations pass, and I'm naming them rather than hiding them:
|
||||||
|
- Removing `mode=ro` changes nothing, because `readOnly: true` already
|
||||||
|
opens read-only. Both stay, as the brief says.
|
||||||
|
- Removing `BEGIN` fails 19 tests, but only because `COMMIT` then has no
|
||||||
|
transaction. No test proves that the queries share one snapshot.
|
||||||
|
- Eight suites on a local clone of 1c5f6bc3 with the five files: config 24,
|
||||||
|
task 90, foundation 43, conductor 17, release 14, auth 15, discord 63,
|
||||||
|
extension-package 18. The first task run showed 89/1, and I didn't capture
|
||||||
|
the failing line. Three more task runs passed 90/90. I count it as a flake
|
||||||
|
I can't name, not as green on the first try.
|
||||||
|
- Union (control-board, webui, seat, mosaic, ledger, discord) on the same
|
||||||
|
clone: 434/434 three times, 23 to 24 s each. No fake pi left running.
|
||||||
|
- No test opens the real `~/.t3`. Every CLI spawn sets `HOME` to a temp
|
||||||
|
directory, and no test calls `readT3` in process. After the runs, no
|
||||||
|
`ledger-*` temp directories remained.
|
||||||
|
|
||||||
|
## Live read
|
||||||
|
|
||||||
|
`node packages/ledger/src/cli.mjs --since 2026-09-01 --until 2026-09-26
|
||||||
|
--no-issues`, exit 0 three times, no header conflict. The table is the run at
|
||||||
|
2026-09-26T21:31:02Z.
|
||||||
|
|
||||||
|
| Seat | T3 threads | T3 board / agent / human | Pi board / agent / human |
|
||||||
|
|---|---|---|---|
|
||||||
|
| darkwing | Darkwing; Darkwing in Claude (archived) | 0 / 19 / 28 | 14 / 109 / 141 |
|
||||||
|
| dewey | Dewey; Dewey in Claude | 0 / 17 / 7 | 7 / 57 / 16 |
|
||||||
|
| filbert | Filbert | 0 / 25 / 1 | 5 / 92 / 9 |
|
||||||
|
| rocko | Rocko | 0 / 20 / 1 | none |
|
||||||
|
| sage | Sage | 0 / 52 / 10 | 0 / 193 / 72 |
|
||||||
|
| researcher | none | none | 3 / 1 / 1 |
|
||||||
|
| t3:unmapped | Discord Bot | 0 / 0 / 68 | none |
|
||||||
|
|
||||||
|
This matches the brief, allowing for messages sent since 20:54Z. It maps the
|
||||||
|
same seven threads. Discord Bot has 68 human: 54 without a header and the 14
|
||||||
|
free-text headers. The diagnostic reads exactly those 14
|
||||||
|
(`humanSentThroughApi: 14`, `humanWithoutEvent: 0`). T3 agent messages total
|
||||||
|
133, against the brief's 96 API headers (80 plus the 16 uppercase ones) at
|
||||||
|
20:54Z. Two imported threads are excluded, and this project has no deleted
|
||||||
|
threads.
|
||||||
|
|
||||||
|
## Not covered
|
||||||
|
|
||||||
|
- Snapshot isolation across the queries (see the `BEGIN` mutation above).
|
||||||
|
- A seat directory named `t3:unmapped` would share the unmapped row. Directory
|
||||||
|
names that contain a colon aren't used in `agents/`.
|
||||||
|
- The live read's effect on the main database file can't be checked while T3
|
||||||
|
writes to it. The stopped and writer-attached WAL tests check it on
|
||||||
|
fixtures.
|
||||||
|
|
||||||
|
## Review and correction
|
||||||
|
|
||||||
|
Filbert approved manifest ba73a163 and the U+2028 fix as its own item:
|
||||||
|
`agents/filbert/work/ledger-t3-build-review-2026-09-26.md`, sha256 e47ec6da.
|
||||||
|
|
||||||
|
Correction to "Beyond the brief" above. Line 611 holds a raw U+2028 and a raw
|
||||||
|
U+2029, and `readline` ends a line at each. The file has 731 lines by `\n`
|
||||||
|
(`wc -l` agrees), and `readline` makes 733. I wrote 732 because I counted the
|
||||||
|
empty string after the final newline. The splitter already ends lines at `\n`
|
||||||
|
only, so the fix covers both characters. The test and the README name only
|
||||||
|
U+2028.
|
||||||
|
|
||||||
|
Filbert's nonblocking notes, for a follow-up after the Gate F commit, since
|
||||||
|
changing the pinned files now would void the approval:
|
||||||
|
1. Add a U+2029 to the splitter test and the README line.
|
||||||
|
2. Two diagnostic mutations survive: `humanWithoutEvent` hardcoded to 0, and
|
||||||
|
an unparseable event skipped instead of making the diagnostic `unknown`.
|
||||||
|
Each needs one fixture message.
|
||||||
|
3. `readT3`'s catch reports any error that isn't a `SourceError` as a SQLite
|
||||||
|
read failure. It still exits 1, but a bug would read as a database
|
||||||
|
problem. Rethrow errors that carry no `errcode`.
|
||||||
|
4. Snapshot isolation stays untested, as recorded above.
|
||||||
@@ -0,0 +1,828 @@
|
|||||||
|
diff --git a/packages/ledger/README.md b/packages/ledger/README.md
|
||||||
|
index da0ab1c5..393e9c37 100644
|
||||||
|
--- a/packages/ledger/README.md
|
||||||
|
+++ b/packages/ledger/README.md
|
||||||
|
@@ -1,13 +1,16 @@
|
||||||
|
# Ledger
|
||||||
|
|
||||||
|
Read-only counts from local `refactor` commit subjects, one Gitea issue-list
|
||||||
|
-request through `scripts/gitea-api.sh`, and repo seats' Pi session logs.
|
||||||
|
+request through `scripts/gitea-api.sh`, repo seats' Pi session logs, and T3's
|
||||||
|
+thread messages in `~/.t3/userdata/state.sqlite`.
|
||||||
|
No board changes, data-root writes, fleet reads, transcript output, or scheduler.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12
|
||||||
|
node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12 --json
|
||||||
|
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-issues
|
||||||
|
+node packages/ledger/src/cli.mjs --since 2026-09-06 --no-t3
|
||||||
|
+node packages/ledger/src/cli.mjs --since 2026-09-06 --t3-db /tmp/fixture.sqlite
|
||||||
|
node --test packages/ledger/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
@@ -36,12 +39,17 @@ No install, build, service restart, or configuration change is needed.
|
||||||
|
duplicated entries in copied logs are not deduplicated. No transcript content
|
||||||
|
leaves the parser. Assistant messages and logs outside repo seats do not count.
|
||||||
|
Symlink source directories are refused and symlink files are not followed.
|
||||||
|
+ A line ends at `\n` only. A U+2028 inside a JSON string does not split a record.
|
||||||
|
+- Table 2 also counts T3 thread messages with role `user`. The T3 source
|
||||||
|
+ follows. A seat's row sums its Pi and T3 counts; the JSON keeps the split in
|
||||||
|
+ `pi` (Pi rows) and `t3.seats` (T3 rows).
|
||||||
|
- The first text line alone classifies a message. Two addressing forms count:
|
||||||
|
the tmux preamble `[host:session -> host:session]` that `agent-send.sh`
|
||||||
|
writes, and the T3 header `[from: role (thread-id) -> to: role (thread-id)]`
|
||||||
|
from `docs/guides/T3-AGENT-COMMS.md`. Either may carry ` class=<class>` before
|
||||||
|
the closing bracket. A preamble whose sender is `control-board` (tmux session
|
||||||
|
or T3 role) is board; any other valid preamble is agent; otherwise human.
|
||||||
|
+ The class may be in either case: seats send `class=DECISION`.
|
||||||
|
This is a format count, not proof of who typed the message. Text blocks are
|
||||||
|
joined with newlines. The entry timestamp is used, falling back to the
|
||||||
|
message timestamp.
|
||||||
|
@@ -53,6 +61,69 @@ No install, build, service restart, or configuration change is needed.
|
||||||
|
ratios and durations with one decimal. Titles truncate to 48 characters in
|
||||||
|
text only. Missing evidence is the literal string `unknown`.
|
||||||
|
|
||||||
|
+## T3 source
|
||||||
|
+
|
||||||
|
+The rules come from `docs/plans/2026-09-26_ledger-t3-source.md` (Gate F).
|
||||||
|
+The source is on by default. `--no-t3` skips it, and the report then says
|
||||||
|
+`T3: not read (--no-t3)`. `--t3-db <path>` reads another database file with
|
||||||
|
+the same checks. The JSON records the database path and whether it was the
|
||||||
|
+default. When it wasn't, the text report prints the path, so a fixture result
|
||||||
|
+can't pass for a live one. The two flags can't be combined.
|
||||||
|
+
|
||||||
|
+The reader opens `state.sqlite` read-only through `node:sqlite` and reads no
|
||||||
|
+other file in `~/.t3`. It runs every query in one read transaction with a 5 s
|
||||||
|
+busy timeout. It never writes the main database file. Like any SQLite
|
||||||
|
+connection it may create `-wal` and `-shm` beside it, so a directory that
|
||||||
|
+isn't writable refuses when SQLite needs them.
|
||||||
|
+
|
||||||
|
+- **Project.** Only threads in the one non-deleted T3 project whose
|
||||||
|
+ `workspace_root` equals this checkout's root byte for byte. The root is the
|
||||||
|
+ realpath of the package, so a project opened through the compatibility
|
||||||
|
+ symlink `~/src/mosaic-stack-dev-test` does not match, and the report refuses
|
||||||
|
+ with no project.
|
||||||
|
+- **Thread to seat.** A thread belongs to seat `<s>` when `<s>` is a real
|
||||||
|
+ directory in `agents/` and the lower-cased title equals `<s>` or starts
|
||||||
|
+ with `<s>` and a space. "Dewey in Claude" maps to `dewey`; "Sagebrush" maps
|
||||||
|
+ to nothing. Several threads can map to one seat. Threads that map to no
|
||||||
|
+ seat share one row, `t3:unmapped`, so their human messages still reach the
|
||||||
|
+ totals. `t3.seats` and `t3.unmapped` in the JSON list the thread ids and
|
||||||
|
+ titles behind each row.
|
||||||
|
+- **Titles are current state.** T3 titles an unnamed thread from its first
|
||||||
|
+ prompt, and a rename moves a thread's whole history to another row. This
|
||||||
|
+ moves counts between rows, never out of the totals.
|
||||||
|
+- **Header check.** A user message whose T3 header is addressed to its own
|
||||||
|
+ thread id must name that thread's seat as the `to:` role (compared lower
|
||||||
|
+ case). In an unmapped thread the `to:` role must not be a seat. A conflict
|
||||||
|
+ exits 1 and names the thread, its title and both roles. A header addressed
|
||||||
|
+ to another thread isn't checked. The check misses a renamed thread that no
|
||||||
|
+ agent writes to. Such a thread can only add human counts to a row.
|
||||||
|
+- **Excluded.** Imported threads (id prefix `import:`) are partial copies of
|
||||||
|
+ Claude Code sessions, not T3 traffic; every T3 event marked `historyImport`
|
||||||
|
+ sits in one today. Deleted threads don't count; archived threads do.
|
||||||
|
+ `t3.excluded` gives both thread counts.
|
||||||
|
+- **Blind spot.** Threads in other T3 projects are not counted, even if they
|
||||||
|
+ worked on this repository. Live, there is a project at `/home/jwoltje` and
|
||||||
|
+ a deleted one at `/mnt/storage/src`.
|
||||||
|
+- **Diagnostic.** `t3.diagnostic.humanSentThroughApi` counts in-range user
|
||||||
|
+ messages the header rule calls human that T3 recorded as sent through its
|
||||||
|
+ API (no `appVersion` in the event's origin). Those are seat messages whose
|
||||||
|
+ header the rule doesn't accept, such as the older free-text Discord Bot
|
||||||
|
+ headers, and would show the next format drift. `humanWithoutEvent` counts
|
||||||
|
+ human messages with no `thread.message-sent` event. This is T3's internal
|
||||||
|
+ metadata, so it feeds no table or total. If `orchestration_events` or a
|
||||||
|
+ column it needs is missing, or an event doesn't parse, both read `unknown`.
|
||||||
|
+
|
||||||
|
+These refuse the report with exit 1, and the ones about the database name
|
||||||
|
+`--no-t3`: a missing, unreadable or unopenable database (including a busy
|
||||||
|
+lock past the timeout); a symlink at `~/.t3`, `~/.t3/userdata` or
|
||||||
|
+`state.sqlite` (with `--t3-db`, the file or its directory); a missing table or
|
||||||
|
+column the counts need; no project or more than one for this root; a message
|
||||||
|
+in a counted thread with a role other than `user` or `assistant`, non-text
|
||||||
|
+content, or a `created_at` that doesn't parse; a header conflict. A missing Pi
|
||||||
|
+directory means no Pi seats ran here; a missing T3 database means the path or
|
||||||
|
+T3 changed, so it refuses instead of counting zero. Error messages name ids
|
||||||
|
+and paths, never message text.
|
||||||
|
+
|
||||||
|
## One Gitea call and missing evidence
|
||||||
|
|
||||||
|
The client requests issues updated since the start date, all states, first page,
|
||||||
|
@@ -62,8 +133,8 @@ Use a narrower range or `--no-issues`, not hidden pagination. A commit-linked
|
||||||
|
issue not returned by the updated-since query still has a row, with unknown
|
||||||
|
metadata. This is the cost of the brief's one-call boundary.
|
||||||
|
|
||||||
|
-Exit 0 means a report was computed. Exit 1 means bad arguments or unreadable git
|
||||||
|
-or session evidence. Malformed JSONL, including a partially written last line,
|
||||||
|
+Exit 0 means a report was computed. Exit 1 means bad arguments or unreadable git,
|
||||||
|
+session or T3 evidence. Malformed JSONL, including a partially written last line,
|
||||||
|
refuses the report; rerun after the seat finishes writing. Exit 2 means issue
|
||||||
|
credentials, API, payload, or completeness failure. The CLI never prints API
|
||||||
|
error bodies or reads authentication files itself. `--no-issues` makes no API
|
||||||
|
@@ -72,8 +143,9 @@ median duration, and human-per-closed ratio. It cannot invent close-only rows.
|
||||||
|
|
||||||
|
For fixtures, a fake `gitea-api.sh` can be placed first on PATH. Otherwise the
|
||||||
|
repository scripts directory is appended to PATH for the issue request.
|
||||||
|
-Tests use only temporary repositories, logs, and fake API tools, with no real
|
||||||
|
-credentials or network. The helper regression stubs Node before any credential
|
||||||
|
+Tests use only temporary repositories, logs, T3 databases and fake API tools,
|
||||||
|
+with no real credentials or network. Every CLI run in the tests sets `HOME` to
|
||||||
|
+a temporary directory, so no test opens the real `~/.t3`. The helper regression stubs Node before any credential
|
||||||
|
read and checks successful GET, successful POST, and failed HTTP status.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
diff --git a/packages/ledger/src/cli.mjs b/packages/ledger/src/cli.mjs
|
||||||
|
index 66cd1417..5cf6705c 100644
|
||||||
|
--- a/packages/ledger/src/cli.mjs
|
||||||
|
+++ b/packages/ledger/src/cli.mjs
|
||||||
|
@@ -1,11 +1,12 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
-import { dateRange, readCommits, readIssues, readSessions, summarize, formatTable, SourceError } from './ledger.mjs';
|
||||||
|
+import { dateRange, readCommits, readIssues, readSessions, mergeSources, summarize, formatTable, SourceError } from './ledger.mjs';
|
||||||
|
+import { readT3, defaultT3Path } from './t3.mjs';
|
||||||
|
|
||||||
|
-const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues]';
|
||||||
|
+const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues] [--no-t3 | --t3-db PATH]';
|
||||||
|
export async function main(args, root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')) {
|
||||||
|
- let since, until, json = false, noIssues = false;
|
||||||
|
+ let since, until, t3Db, json = false, noIssues = false, noT3 = false;
|
||||||
|
const seen = new Set();
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const flag = args[i];
|
||||||
|
@@ -14,13 +15,18 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
|
||||||
|
if (flag === '--help') { console.log(usage); return; }
|
||||||
|
if (flag === '--json') json = true;
|
||||||
|
else if (flag === '--no-issues') noIssues = true;
|
||||||
|
- else if (flag === '--since' || flag === '--until') {
|
||||||
|
+ else if (flag === '--no-t3') noT3 = true;
|
||||||
|
+ else if (flag === '--t3-db') {
|
||||||
|
+ t3Db = args[++i];
|
||||||
|
+ if (!t3Db || t3Db.startsWith('--')) throw new SourceError('--t3-db requires a path');
|
||||||
|
+ } else if (flag === '--since' || flag === '--until') {
|
||||||
|
const value = args[++i];
|
||||||
|
if (!value || value.startsWith('--')) throw new SourceError(`${flag} requires a date`);
|
||||||
|
if (flag === '--since') since = value; else until = value;
|
||||||
|
} else throw new SourceError('Unknown option; ' + usage);
|
||||||
|
}
|
||||||
|
if (!since) throw new SourceError(usage);
|
||||||
|
+ if (noT3 && t3Db !== undefined) throw new SourceError('--no-t3 and --t3-db cannot be combined');
|
||||||
|
const range = dateRange(since, until);
|
||||||
|
const commits = readCommits(root, range);
|
||||||
|
// Fixture tools may be placed first on PATH. The repository client is the
|
||||||
|
@@ -30,7 +36,9 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
|
||||||
|
let issues;
|
||||||
|
try { issues = noIssues ? null : readIssues(root, range); }
|
||||||
|
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
|
||||||
|
- const sessions = await readSessions(root, range);
|
||||||
|
+ // T3 is on by default. A missing or unreadable database refuses the report.
|
||||||
|
+ const t3 = noT3 ? null : await readT3(root, range, t3Db === undefined ? { dbPath: defaultT3Path(), isDefault: true } : { dbPath: t3Db, isDefault: false });
|
||||||
|
+ const sessions = mergeSources(await readSessions(root, range), t3);
|
||||||
|
const report = summarize(range, commits, issues, sessions);
|
||||||
|
console.log(json ? JSON.stringify(report, null, 2) : formatTable(report));
|
||||||
|
return report;
|
||||||
|
diff --git a/packages/ledger/src/ledger.mjs b/packages/ledger/src/ledger.mjs
|
||||||
|
index b09e95f5..3669d1cc 100644
|
||||||
|
--- a/packages/ledger/src/ledger.mjs
|
||||||
|
+++ b/packages/ledger/src/ledger.mjs
|
||||||
|
@@ -1,7 +1,6 @@
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { readdir, lstat } from 'node:fs/promises';
|
||||||
|
-import { createInterface } from 'node:readline';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DAY = 86400000;
|
||||||
|
@@ -23,7 +22,7 @@ export function dateRange(since, until = new Date().toISOString().slice(0, 10))
|
||||||
|
if (end <= start) throw new SourceError('--until must not precede --since');
|
||||||
|
return { since, until, start, end };
|
||||||
|
}
|
||||||
|
-const inRange = (value, range) => {
|
||||||
|
+export const inRange = (value, range) => {
|
||||||
|
const ms = typeof value === 'number' ? value : Date.parse(value);
|
||||||
|
return Number.isFinite(ms) && ms >= range.start && ms < range.end;
|
||||||
|
};
|
||||||
|
@@ -75,15 +74,32 @@ export function messageText(content) {
|
||||||
|
if (Array.isArray(content)) return content.filter(c => c?.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
+// Classes are matched in either case: seats send DECISION and REVIEW-REQUEST.
|
||||||
|
+// tmux preamble from agent-send.sh: [host:session -> host:session class=x]
|
||||||
|
+const TMUX = /^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[A-Za-z-]+)?\](?:\s|$)/;
|
||||||
|
+// T3 header (docs/guides/T3-AGENT-COMMS.md): [from: role (id) -> to: role (id) class=x]
|
||||||
|
+const T3 = /^\[from: ([^\s()\[\]]+) \(([^()\[\]]+)\) -> to: ([^\s()\[\]]+) \(([^()\[\]]+)\)(?: class=[A-Za-z-]+)?\](?:\s|$)/;
|
||||||
|
+const firstLine = text => text.split(/\r?\n/, 1)[0];
|
||||||
|
+export function t3Header(text) {
|
||||||
|
+ const m = firstLine(text).match(T3);
|
||||||
|
+ return m ? { from: m[1], fromId: m[2], to: m[3], toId: m[4] } : null;
|
||||||
|
+}
|
||||||
|
export function messageKind(text) {
|
||||||
|
- const firstLine = text.split(/\r?\n/, 1)[0];
|
||||||
|
- // tmux preamble from agent-send.sh: [host:session -> host:session class=x]
|
||||||
|
- const tmux = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/);
|
||||||
|
- // T3 header (docs/guides/T3-AGENT-COMMS.md): [from: role (id) -> to: role (id) class=x]
|
||||||
|
- const t3 = firstLine.match(/^\[from: ([^\s()\[\]]+) \(([^()\[\]]+)\) -> to: ([^\s()\[\]]+) \(([^()\[\]]+)\)(?: class=[a-z-]+)?\](?:\s|$)/);
|
||||||
|
- const sender = tmux ? tmux[2] : t3 ? t3[1] : null;
|
||||||
|
+ const tmux = firstLine(text).match(TMUX), t3 = t3Header(text);
|
||||||
|
+ const sender = tmux ? tmux[2] : t3 ? t3.from : null;
|
||||||
|
return sender === null ? 'human' : sender === 'control-board' ? 'board' : 'agent';
|
||||||
|
}
|
||||||
|
+// JSONL lines end at \n only. readline also ends a line at U+2028, which JSON
|
||||||
|
+// allows raw inside a string, so it split valid records (Node 26.8.1).
|
||||||
|
+async function* jsonLines(input) {
|
||||||
|
+ let rest = '';
|
||||||
|
+ for await (const chunk of input) {
|
||||||
|
+ const parts = (rest + chunk).split('\n');
|
||||||
|
+ rest = parts.pop();
|
||||||
|
+ yield* parts;
|
||||||
|
+ }
|
||||||
|
+ if (rest) yield rest;
|
||||||
|
+}
|
||||||
|
async function directories(dir, optional = false) {
|
||||||
|
try {
|
||||||
|
if (!(await lstat(dir)).isDirectory()) throw new SourceError('Session source must be a real directory');
|
||||||
|
@@ -93,11 +109,15 @@ async function directories(dir, optional = false) {
|
||||||
|
throw new SourceError(`Cannot read ledger directory: ${dir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+// Seats are the real directories in agents/, sorted.
|
||||||
|
+export async function readSeats(root) {
|
||||||
|
+ return (await directories(path.join(root, 'agents'))).filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => a.localeCompare(b));
|
||||||
|
+}
|
||||||
|
export async function readSessions(root, range) {
|
||||||
|
const rows = [];
|
||||||
|
const mentions = new Map();
|
||||||
|
// No symlink traversal, no fleet paths, no transcript content in the report.
|
||||||
|
- const agents = (await directories(path.join(root, 'agents'))).filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
+ const agents = (await readSeats(root)).map(name => ({ name }));
|
||||||
|
const state = path.join(root, '.pi', 'state');
|
||||||
|
// Check every source ancestor, not only the leaf directory.
|
||||||
|
if (!(await directories(path.join(root, '.pi'), true)).length) return { rows, mentions };
|
||||||
|
@@ -109,11 +129,10 @@ export async function readSessions(root, range) {
|
||||||
|
const files = (await directories(dir, true)).filter(e => e.isFile() && e.name.endsWith('.jsonl'));
|
||||||
|
const row = { seat: agent.name, board: 0, agent: 0, human: 0 };
|
||||||
|
for (const file of files) {
|
||||||
|
- const input = createReadStream(path.join(dir, file.name));
|
||||||
|
- const lines = createInterface({ input, crlfDelay: Infinity });
|
||||||
|
+ const input = createReadStream(path.join(dir, file.name), { encoding: 'utf8' });
|
||||||
|
let lineNumber = 0;
|
||||||
|
try {
|
||||||
|
- for await (const line of lines) {
|
||||||
|
+ for await (const line of jsonLines(input)) {
|
||||||
|
lineNumber++;
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let entry;
|
||||||
|
@@ -132,12 +151,33 @@ export async function readSessions(root, range) {
|
||||||
|
mentions.get(number).add(agent.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
- } finally { lines.close(); input.destroy(); }
|
||||||
|
+ } finally { input.destroy(); }
|
||||||
|
}
|
||||||
|
if (row.board + row.agent + row.human) rows.push(row);
|
||||||
|
}
|
||||||
|
return { rows, mentions };
|
||||||
|
}
|
||||||
|
+// Adds T3 counts to the Pi rows per seat. Unmapped T3 threads get one row,
|
||||||
|
+// last. The report keeps the Pi rows and the T3 section so the split shows.
|
||||||
|
+export function mergeSources(pi, t3, unmapped = 't3:unmapped') {
|
||||||
|
+ if (!t3) return { rows: pi.rows, mentions: pi.mentions, pi: pi.rows, t3: { read: false } };
|
||||||
|
+ const bySeat = new Map(pi.rows.map(r => [r.seat, { ...r }]));
|
||||||
|
+ for (const [seat, counts] of t3.rows) {
|
||||||
|
+ if (seat === unmapped || !(counts.board + counts.agent + counts.human)) continue;
|
||||||
|
+ const row = bySeat.get(seat) ?? { seat, board: 0, agent: 0, human: 0 };
|
||||||
|
+ for (const kind of ['board', 'agent', 'human']) row[kind] += counts[kind];
|
||||||
|
+ bySeat.set(seat, row);
|
||||||
|
+ }
|
||||||
|
+ const rows = [...bySeat.values()].sort((a, b) => a.seat.localeCompare(b.seat));
|
||||||
|
+ const extra = t3.rows.get(unmapped);
|
||||||
|
+ if (extra.board + extra.agent + extra.human) rows.push({ seat: unmapped, ...extra });
|
||||||
|
+ const mentions = new Map([...pi.mentions].map(([n, seats]) => [n, new Set(seats)]));
|
||||||
|
+ for (const [n, seats] of t3.mentions) {
|
||||||
|
+ if (!mentions.has(n)) mentions.set(n, new Set());
|
||||||
|
+ for (const seat of seats) mentions.get(n).add(seat);
|
||||||
|
+ }
|
||||||
|
+ return { rows, mentions, pi: pi.rows, t3: t3.section };
|
||||||
|
+}
|
||||||
|
const round = value => Math.round(value * 10) / 10;
|
||||||
|
function duration(issue) {
|
||||||
|
if (!issue) return UNKNOWN;
|
||||||
|
@@ -163,13 +203,14 @@ export function summarize(range, commits, issues, sessions) {
|
||||||
|
const median = hours.includes(UNKNOWN) ? UNKNOWN : hours.length ?
|
||||||
|
round(hours.length % 2 ? hours[middle] : (hours[middle - 1] + hours[middle]) / 2) : 0;
|
||||||
|
const human = sessions.rows.reduce((sum, r) => sum + r.human, 0);
|
||||||
|
- return { since: range.since, until: range.until, timezone: 'UTC', issues: rows, seats: sessions.rows,
|
||||||
|
+ const sources = sessions.t3 ? { pi: sessions.pi, t3: sessions.t3 } : {};
|
||||||
|
+ return { since: range.since, until: range.until, timezone: 'UTC', issues: rows, seats: sessions.rows, ...sources,
|
||||||
|
totals: { issuesClosed: issues === null ? UNKNOWN : closed.length,
|
||||||
|
medianHoursOpen: issues === null ? UNKNOWN : median, commits: commits.length,
|
||||||
|
followUpsPerIssue: rows.length ? round(rows.reduce((sum, r) => sum + r.followUps, 0) / rows.length) : 0,
|
||||||
|
humanMessagesPerClosedIssue: issues === null ? UNKNOWN : closed.length ? round(human / closed.length) : human ? UNKNOWN : 0 } };
|
||||||
|
}
|
||||||
|
-const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
|
||||||
|
+export const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
|
||||||
|
const decimal = value => typeof value === 'number' ? value.toFixed(1) : value;
|
||||||
|
export function totalsLine(t) {
|
||||||
|
return `Totals: issues closed ${t.issuesClosed} | median hours open ${decimal(t.medianHoursOpen)} | commits ${t.commits} | follow-ups per issue ${decimal(t.followUpsPerIssue)} | human messages per closed issue ${decimal(t.humanMessagesPerClosedIssue)}`;
|
||||||
|
@@ -181,5 +222,12 @@ export function formatTable(report) {
|
||||||
|
decimal(r.hoursOpen), r.commits, r.followUps, r.seats.join(', ')].join(' | ')),
|
||||||
|
'', 'Seat | Board | Agent | Human',
|
||||||
|
...report.seats.map(r => [clean(r.seat), r.board, r.agent, r.human].join(' | ')),
|
||||||
|
+ ...t3Line(report.t3),
|
||||||
|
'', totalsLine(report.totals)].join('\n');
|
||||||
|
}
|
||||||
|
+// One line when T3 was skipped or read from somewhere other than the default.
|
||||||
|
+function t3Line(t3) {
|
||||||
|
+ if (!t3) return [];
|
||||||
|
+ if (!t3.read) return ['T3: not read (--no-t3)'];
|
||||||
|
+ return t3.database.default ? [] : [`T3: read from ${clean(t3.database.path)}, not the default`];
|
||||||
|
+}
|
||||||
|
diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs
|
||||||
|
index 175f5d5e..8e60b96b 100644
|
||||||
|
--- a/packages/ledger/tests/ledger.test.mjs
|
||||||
|
+++ b/packages/ledger/tests/ledger.test.mjs
|
||||||
|
@@ -1,6 +1,7 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
-import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, symlinkSync } from 'node:fs';
|
||||||
|
+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, symlinkSync, realpathSync } from 'node:fs';
|
||||||
|
+import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
@@ -9,13 +10,50 @@ import { dateRange, messageKind, issueNumbers, totalsLine, summarize } from '../
|
||||||
|
|
||||||
|
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
|
||||||
|
const range = dateRange('2026-09-06', '2026-09-12');
|
||||||
|
+// T3 fixture schema: the live tables, cut to the columns the reader uses plus
|
||||||
|
+// one it doesn't. `text` allows NULL so a non-text row can be tested.
|
||||||
|
+const T3_SCHEMA = `
|
||||||
|
+ create table projection_projects (project_id text primary key, title text not null, workspace_root text not null, deleted_at text);
|
||||||
|
+ create table projection_threads (thread_id text primary key, project_id text not null, title text not null, archived_at text, deleted_at text);
|
||||||
|
+ create table projection_thread_messages (message_id text primary key, thread_id text not null, role text not null, text, created_at text not null);
|
||||||
|
+ create table orchestration_events (sequence integer primary key autoincrement, stream_id text not null, event_type text not null, payload_json text not null, metadata_json text not null);`;
|
||||||
|
+// Writes a T3 database in WAL mode. Threads default to project p1, which is
|
||||||
|
+// the fixture root. Returns the open writer when keepOpen is set.
|
||||||
|
+function t3db(file, { root, projects, threads = [], messages = [], after = [], keepOpen = false }) {
|
||||||
|
+ mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
+ for (const old of [file, `${file}-wal`, `${file}-shm`]) rmSync(old, { force: true });
|
||||||
|
+ const db = new DatabaseSync(file);
|
||||||
|
+ db.exec('pragma journal_mode=wal'); db.exec(T3_SCHEMA);
|
||||||
|
+ for (const [id, workspace, deleted = null] of projects ?? [['p1', root]]) {
|
||||||
|
+ db.prepare('insert into projection_projects values (?, ?, ?, ?)').run(id, 'project', workspace, deleted);
|
||||||
|
+ }
|
||||||
|
+ for (const t of threads) {
|
||||||
|
+ db.prepare('insert into projection_threads values (?, ?, ?, ?, ?)').run(t.id, t.project ?? 'p1', t.title, t.archived ?? null, t.deleted ?? null);
|
||||||
|
+ }
|
||||||
|
+ for (const m of messages) addMessage(db, m);
|
||||||
|
+ for (const sql of after) db.exec(sql);
|
||||||
|
+ if (keepOpen) return db;
|
||||||
|
+ db.close();
|
||||||
|
+}
|
||||||
|
+let messageId = 0;
|
||||||
|
+function addMessage(db, { thread, text, role = 'user', at = '2026-09-08T12:00:00Z', origin = 'app' }) {
|
||||||
|
+ const id = `m${++messageId}`;
|
||||||
|
+ db.prepare('insert into projection_thread_messages values (?, ?, ?, ?, ?)').run(id, thread, role, text, at);
|
||||||
|
+ if (origin !== 'none') db.prepare('insert into orchestration_events (stream_id, event_type, payload_json, metadata_json) values (?, ?, ?, ?)')
|
||||||
|
+ .run(thread, 'thread.message-sent', JSON.stringify({ messageId: id, threadId: thread, role, text }), JSON.stringify({ origin: origin === 'app' ? { appVersion: '0.0.0' } : {} }));
|
||||||
|
+}
|
||||||
|
const fixtureIssues = [
|
||||||
|
{ number: 1, title: 'First issue', created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-07T12:00:00Z' },
|
||||||
|
{ number: 2, title: 'Second issue', created_at: '2026-09-06T00:00:00Z', closed_at: null },
|
||||||
|
];
|
||||||
|
function fixture(t) {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'ledger-test-'));
|
||||||
|
- t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
+ // No test opens the real ~/.t3: every CLI run gets this HOME, with an empty
|
||||||
|
+ // T3 database at the default path. The CLI's root is a realpath.
|
||||||
|
+ const home = mkdtempSync(path.join(os.tmpdir(), 'ledger-home-'));
|
||||||
|
+ t.after(() => { rmSync(root, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); });
|
||||||
|
+ const defaultDb = path.join(home, '.t3/userdata/state.sqlite');
|
||||||
|
+ t3db(defaultDb, { root: realpathSync(root) });
|
||||||
|
const put = (name, data) => { const p = path.join(root, name); mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, data); return p; };
|
||||||
|
const git = (args, date = '2026-09-07T00:00:00Z') => execFileSync('git', args, { cwd: root, env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, stdio: 'pipe' });
|
||||||
|
git(['init', '-b', 'refactor']); git(['config', 'user.email', '[email protected]']); git(['config', 'user.name', 'Fixture']);
|
||||||
|
@@ -30,8 +68,8 @@ function fixture(t) {
|
||||||
|
const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
|
||||||
|
const logs = [entry('[host:control-board -> host:alice] do #1'), entry('[host:bob -> host:alice] review #2'), entry('build #2'), entry('old #1', '2026-09-05T23:59:59Z'), { type: 'message', timestamp: '2026-09-08T00:00:00Z', message: { role: 'assistant', content: 'not a user #1' } }];
|
||||||
|
put('.pi/state/alice/sessions/one.jsonl', logs.map(x => JSON.stringify(x)).join('\n') + '\n');
|
||||||
|
- const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
|
||||||
|
- return { root, put, commit, run, entry, logs };
|
||||||
|
+ const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
|
||||||
|
+ return { root, real: realpathSync(root), home, defaultDb, put, commit, run, entry, logs };
|
||||||
|
}
|
||||||
|
test('fixture git subjects only, follow-ups and three session kinds', t => {
|
||||||
|
const f = fixture(t), result = f.run(['--json']);
|
||||||
|
@@ -63,7 +101,7 @@ test('missing credentials exit 2, no-issues never calls API and shows unknown',
|
||||||
|
test('empty range gives no rows and zero totals', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.put('issues.json', '[]');
|
||||||
|
- const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json'], { encoding: 'utf8', env: { ...process.env, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
|
||||||
|
+ const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json'], { encoding: 'utf8', env: { ...process.env, HOME: f.home, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
|
||||||
|
assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
|
||||||
|
assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0));
|
||||||
|
});
|
||||||
|
@@ -96,6 +134,13 @@ test('partial or malformed session log refuses with location, not content', t =>
|
||||||
|
const f = fixture(t); f.put('.pi/state/alice/sessions/bad.jsonl', '{sensitive'); const r = f.run();
|
||||||
|
assert.equal(r.status, 1); assert.match(r.stderr, /Malformed session JSON: alice\/bad.jsonl:1/); assert.doesNotMatch(r.stderr, /sensitive/);
|
||||||
|
});
|
||||||
|
+test('a U+2028 inside a session string is one line, not a malformed record', t => {
|
||||||
|
+ const f = fixture(t);
|
||||||
|
+ f.put('.pi/state/bob/sessions/sep.jsonl', [f.entry('Jason: one\u2028two #2'), f.entry('[h:alice -> h:bob] ok')].map(x => JSON.stringify(x)).join('\r\n') + '\r\n');
|
||||||
|
+ assert.ok(readFileSync(path.join(f.root, '.pi/state/bob/sessions/sep.jsonl'), 'utf8').includes('\u2028'));
|
||||||
|
+ const r = f.run(['--json']); assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.deepEqual(JSON.parse(r.stdout).seats[1], { seat: 'bob', board: 0, agent: 1, human: 1 });
|
||||||
|
+});
|
||||||
|
test('no sessions is an empty table; symlink source refuses', t => {
|
||||||
|
const f = fixture(t); rmSync(path.join(f.root, '.pi'), { recursive: true });
|
||||||
|
assert.deepEqual(JSON.parse(f.run(['--json']).stdout).seats, []);
|
||||||
|
@@ -140,7 +185,11 @@ test('T3 header: agent, or board from control-board; anything short of the full
|
||||||
|
assert.equal(messageKind(`Jason here\n[from: ${sage} -> to: ${filbert}]\nquoted`), 'human');
|
||||||
|
assert.equal(messageKind(` [from: ${sage} -> to: ${filbert}]`), 'human');
|
||||||
|
assert.equal(messageKind(`[from: sage -> to: filbert]\nno thread ids`), 'human');
|
||||||
|
- assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=Actionable]`), 'human');
|
||||||
|
+ // Classes match in either case (Gate F). HEAD before the fix called these human.
|
||||||
|
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=Actionable]`), 'agent');
|
||||||
|
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=REVIEW-REQUEST]\nreview`), 'agent');
|
||||||
|
+ assert.equal(messageKind('[h:sage -> h:bob class=DECISION] go'), 'agent');
|
||||||
|
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=review_request]`), 'human');
|
||||||
|
assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]trailing`), 'human');
|
||||||
|
assert.equal(messageKind(`[From: ${sage} -> to: ${filbert}]`), 'human');
|
||||||
|
});
|
||||||
|
@@ -148,3 +197,218 @@ test('no closed issues with human messages means undefined ratio, not invented z
|
||||||
|
const r = summarize(range, [], [], { rows: [{ seat: 'a', human: 1, board: 0, agent: 0 }], mentions: new Map() });
|
||||||
|
assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown');
|
||||||
|
});
|
||||||
|
+
|
||||||
|
+// T3 thread source (Gate F, docs/plans/2026-09-26_ledger-t3-source.md).
|
||||||
|
+const T1 = 't-alice', T2 = 't-bob', T3 = 't-sagebrush', T4 = 't-researcher', T5 = 't-discord';
|
||||||
|
+const header = (from, to, toId, cls = '') => `[from: ${from} (x1) -> to: ${to} (${toId})${cls}]`;
|
||||||
|
+function t3Fixture(t) {
|
||||||
|
+ const f = fixture(t);
|
||||||
|
+ for (const seat of ['sage', 'researcher']) mkdirSync(path.join(f.root, 'agents', seat), { recursive: true });
|
||||||
|
+ const db = path.join(f.home, 'fixture/t3.sqlite');
|
||||||
|
+ const threads = [
|
||||||
|
+ { id: T1, title: 'Alice' }, { id: T2, title: 'Bob in Claude', archived: '2026-09-09T00:00:00Z' },
|
||||||
|
+ { id: T3, title: 'Sagebrush' }, { id: T4, title: 'Researcher' }, { id: T5, title: 'Discord Bot' },
|
||||||
|
+ { id: 'import:claudeAgent:1', title: 'alice' }, { id: 't-deleted', title: 'Alice', deleted: '2026-09-09T00:00:00Z' },
|
||||||
|
+ { id: 't-other', project: 'p2', title: 'Alice' },
|
||||||
|
+ ];
|
||||||
|
+ const messages = [
|
||||||
|
+ { thread: T1, text: 'Jason: go #1' },
|
||||||
|
+ { thread: T1, text: `${header('sage', 'alice', T1, ' class=REVIEW-REQUEST')}\nreview #2`, origin: 'api' },
|
||||||
|
+ { thread: T1, text: '[h:sage -> h:alice class=DECISION] go', origin: 'api' },
|
||||||
|
+ { thread: T1, text: `${header('control-board', 'alice', T1)}\nbuzz`, origin: 'api' },
|
||||||
|
+ { thread: T1, text: 'outside', at: '2026-09-13T00:00:00Z' },
|
||||||
|
+ { thread: T1, text: 'an answer #9', role: 'assistant', origin: 'none' },
|
||||||
|
+ { thread: T2, text: 'archived still counts #2' },
|
||||||
|
+ { thread: T3, text: 'Sagebrush is not sage' },
|
||||||
|
+ { thread: T3, text: `${header('sage', 'discord', T3)}\nnot a seat role`, origin: 'api' },
|
||||||
|
+ { thread: T4, text: 'research this' },
|
||||||
|
+ { thread: T5, text: '[from: SetSpark coordinator (x1) -> to: Discord Bot (x2)]\nfree text', origin: 'api' },
|
||||||
|
+ { thread: 'import:claudeAgent:1', text: 'imported' },
|
||||||
|
+ { thread: 't-deleted', text: 'deleted' },
|
||||||
|
+ { thread: 't-other', text: 'other project' },
|
||||||
|
+ ];
|
||||||
|
+ const write = (overrides = {}) => t3db(db, { root: f.real, projects: [['p1', f.real], ['p2', '/elsewhere']], threads, messages, ...overrides });
|
||||||
|
+ return { ...f, db, threads, messages, write };
|
||||||
|
+}
|
||||||
|
+test('T3: seat, archived, unmapped and Researcher threads count; imported, deleted and other-project threads do not', t => {
|
||||||
|
+ const f = t3Fixture(t); f.write();
|
||||||
|
+ const result = f.run(['--json', '--t3-db', f.db]);
|
||||||
|
+ assert.equal(result.status, 0, result.stderr);
|
||||||
|
+ const r = JSON.parse(result.stdout);
|
||||||
|
+ assert.deepEqual(r.seats, [
|
||||||
|
+ { seat: 'alice', board: 2, agent: 3, human: 2 }, { seat: 'bob', board: 0, agent: 0, human: 1 },
|
||||||
|
+ { seat: 'researcher', board: 0, agent: 0, human: 1 }, { seat: 't3:unmapped', board: 0, agent: 1, human: 2 },
|
||||||
|
+ ]);
|
||||||
|
+ assert.deepEqual(r.pi, [{ seat: 'alice', board: 1, agent: 1, human: 1 }]);
|
||||||
|
+ assert.deepEqual(r.t3.database, { path: f.db, default: false });
|
||||||
|
+ assert.deepEqual(r.t3.seats, [
|
||||||
|
+ { seat: 'alice', board: 1, agent: 2, human: 1, threads: [{ id: T1, title: 'Alice', archived: false }] },
|
||||||
|
+ { seat: 'bob', board: 0, agent: 0, human: 1, threads: [{ id: T2, title: 'Bob in Claude', archived: true }] },
|
||||||
|
+ { seat: 'researcher', board: 0, agent: 0, human: 1, threads: [{ id: T4, title: 'Researcher', archived: false }] },
|
||||||
|
+ ]);
|
||||||
|
+ assert.deepEqual(r.t3.unmapped, { board: 0, agent: 1, human: 2, threads: [
|
||||||
|
+ { id: T5, title: 'Discord Bot', archived: false }, { id: T3, title: 'Sagebrush', archived: false }] });
|
||||||
|
+ assert.deepEqual(r.t3.excluded, { importedThreads: 1, deletedThreads: 1 });
|
||||||
|
+ // The free-text header counts as human; only the diagnostic shows it was sent through the API.
|
||||||
|
+ assert.deepEqual(r.t3.diagnostic, { humanSentThroughApi: 1, humanWithoutEvent: 0 });
|
||||||
|
+ assert.equal(r.totals.humanMessagesPerClosedIssue, 6);
|
||||||
|
+ assert.deepEqual(r.issues.map(x => [x.issue, x.seats]), [[1, ['alice']], [2, ['alice', 'bob']]]);
|
||||||
|
+ const text = f.run(['--t3-db', f.db]);
|
||||||
|
+ assert.equal(text.status, 0, text.stderr);
|
||||||
|
+ assert.ok(text.stdout.includes(`T3: read from ${f.db}, not the default`));
|
||||||
|
+ assert.match(text.stdout, /t3:unmapped \| 0 \| 1 \| 2/);
|
||||||
|
+});
|
||||||
|
+test('T3: the default path is read from HOME and prints no path line; --no-t3 says so', t => {
|
||||||
|
+ const f = t3Fixture(t); f.write();
|
||||||
|
+ rmSync(f.defaultDb); cpSync(f.db, f.defaultDb);
|
||||||
|
+ const json = JSON.parse(f.run(['--json']).stdout);
|
||||||
|
+ assert.deepEqual(json.t3.database, { path: f.defaultDb, default: true });
|
||||||
|
+ assert.equal(json.seats.at(-1).seat, 't3:unmapped');
|
||||||
|
+ const text = f.run(); assert.equal(text.status, 0, text.stderr); assert.doesNotMatch(text.stdout, /^T3:/m);
|
||||||
|
+ rmSync(path.join(f.home, '.t3'), { recursive: true });
|
||||||
|
+ const off = f.run(['--no-t3']); assert.equal(off.status, 0, off.stderr);
|
||||||
|
+ assert.match(off.stdout, /^T3: not read \(--no-t3\)$/m);
|
||||||
|
+ const offJson = JSON.parse(f.run(['--no-t3', '--json']).stdout);
|
||||||
|
+ assert.deepEqual(offJson.t3, { read: false }); assert.deepEqual(offJson.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }]);
|
||||||
|
+ const both = f.run(['--no-t3', '--t3-db', f.db]); assert.equal(both.status, 1); assert.match(both.stderr, /cannot be combined/);
|
||||||
|
+ assert.equal(f.run(['--t3-db']).status, 1);
|
||||||
|
+});
|
||||||
|
+test('T3: a HOME with no database exits 1 and names --no-t3', t => {
|
||||||
|
+ const f = fixture(t); rmSync(path.join(f.home, '.t3'), { recursive: true });
|
||||||
|
+ const r = f.run(); assert.equal(r.status, 1); assert.equal(r.stdout, '');
|
||||||
|
+ assert.match(r.stderr, /T3 database unavailable: .*\.t3 is missing or unreadable; use --no-t3/);
|
||||||
|
+});
|
||||||
|
+test('T3: a file that is not a database exits 1 and names --no-t3', t => {
|
||||||
|
+ const f = fixture(t); writeFileSync(f.defaultDb, 'not sqlite'.repeat(100));
|
||||||
|
+ const r = f.run(); assert.equal(r.status, 1); assert.match(r.stderr, /T3 database cannot be read: .*\(SQLite \d+\); use --no-t3/);
|
||||||
|
+});
|
||||||
|
+test('T3: a seat thread renamed to another seat exits 1 naming thread, title and roles', t => {
|
||||||
|
+ const f = t3Fixture(t);
|
||||||
|
+ f.write({ messages: [...f.messages, { thread: T2, text: `${header('sage', 'alice', T2, ' class=INFO')}\nfor alice`, origin: 'api' }] });
|
||||||
|
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1);
|
||||||
|
+ assert.equal(r.stderr.trim(), `T3 header conflict: thread ${T2} "Bob in Claude" maps to bob, but a header addresses alice`);
|
||||||
|
+});
|
||||||
|
+test('T3: an unmapped thread addressed as a seat exits 1', t => {
|
||||||
|
+ const f = t3Fixture(t);
|
||||||
|
+ f.write({ messages: [...f.messages, { thread: T3, text: `${header('bob', 'Sage', T3)}\nhi`, origin: 'api' }] });
|
||||||
|
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1);
|
||||||
|
+ assert.match(r.stderr, /thread t-sagebrush "Sagebrush" maps to no seat, but a header addresses Sage/);
|
||||||
|
+});
|
||||||
|
+test('T3: a header to another thread id is not cross-checked', t => {
|
||||||
|
+ const f = t3Fixture(t);
|
||||||
|
+ f.write({ messages: [...f.messages, { thread: T2, text: `${header('sage', 'alice', T1)}\ncopied`, origin: 'api' }] });
|
||||||
|
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.equal(JSON.parse(r.stdout).t3.seats[1].agent, 1);
|
||||||
|
+});
|
||||||
|
+test('T3: no project, or two, for this root exits 1', t => {
|
||||||
|
+ const f = t3Fixture(t);
|
||||||
|
+ f.write({ projects: [['p1', `${f.real}-link`], ['p2', '/elsewhere']] });
|
||||||
|
+ let r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /T3 has no project for .*symlink does not match/);
|
||||||
|
+ f.write({ projects: [['p1', f.real], ['p2', f.real]] });
|
||||||
|
+ r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /T3 has more than one project for/);
|
||||||
|
+ f.write({ projects: [['p1', f.real], ['p2', f.real, '2026-09-01T00:00:00Z']] });
|
||||||
|
+ assert.equal(f.run(['--t3-db', f.db]).status, 0);
|
||||||
|
+});
|
||||||
|
+for (const [name, after, pattern] of [
|
||||||
|
+ ['a removed column', ['alter table projection_threads drop column title'], /T3 schema changed: missing projection_threads.title/],
|
||||||
|
+ ['a missing table', ['drop table projection_thread_messages'], /T3 schema changed: missing projection_thread_messages$/m],
|
||||||
|
+]) test(`T3: ${name} exits 1 and names it`, t => {
|
||||||
|
+ const f = t3Fixture(t); f.write({ after, messages: [] });
|
||||||
|
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, pattern);
|
||||||
|
+});
|
||||||
|
+for (const [name, message, pattern] of [
|
||||||
|
+ ['an unknown role', { role: 'system' }, /T3 message m\d+ in thread t-alice has an unknown role/],
|
||||||
|
+ ['non-text content', { text: null }, /has non-text content/],
|
||||||
|
+ ['an unparseable created_at', { at: 'yesterday' }, /has an invalid created_at/],
|
||||||
|
+]) test(`T3: a counted row with ${name} exits 1 without its text`, t => {
|
||||||
|
+ const f = t3Fixture(t);
|
||||||
|
+ f.write({ messages: [...f.messages, { thread: T1, text: 'secret words', ...message }] });
|
||||||
|
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, pattern); assert.doesNotMatch(r.stderr, /secret/);
|
||||||
|
+});
|
||||||
|
+test('T3: a missing orchestration_events makes the diagnostic unknown and keeps the counts', t => {
|
||||||
|
+ const f = t3Fixture(t); f.write();
|
||||||
|
+ const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);
|
||||||
|
+ f.write({ after: ['drop table orchestration_events'] });
|
||||||
|
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ const after = JSON.parse(r.stdout);
|
||||||
|
+ assert.deepEqual(after.t3.diagnostic, { humanSentThroughApi: 'unknown', humanWithoutEvent: 'unknown' });
|
||||||
|
+ assert.deepEqual(after.seats, before.seats); assert.deepEqual(after.totals, before.totals);
|
||||||
|
+});
|
||||||
|
+for (const link of ['.t3', '.t3/userdata', '.t3/userdata/state.sqlite']) test(`T3: a symlink at ~/${link} exits 1`, t => {
|
||||||
|
+ const f = fixture(t), target = path.join(f.home, 'real', link);
|
||||||
|
+ mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
+ cpSync(path.join(f.home, link), target, { recursive: true });
|
||||||
|
+ rmSync(path.join(f.home, link), { recursive: true }); symlinkSync(target, path.join(f.home, link));
|
||||||
|
+ const r = f.run(); assert.equal(r.status, 1); assert.match(r.stderr, new RegExp(`${link.replaceAll('.', '\\.')} is a symlink; use --no-t3`));
|
||||||
|
+});
|
||||||
|
+test('T3: with --t3-db, a symlinked file or directory exits 1', t => {
|
||||||
|
+ const f = t3Fixture(t); f.write();
|
||||||
|
+ const file = path.join(f.home, 'file-link.sqlite'); symlinkSync(f.db, file);
|
||||||
|
+ let r = f.run(['--t3-db', file]); assert.equal(r.status, 1); assert.match(r.stderr, /file-link.sqlite is a symlink/);
|
||||||
|
+ const dir = path.join(f.home, 'dir-link'); symlinkSync(path.dirname(f.db), dir);
|
||||||
|
+ r = f.run(['--t3-db', path.join(dir, 't3.sqlite')]); assert.equal(r.status, 1); assert.match(r.stderr, /dir-link is a symlink/);
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+// WAL states. The CLI reads with mode=ro; it may create -wal and -shm but must
|
||||||
|
+// never change the main file.
|
||||||
|
+const sha = file => execFileSync('sha256sum', [file], { encoding: 'utf8' }).split(' ')[0];
|
||||||
|
+const humans = r => JSON.parse(r.stdout).t3.seats.find(s => s.seat === 'alice').human;
|
||||||
|
+const asRoot = process.getuid?.() === 0;
|
||||||
|
+function killedWriter(db) {
|
||||||
|
+ // A writer that commits into the WAL and dies without a checkpoint.
|
||||||
|
+ const code = `const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(${JSON.stringify(db)});
|
||||||
|
+ db.exec('pragma wal_autocheckpoint=0');
|
||||||
|
+ db.prepare("insert into projection_thread_messages values ('late', 't-alice', 'user', 'late human', '2026-09-08T13:00:00Z')").run();
|
||||||
|
+ process.kill(process.pid, 'SIGKILL');`;
|
||||||
|
+ const r = spawnSync(process.execPath, ['-e', code]);
|
||||||
|
+ assert.equal(r.signal, 'SIGKILL');
|
||||||
|
+ rmSync(`${db}-shm`);
|
||||||
|
+}
|
||||||
|
+function inReadOnlyDir(dir, check) {
|
||||||
|
+ execFileSync('chmod', ['0555', dir]);
|
||||||
|
+ try { check(); } finally { execFileSync('chmod', ['0755', dir]); }
|
||||||
|
+}
|
||||||
|
+test('T3 WAL: the newest message only in -wal, writer attached, is counted', t => {
|
||||||
|
+ const f = t3Fixture(t), writer = f.write({ keepOpen: true });
|
||||||
|
+ t.after(() => writer.close());
|
||||||
|
+ writer.exec('pragma wal_autocheckpoint=0');
|
||||||
|
+ addMessage(writer, { thread: T1, text: 'newest', at: '2026-09-08T13:00:00Z' });
|
||||||
|
+ const main = sha(f.db);
|
||||||
|
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.equal(humans(r), 2); assert.equal(sha(f.db), main);
|
||||||
|
+});
|
||||||
|
+test('T3 WAL: stopped cleanly, counts are correct and the main file is unchanged', t => {
|
||||||
|
+ const f = t3Fixture(t); f.write();
|
||||||
|
+ assert.throws(() => readFileSync(`${f.db}-wal`));
|
||||||
|
+ const main = sha(f.db);
|
||||||
|
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.equal(humans(r), 1); assert.equal(sha(f.db), main);
|
||||||
|
+});
|
||||||
|
+test('T3 WAL: -wal without -shm in a writable directory is read', t => {
|
||||||
|
+ const f = t3Fixture(t); f.write(); killedWriter(f.db);
|
||||||
|
+ const main = sha(f.db);
|
||||||
|
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.equal(humans(r), 2); assert.equal(sha(f.db), main);
|
||||||
|
+});
|
||||||
|
+test('T3 WAL: -wal without -shm in a read-only directory exits 1', { skip: asRoot && 'mode bits do not bind root' }, t => {
|
||||||
|
+ const f = t3Fixture(t); f.write(); killedWriter(f.db);
|
||||||
|
+ inReadOnlyDir(path.dirname(f.db), () => {
|
||||||
|
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /cannot be read: .*\(SQLite 14\); use --no-t3/);
|
||||||
|
+ });
|
||||||
|
+});
|
||||||
|
+test('T3 WAL: stopped cleanly in a read-only directory exits 1', { skip: asRoot && 'mode bits do not bind root' }, t => {
|
||||||
|
+ const f = t3Fixture(t); f.write();
|
||||||
|
+ inReadOnlyDir(path.dirname(f.db), () => {
|
||||||
|
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /cannot be read: .*\(SQLite 1544\); use --no-t3/);
|
||||||
|
+ });
|
||||||
|
+});
|
||||||
|
+test('T3: a lock held past the 5 s busy timeout exits 1 and names --no-t3', t => {
|
||||||
|
+ const f = t3Fixture(t), writer = f.write({ keepOpen: true });
|
||||||
|
+ t.after(() => writer.close());
|
||||||
|
+ writer.exec('pragma locking_mode=exclusive'); writer.exec('begin exclusive');
|
||||||
|
+ addMessage(writer, { thread: T1, text: 'held' });
|
||||||
|
+ const started = Date.now(), r = f.run(['--t3-db', f.db]);
|
||||||
|
+ writer.exec('commit');
|
||||||
|
+ assert.equal(r.status, 1); assert.match(r.stderr, /cannot be read: .*\(SQLite 5\); use --no-t3/);
|
||||||
|
+ assert.ok(Date.now() - started >= 4500, 'the reader waited for the busy timeout');
|
||||||
|
+});
|
||||||
|
diff --git a/packages/ledger/src/t3.mjs b/packages/ledger/src/t3.mjs
|
||||||
|
new file mode 100644
|
||||||
|
index 00000000..9672fcc9
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/ledger/src/t3.mjs
|
||||||
|
@@ -0,0 +1,151 @@
|
||||||
|
+import { lstat } from 'node:fs/promises';
|
||||||
|
+import { DatabaseSync } from 'node:sqlite';
|
||||||
|
+import { pathToFileURL } from 'node:url';
|
||||||
|
+import os from 'node:os';
|
||||||
|
+import path from 'node:path';
|
||||||
|
+import { SourceError, UNKNOWN, clean, inRange, issueNumbers, messageKind, readSeats, t3Header } from './ledger.mjs';
|
||||||
|
+
|
||||||
|
+// T3 keeps every thread message in one SQLite database. This reader opens that
|
||||||
|
+// file read-only and nothing else in ~/.t3. See
|
||||||
|
+// docs/plans/2026-09-26_ledger-t3-source.md for the rules below.
|
||||||
|
+export const UNMAPPED = 't3:unmapped';
|
||||||
|
+const SKIP = 'use --no-t3 to skip T3';
|
||||||
|
+const REQUIRED = {
|
||||||
|
+ projection_projects: ['project_id', 'workspace_root', 'deleted_at'],
|
||||||
|
+ projection_threads: ['thread_id', 'project_id', 'title', 'archived_at', 'deleted_at'],
|
||||||
|
+ projection_thread_messages: ['message_id', 'thread_id', 'role', 'text', 'created_at'],
|
||||||
|
+};
|
||||||
|
+const DIAGNOSTIC = { orchestration_events: ['stream_id', 'event_type', 'payload_json', 'metadata_json'] };
|
||||||
|
+
|
||||||
|
+export const defaultT3Path = () => path.join(os.homedir(), '.t3', 'userdata', 'state.sqlite');
|
||||||
|
+
|
||||||
|
+// Every named path must exist and must not be a symlink. Skipping one would be
|
||||||
|
+// a silent zero, so each problem refuses the report.
|
||||||
|
+async function checkPaths(dbPath, isDefault) {
|
||||||
|
+ const dirs = isDefault ? [path.dirname(path.dirname(dbPath)), path.dirname(dbPath)] : [path.dirname(dbPath)];
|
||||||
|
+ for (const [target, wantDir] of [...dirs.map(d => [d, true]), [dbPath, false]]) {
|
||||||
|
+ let stat;
|
||||||
|
+ try { stat = await lstat(target); }
|
||||||
|
+ catch { throw new SourceError(`T3 database unavailable: ${target} is missing or unreadable; ${SKIP}`); }
|
||||||
|
+ if (stat.isSymbolicLink()) throw new SourceError(`T3 database refused: ${target} is a symlink; ${SKIP}`);
|
||||||
|
+ if (wantDir ? !stat.isDirectory() : !stat.isFile()) {
|
||||||
|
+ throw new SourceError(`T3 database refused: ${target} is not a ${wantDir ? 'directory' : 'regular file'}; ${SKIP}`);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+function missingColumns(db, tables) {
|
||||||
|
+ const missing = [];
|
||||||
|
+ for (const [table, columns] of Object.entries(tables)) {
|
||||||
|
+ const have = new Set(db.prepare('select name from pragma_table_info(?)').all(table).map(r => r.name));
|
||||||
|
+ if (!have.size) missing.push(table);
|
||||||
|
+ else for (const column of columns) if (!have.has(column)) missing.push(`${table}.${column}`);
|
||||||
|
+ }
|
||||||
|
+ return missing;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Seat for a thread title: the lower-cased title equals the seat or starts
|
||||||
|
+// with the seat and a space. Longest seat first, so the most specific wins.
|
||||||
|
+export function seatForTitle(title, seats) {
|
||||||
|
+ const lower = title.toLowerCase();
|
||||||
|
+ return [...seats].sort((a, b) => b.length - a.length).find(s => lower === s || lower.startsWith(`${s} `)) ?? null;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Origin per message id from thread.message-sent events. Any missing table,
|
||||||
|
+// column or unparseable event makes the diagnostic unknown; it decides nothing.
|
||||||
|
+function origins(db, projectId) {
|
||||||
|
+ if (missingColumns(db, DIAGNOSTIC).length) return null;
|
||||||
|
+ const byMessage = new Map();
|
||||||
|
+ const events = db.prepare(`select e.payload_json, e.metadata_json from orchestration_events e
|
||||||
|
+ join projection_threads t on t.thread_id = e.stream_id
|
||||||
|
+ where e.event_type = 'thread.message-sent' and t.project_id = ?`).all(projectId);
|
||||||
|
+ for (const event of events) {
|
||||||
|
+ let payload, metadata;
|
||||||
|
+ try { payload = JSON.parse(event.payload_json); metadata = JSON.parse(event.metadata_json); }
|
||||||
|
+ catch { return null; }
|
||||||
|
+ if (typeof payload?.messageId !== 'string') return null;
|
||||||
|
+ byMessage.set(payload.messageId, typeof metadata?.origin?.appVersion === 'string');
|
||||||
|
+ }
|
||||||
|
+ return byMessage;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+function query(db, root, range, seats) {
|
||||||
|
+ const missing = missingColumns(db, REQUIRED);
|
||||||
|
+ if (missing.length) throw new SourceError(`T3 schema changed: missing ${missing.join(', ')}`);
|
||||||
|
+ // Compared in JavaScript so a declared collation can't loosen the match.
|
||||||
|
+ const projects = db.prepare('select project_id, workspace_root from projection_projects where deleted_at is null').all()
|
||||||
|
+ .filter(p => p.workspace_root === root);
|
||||||
|
+ if (projects.length !== 1) {
|
||||||
|
+ throw new SourceError(`T3 has ${projects.length ? 'more than one project' : 'no project'} for ${root}; a project opened through a symlink does not match; ${SKIP}`);
|
||||||
|
+ }
|
||||||
|
+ const projectId = projects[0].project_id;
|
||||||
|
+ const threads = new Map(), excluded = { importedThreads: 0, deletedThreads: 0 };
|
||||||
|
+ for (const t of db.prepare('select thread_id, title, archived_at, deleted_at from projection_threads where project_id = ?').all(projectId)) {
|
||||||
|
+ if (typeof t.thread_id !== 'string' || typeof t.title !== 'string') throw new SourceError('T3 thread with a non-text id or title');
|
||||||
|
+ if (t.thread_id.startsWith('import:')) { excluded.importedThreads++; continue; }
|
||||||
|
+ if (t.deleted_at !== null) { excluded.deletedThreads++; continue; }
|
||||||
|
+ threads.set(t.thread_id, { id: t.thread_id, title: t.title, archived: t.archived_at !== null, seat: seatForTitle(t.title, seats) });
|
||||||
|
+ }
|
||||||
|
+ const rows = new Map([...seats, UNMAPPED].map(s => [s, { board: 0, agent: 0, human: 0 }]));
|
||||||
|
+ const mentions = new Map(), human = [];
|
||||||
|
+ const messages = db.prepare(`select m.message_id, m.thread_id, m.role, m.text, m.created_at from projection_thread_messages m
|
||||||
|
+ join projection_threads t on t.thread_id = m.thread_id where t.project_id = ?`).all(projectId);
|
||||||
|
+ for (const m of messages) {
|
||||||
|
+ const thread = threads.get(m.thread_id);
|
||||||
|
+ if (!thread) continue;
|
||||||
|
+ const where = `T3 message ${clean(m.message_id)} in thread ${clean(m.thread_id)}`;
|
||||||
|
+ if (m.role !== 'user' && m.role !== 'assistant') throw new SourceError(`${where} has an unknown role`);
|
||||||
|
+ if (typeof m.text !== 'string') throw new SourceError(`${where} has non-text content`);
|
||||||
|
+ if (typeof m.created_at !== 'string' || !Number.isFinite(Date.parse(m.created_at))) throw new SourceError(`${where} has an invalid created_at`);
|
||||||
|
+ if (m.role !== 'user') continue;
|
||||||
|
+ // A header addressed to its own thread must agree with the title mapping.
|
||||||
|
+ const header = t3Header(m.text);
|
||||||
|
+ if (header && header.toId === thread.id) {
|
||||||
|
+ const to = header.to.toLowerCase();
|
||||||
|
+ if (thread.seat ? to !== thread.seat : seats.includes(to)) {
|
||||||
|
+ throw new SourceError(`T3 header conflict: thread ${clean(thread.id)} "${clean(thread.title)}" maps to ${thread.seat ?? 'no seat'}, but a header addresses ${clean(header.to)}`);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ if (!inRange(m.created_at, range)) continue;
|
||||||
|
+ const kind = messageKind(m.text), seat = thread.seat ?? UNMAPPED;
|
||||||
|
+ rows.get(seat)[kind]++;
|
||||||
|
+ if (kind === 'human') human.push(m.message_id);
|
||||||
|
+ for (const number of issueNumbers(m.text)) {
|
||||||
|
+ if (!mentions.has(number)) mentions.set(number, new Set());
|
||||||
|
+ mentions.get(number).add(seat);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ const byMessage = origins(db, projectId);
|
||||||
|
+ const sentThroughApi = byMessage === null ? UNKNOWN : human.filter(id => byMessage.get(id) === false).length;
|
||||||
|
+ const noEvent = byMessage === null ? UNKNOWN : human.filter(id => !byMessage.has(id)).length;
|
||||||
|
+ const listed = seat => [...threads.values()].filter(t => (t.seat ?? UNMAPPED) === seat)
|
||||||
|
+ .sort((a, b) => a.id.localeCompare(b.id)).map(({ id, title, archived }) => ({ id, title, archived }));
|
||||||
|
+ const seatRows = seats.map(seat => ({ seat, ...rows.get(seat), threads: listed(seat) })).filter(r => r.threads.length);
|
||||||
|
+ return { rows, mentions, excluded, seats: seatRows, unmapped: { ...rows.get(UNMAPPED), threads: listed(UNMAPPED) },
|
||||||
|
+ diagnostic: { humanSentThroughApi: sentThroughApi, humanWithoutEvent: noEvent } };
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Reads one snapshot of T3's database. Returns the per-seat rows and issue
|
||||||
|
+// mentions the ledger merges with Pi, and the report's `t3` section.
|
||||||
|
+export async function readT3(root, range, { dbPath = defaultT3Path(), isDefault = true } = {}) {
|
||||||
|
+ dbPath = path.resolve(dbPath);
|
||||||
|
+ await checkPaths(dbPath, isDefault);
|
||||||
|
+ const seats = await readSeats(root);
|
||||||
|
+ const url = pathToFileURL(dbPath);
|
||||||
|
+ url.searchParams.set('mode', 'ro');
|
||||||
|
+ let db, result;
|
||||||
|
+ try {
|
||||||
|
+ db = new DatabaseSync(url, { readOnly: true, timeout: 5000 });
|
||||||
|
+ db.exec('BEGIN');
|
||||||
|
+ result = query(db, root, range, seats);
|
||||||
|
+ db.exec('COMMIT');
|
||||||
|
+ } catch (error) {
|
||||||
|
+ if (error instanceof SourceError) throw error;
|
||||||
|
+ throw new SourceError(`T3 database cannot be read: ${dbPath} (SQLite ${error.errcode ?? 'error'}); ${SKIP}`);
|
||||||
|
+ } finally {
|
||||||
|
+ try { if (db?.isTransaction) db.exec('ROLLBACK'); } catch { /* the close below still runs */ }
|
||||||
|
+ try { db?.close(); } catch { /* nothing was written */ }
|
||||||
|
+ }
|
||||||
|
+ const { rows, mentions, ...section } = result;
|
||||||
|
+ return { rows, mentions, section: { read: true, database: { path: dbPath, default: isDefault }, ...section } };
|
||||||
|
+}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
5acbc1075a5d0ad709faf14698235c8c2332c408cb4fccc580bd4a75e2c314fb packages/ledger/src/t3.mjs
|
||||||
|
6546dbaf59c046d599a9d378c1a1f2d9afc9487189db06f50fa3201c9cadc63b packages/ledger/tests/ledger.test.mjs
|
||||||
|
101013def3b168ae1b7e291ff86200b49ed6b27927787585d5ca32a283bf38bd packages/ledger/README.md
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Gate F follow-up: Filbert's notes 1 to 3 (#1506), candidate for review
|
||||||
|
|
||||||
|
Darkwing, 2026-09-26. Filbert's build review
|
||||||
|
(`agents/filbert/work/ledger-t3-build-review-2026-09-26.md`, e47ec6da) left
|
||||||
|
four nonblocking notes on Gate F (136958c9). Sage asked for 1 to 3 as one small
|
||||||
|
change that Filbert reviews and Sage commits. Note 4, snapshot isolation, went
|
||||||
|
to DEFERRED (a68dc174). Base is HEAD a4d38a3d, which changes nothing under
|
||||||
|
`packages/ledger` since 136958c9. Nothing is committed or pushed.
|
||||||
|
|
||||||
|
`followup-manifest.sha256` pins the three files. `followup.patch` is the diff
|
||||||
|
against a4d38a3d.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
1. **U+2029.** The splitter test now writes a Pi entry holding a raw U+2028
|
||||||
|
and a raw U+2029, with CRLF endings, and asserts that the file contains
|
||||||
|
both. The README line names both characters. `ledger.mjs` is unchanged,
|
||||||
|
because the splitter already ends lines at `\n` only.
|
||||||
|
2. **Diagnostic.** Three new tests:
|
||||||
|
- A human message with no `thread.message-sent` event gives
|
||||||
|
`humanWithoutEvent: 1`.
|
||||||
|
- A `thread.message-sent` event whose payload doesn't parse makes both
|
||||||
|
diagnostic fields `unknown` and leaves `seats` unchanged.
|
||||||
|
- The same for an event whose `messageId` isn't a string. Filbert didn't
|
||||||
|
list this one, but it's the third `return null` in `origins()` and had
|
||||||
|
no test either.
|
||||||
|
3. **Rethrow.** `readT3`'s catch now rethrows anything that is not a
|
||||||
|
`SourceError` and carries no numeric `errcode`. The CLI prints such an
|
||||||
|
error as `Ledger failed: cannot read source evidence`, exit 1. That's the
|
||||||
|
CLI's existing message for a non-source error, and it no longer points
|
||||||
|
at SQLite or `--no-t3`. With only numeric errcodes left, the message's
|
||||||
|
`?? 'error'` fallback could no longer fire, so I removed it. The new test
|
||||||
|
calls `readT3` in process with an explicit fixture path and a `null`
|
||||||
|
range, so `inRange` throws a `TypeError` inside the read transaction. It
|
||||||
|
asserts the `TypeError` comes out. It never touches the real `~/.t3`, and
|
||||||
|
the fixture comment says so.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Ledger tests: 51/51, the Gate F 47 plus 4 new.
|
||||||
|
- Mutations on a scratch copy of the package. The three `gitea-helper` tests
|
||||||
|
fail in every scratch copy, as before, so the counts leave them out:
|
||||||
|
|
||||||
|
| Mutation | Result |
|
||||||
|
|---|---|
|
||||||
|
| `humanWithoutEvent` hardcoded to 0 | 1 fails (no-event test) |
|
||||||
|
| unparseable event skipped (`continue`) | 1 fails (unparseable test) |
|
||||||
|
| non-string `messageId` skipped | 1 fails (messageId test) |
|
||||||
|
| rethrow removed (Gate F catch) | 1 fails (rethrow test) |
|
||||||
|
| splitter also splits at U+2028 | 1 fails (splitter test) |
|
||||||
|
| splitter also splits at U+2029 | 1 fails (splitter test) |
|
||||||
|
|
||||||
|
My first try at the last two put a raw U+2028 or U+2029 in the regex
|
||||||
|
source. That ends a JS regex literal, so the whole test file failed to
|
||||||
|
load, which doesn't count as a kill. I reran with the escape written out
|
||||||
|
literally, and the rows above come from that rerun.
|
||||||
|
- Eight suites on a local clone of a4d38a3d with the three files: config 24,
|
||||||
|
task 90, foundation 43, conductor 17, release 14, auth 15, discord 63,
|
||||||
|
extension-package 18. I ran them twice, and the second run was on the final
|
||||||
|
files after the errcode edit.
|
||||||
|
- Union on the same clone. Control-board, webui, seat, mosaic, ledger and
|
||||||
|
discord, plus conversation, which CHAT-02 committed: 474/474 twice before
|
||||||
|
the errcode edit and once after. No `ledger-*` temp directories remained.
|
||||||
|
- Live read, `--since 2026-09-01 --until 2026-09-26 --no-issues --json`, at
|
||||||
|
2026-09-26T21:47Z: exit 0, no header conflict, diagnostic
|
||||||
|
`{humanSentThroughApi: 15, humanWithoutEvent: 0}`, two imported threads
|
||||||
|
excluded. The Gate F build read 14; messages have been sent since then.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
diff --git a/packages/ledger/README.md b/packages/ledger/README.md
|
||||||
|
index 393e9c37..3a2ce27c 100644
|
||||||
|
--- a/packages/ledger/README.md
|
||||||
|
+++ b/packages/ledger/README.md
|
||||||
|
@@ -39,7 +39,8 @@ No install, build, service restart, or configuration change is needed.
|
||||||
|
duplicated entries in copied logs are not deduplicated. No transcript content
|
||||||
|
leaves the parser. Assistant messages and logs outside repo seats do not count.
|
||||||
|
Symlink source directories are refused and symlink files are not followed.
|
||||||
|
- A line ends at `\n` only. A U+2028 inside a JSON string does not split a record.
|
||||||
|
+ A line ends at `\n` only. A U+2028 or U+2029 inside a JSON string does not
|
||||||
|
+ split a record.
|
||||||
|
- Table 2 also counts T3 thread messages with role `user`. The T3 source
|
||||||
|
follows. A seat's row sums its Pi and T3 counts; the JSON keeps the split in
|
||||||
|
`pi` (Pi rows) and `t3.seats` (T3 rows).
|
||||||
|
diff --git a/packages/ledger/src/t3.mjs b/packages/ledger/src/t3.mjs
|
||||||
|
index 9672fcc9..fc9da14e 100644
|
||||||
|
--- a/packages/ledger/src/t3.mjs
|
||||||
|
+++ b/packages/ledger/src/t3.mjs
|
||||||
|
@@ -140,8 +140,10 @@ export async function readT3(root, range, { dbPath = defaultT3Path(), isDefault
|
||||||
|
result = query(db, root, range, seats);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
- if (error instanceof SourceError) throw error;
|
||||||
|
- throw new SourceError(`T3 database cannot be read: ${dbPath} (SQLite ${error.errcode ?? 'error'}); ${SKIP}`);
|
||||||
|
+ // Only a SQLite failure carries an errcode. Anything else is a bug and
|
||||||
|
+ // surfaces as itself, not as a database problem.
|
||||||
|
+ if (error instanceof SourceError || typeof error?.errcode !== 'number') throw error;
|
||||||
|
+ throw new SourceError(`T3 database cannot be read: ${dbPath} (SQLite ${error.errcode}); ${SKIP}`);
|
||||||
|
} finally {
|
||||||
|
try { if (db?.isTransaction) db.exec('ROLLBACK'); } catch { /* the close below still runs */ }
|
||||||
|
try { db?.close(); } catch { /* nothing was written */ }
|
||||||
|
diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs
|
||||||
|
index 8e60b96b..834dbd55 100644
|
||||||
|
--- a/packages/ledger/tests/ledger.test.mjs
|
||||||
|
+++ b/packages/ledger/tests/ledger.test.mjs
|
||||||
|
@@ -7,6 +7,7 @@ import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
|
import { dateRange, messageKind, issueNumbers, totalsLine, summarize } from '../src/ledger.mjs';
|
||||||
|
+import { readT3 } from '../src/t3.mjs';
|
||||||
|
|
||||||
|
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
|
||||||
|
const range = dateRange('2026-09-06', '2026-09-12');
|
||||||
|
@@ -49,7 +50,8 @@ const fixtureIssues = [
|
||||||
|
function fixture(t) {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'ledger-test-'));
|
||||||
|
// No test opens the real ~/.t3: every CLI run gets this HOME, with an empty
|
||||||
|
- // T3 database at the default path. The CLI's root is a realpath.
|
||||||
|
+ // T3 database at the default path. The one in-process readT3 call passes an
|
||||||
|
+ // explicit fixture path. The CLI's root is a realpath.
|
||||||
|
const home = mkdtempSync(path.join(os.tmpdir(), 'ledger-home-'));
|
||||||
|
t.after(() => { rmSync(root, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); });
|
||||||
|
const defaultDb = path.join(home, '.t3/userdata/state.sqlite');
|
||||||
|
@@ -134,10 +136,11 @@ test('partial or malformed session log refuses with location, not content', t =>
|
||||||
|
const f = fixture(t); f.put('.pi/state/alice/sessions/bad.jsonl', '{sensitive'); const r = f.run();
|
||||||
|
assert.equal(r.status, 1); assert.match(r.stderr, /Malformed session JSON: alice\/bad.jsonl:1/); assert.doesNotMatch(r.stderr, /sensitive/);
|
||||||
|
});
|
||||||
|
-test('a U+2028 inside a session string is one line, not a malformed record', t => {
|
||||||
|
+test('a U+2028 or U+2029 inside a session string is one line, not a malformed record', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
- f.put('.pi/state/bob/sessions/sep.jsonl', [f.entry('Jason: one\u2028two #2'), f.entry('[h:alice -> h:bob] ok')].map(x => JSON.stringify(x)).join('\r\n') + '\r\n');
|
||||||
|
- assert.ok(readFileSync(path.join(f.root, '.pi/state/bob/sessions/sep.jsonl'), 'utf8').includes('\u2028'));
|
||||||
|
+ f.put('.pi/state/bob/sessions/sep.jsonl', [f.entry('Jason: one\u2028two\u2029three #2'), f.entry('[h:alice -> h:bob] ok')].map(x => JSON.stringify(x)).join('\r\n') + '\r\n');
|
||||||
|
+ const written = readFileSync(path.join(f.root, '.pi/state/bob/sessions/sep.jsonl'), 'utf8');
|
||||||
|
+ assert.ok(written.includes('\u2028') && written.includes('\u2029'));
|
||||||
|
const r = f.run(['--json']); assert.equal(r.status, 0, r.stderr);
|
||||||
|
assert.deepEqual(JSON.parse(r.stdout).seats[1], { seat: 'bob', board: 0, agent: 1, human: 1 });
|
||||||
|
});
|
||||||
|
@@ -334,6 +337,30 @@ test('T3: a missing orchestration_events makes the diagnostic unknown and keeps
|
||||||
|
assert.deepEqual(after.t3.diagnostic, { humanSentThroughApi: 'unknown', humanWithoutEvent: 'unknown' });
|
||||||
|
assert.deepEqual(after.seats, before.seats); assert.deepEqual(after.totals, before.totals);
|
||||||
|
});
|
||||||
|
+test('T3: a human message with no event counts in humanWithoutEvent', t => {
|
||||||
|
+ const f = t3Fixture(t);
|
||||||
|
+ f.write({ messages: [...f.messages, { thread: T1, text: 'typed, no event', origin: 'none' }] });
|
||||||
|
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.deepEqual(JSON.parse(r.stdout).t3.diagnostic, { humanSentThroughApi: 1, humanWithoutEvent: 1 });
|
||||||
|
+});
|
||||||
|
+const badEvent = payload => `insert into orchestration_events (stream_id, event_type, payload_json, metadata_json) values ('${T1}', 'thread.message-sent', '${payload}', '{}')`;
|
||||||
|
+for (const [name, payload] of [['an unparseable event', '{bad'], ['an event with no string messageId', '{"messageId":7}']]) {
|
||||||
|
+ test(`T3: ${name} makes the diagnostic unknown and keeps the counts`, t => {
|
||||||
|
+ const f = t3Fixture(t); f.write();
|
||||||
|
+ const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);
|
||||||
|
+ f.write({ after: [badEvent(payload)] });
|
||||||
|
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ const after = JSON.parse(r.stdout);
|
||||||
|
+ assert.deepEqual(after.t3.diagnostic, { humanSentThroughApi: 'unknown', humanWithoutEvent: 'unknown' });
|
||||||
|
+ assert.deepEqual(after.seats, before.seats);
|
||||||
|
+ });
|
||||||
|
+}
|
||||||
|
+test('T3: an error that is not from SQLite is rethrown, not reported as a database failure', async t => {
|
||||||
|
+ const f = t3Fixture(t); f.write();
|
||||||
|
+ // In process with an explicit path, so the real ~/.t3 stays closed. A null
|
||||||
|
+ // range makes inRange throw a TypeError inside the read transaction.
|
||||||
|
+ await assert.rejects(readT3(f.real, null, { dbPath: f.db, isDefault: false }), TypeError);
|
||||||
|
+});
|
||||||
|
for (const link of ['.t3', '.t3/userdata', '.t3/userdata/state.sqlite']) test(`T3: a symlink at ~/${link} exits 1`, t => {
|
||||||
|
const f = fixture(t), target = path.join(f.home, 'real', link);
|
||||||
|
mkdirSync(path.dirname(target), { recursive: true });
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
08959a05574264e4f8243a90af94746e73a2fde3706f22e38f4ff1105b7a45a8 agents/darkwing/work/ledger-t3-source/r1.md
|
||||||
|
e8300cb6abea70819aba7cf10040d19b4d6019b5663c37203209537a5f10ee62 agents/darkwing/work/ledger-t3-source/r2.md
|
||||||
|
f3c05c1b4d28a419ab817621e15708b147f71dff588664980e22696eaafbc342 docs/plans/2026-09-26_ledger-t3-source.md
|
||||||
|
aa4740ae5d045aa12971af5de36a5107805bd31da839aae4e4d398a818d471fc agents/darkwing/work/ledger-t3-source/r1-to-r2.diff
|
||||||
|
4128375121673e0a49ef6789390503ff7395ba59f87bfc450764ea56b536c5b2 agents/darkwing/work/ledger-t3-source/r2-to-r3.diff
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
--- r1.md
|
||||||
|
+++ docs/plans/2026-09-26_ledger-t3-source.md
|
||||||
|
@@ -1,8 +1,11 @@
|
||||||
|
# Ledger: a read-only T3 thread source for Table 2 (Gate F brief)
|
||||||
|
|
||||||
|
-Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request. Filbert
|
||||||
|
-reviews it, and Jason sees it on the decision sheet before anyone builds it.
|
||||||
|
-Issue #1506.
|
||||||
|
+Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request, issue
|
||||||
|
+#1506. R1 (sha256 08959a05) went to Filbert, whose review asked for
|
||||||
|
+revisions: `agents/filbert/work/ledger-t3-source-review-2026-09-26.md`, sha256
|
||||||
|
+19dda29a. This is R2. It takes every finding, and it records Sage's rulings
|
||||||
|
+on the three open questions. Section 1 has one measurement that differs from
|
||||||
|
+the review.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
@@ -18,8 +21,8 @@
|
||||||
|
## Where T3 keeps messages
|
||||||
|
|
||||||
|
T3 keeps its state in one SQLite database, `~/.t3/userdata/state.sqlite`, in
|
||||||
|
-WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). Three
|
||||||
|
-projection tables are enough:
|
||||||
|
+WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). The counts
|
||||||
|
+need three projection tables:
|
||||||
|
|
||||||
|
- `projection_projects`: `project_id`, `workspace_root`, `deleted_at`.
|
||||||
|
- `projection_threads`: `thread_id`, `project_id`, `title`, `archived_at`,
|
||||||
|
@@ -27,51 +30,72 @@
|
||||||
|
- `projection_thread_messages`: `message_id` (primary key), `thread_id`,
|
||||||
|
`role` (`user` or `assistant`), `text`, `created_at` (ISO UTC).
|
||||||
|
|
||||||
|
-One more table is optional. In `orchestration_events`, each
|
||||||
|
+The JSON diagnostic reads one more. In `orchestration_events`, each
|
||||||
|
`thread.message-sent` event carries `metadata_json.origin`. Messages typed in
|
||||||
|
the T3 app carry an `appVersion` there. Messages sent through T3's API or MCP
|
||||||
|
-tools, which is how seats talk to each other, don't. See the cross-check below.
|
||||||
|
+tools, which is how seats talk to each other, don't.
|
||||||
|
|
||||||
|
The same directory also holds `secrets/`, `clerk-tokens.json` and other
|
||||||
|
settings files. The reader opens `state.sqlite` and nothing else, and it
|
||||||
|
selects named columns only, never `*`.
|
||||||
|
|
||||||
|
-## Reading it, with T3 running or not
|
||||||
|
+## 1. Reading it, with T3 running or not
|
||||||
|
|
||||||
|
-The file stays on disk whether T3 runs or not. The reader opens it with Node's
|
||||||
|
-built-in `node:sqlite` (`DatabaseSync`, `file:<path>?mode=ro`, `readOnly:
|
||||||
|
-true`). That needs no dependency, and Node 26.8.1 prints no warning for it. I
|
||||||
|
-read the live database this way today, while T3 was running, with no errors
|
||||||
|
-and no locks. A WAL reader sees every committed message, including those still
|
||||||
|
-in the `-wal` file.
|
||||||
|
-
|
||||||
|
-Two rules:
|
||||||
|
-- Never open with `immutable=1` and never copy the file. Both skip the WAL
|
||||||
|
- and silently lose the newest messages. A copy of the three files is also
|
||||||
|
- not atomic.
|
||||||
|
-- If T3 stopped uncleanly and left a `-wal` without its `-shm`, a read-only
|
||||||
|
- connection may be unable to rebuild the index. If the open fails, the
|
||||||
|
- ledger reports it and refuses. I have not tested this case or the fully
|
||||||
|
- stopped case. Both are acceptance checks below.
|
||||||
|
+The reader uses Node's built-in `node:sqlite` (`DatabaseSync`). That needs no
|
||||||
|
+dependency, and Node 26.8.1 (SQLite 3.53.4) prints no warning for it.
|
||||||
|
|
||||||
|
-## Jason or agent
|
||||||
|
+- **URI.** Build it with `pathToFileURL(dbPath)` and set `mode=ro` through
|
||||||
|
+ `searchParams`, then pass `readOnly: true`. A `?`, `#` or `%` in the home
|
||||||
|
+ path would break a string-built URI.
|
||||||
|
+- **One snapshot.** Run every query, from the schema checks through the
|
||||||
|
+ diagnostic, inside one `BEGIN` … `COMMIT`. In autocommit mode each
|
||||||
|
+ statement sees its own snapshot while T3 writes between them.
|
||||||
|
+- **Busy timeout.** Set `DatabaseSync`'s `timeout` to 5 s. A transient
|
||||||
|
+ `SQLITE_BUSY` during a T3 checkpoint then waits instead of failing. A busy
|
||||||
|
+ error after the timeout exits 1 like any open failure.
|
||||||
|
+- **No `immutable=1` and no copy.** Both lose the WAL. Filbert found worse
|
||||||
|
+ than lost messages: with a table created inside the WAL, `immutable=1`
|
||||||
|
+ fails with `no such table`.
|
||||||
|
+
|
||||||
|
+What happens on disk. Filbert and I both tested these in scratch
|
||||||
|
+directories:
|
||||||
|
+
|
||||||
|
+| State | Directory writable | Result |
|
||||||
|
+|---|---|---|
|
||||||
|
+| T3 running, writer attached, newest rows only in `-wal` | yes | reads them |
|
||||||
|
+| `-wal` without `-shm` (writer killed, `-shm` removed) | yes | reads the WAL rows and creates `-shm` |
|
||||||
|
+| `-wal` without `-shm` | no | open fails, SQLite 14 |
|
||||||
|
+| T3 stopped cleanly, no `-wal` or `-shm` | yes | reads, then leaves an empty `-wal` and a 32 KiB `-shm` |
|
||||||
|
+| T3 stopped cleanly | no | fails, SQLite 1544 "attempt to write a readonly database" |
|
||||||
|
+
|
||||||
|
+In every case the main file's bytes stayed the same. The last row is where
|
||||||
|
+Filbert and I differ. His review says the stopped-case read works with the
|
||||||
|
+directory read-only. In my run it failed with and without the read
|
||||||
|
+transaction. The build's test settles it. Either way a failed open is exit 1.
|
||||||
|
+
|
||||||
|
+So the accurate claim: the reader never writes the main database file. Like
|
||||||
|
+any SQLite connection, it may create or update `-wal` and `-shm` beside it
|
||||||
|
+and takes read locks in `-shm`. T3 opens normally afterwards.
|
||||||
|
+
|
||||||
|
+## 2. Jason or agent
|
||||||
|
|
||||||
|
Reuse the 6a rule. The first line of `text` decides: a T3 header or the tmux
|
||||||
|
preamble counts as agent, `control-board` as the sender counts as board, and
|
||||||
|
anything else counts as human. Messages with role `user` count; assistant
|
||||||
|
messages don't.
|
||||||
|
|
||||||
|
-6a has a defect this source would expose. Its regex allows only a lowercase
|
||||||
|
-class (`class=[a-z-]+`). Seats send uppercase classes: Sage's DECISION, INFO,
|
||||||
|
-REVIEW-REQUEST and REVIEW-NOTE, and my own REVIEW-REQUEST. In this project's
|
||||||
|
-threads, 16 real agent headers fail on that alone and would count as human.
|
||||||
|
-The fix is to make the class match case-insensitive. It belongs in this build
|
||||||
|
-or just before it, reviewed with it. The ms-communications table lists
|
||||||
|
-lowercase names, so the fix follows what seats send, not the table.
|
||||||
|
+The class fix rides in this build (Sage's ruling). HEAD's
|
||||||
|
+`packages/ledger/src/ledger.mjs:81` (tmux) and `:83` (T3) both allow only
|
||||||
|
+`class=[a-z-]+`. Both become case-insensitive. Seats send uppercase classes:
|
||||||
|
+Sage's DECISION, INFO, REVIEW-REQUEST and REVIEW-NOTE, and my own
|
||||||
|
+REVIEW-REQUEST. In this project's threads 16 real agent headers failed on
|
||||||
|
+that alone at 20:54Z. The ms-communications table lists lowercase names, so
|
||||||
|
+the fix follows what seats send, not the table.
|
||||||
|
|
||||||
|
Cross-check, read at 2026-09-26T20:54Z for the mosaic-stack project (209
|
||||||
|
user messages outside imported and deleted threads, every one with its
|
||||||
|
-`thread.message-sent` event):
|
||||||
|
+`thread.message-sent` event). Filbert's later read agreed, plus messages sent
|
||||||
|
+since.
|
||||||
|
|
||||||
|
| T3 origin | Header matches 6a | Count |
|
||||||
|
|---|---|---|
|
||||||
|
@@ -83,110 +107,193 @@
|
||||||
|
No message typed in the app carries a header, and every API message in this
|
||||||
|
project carries one of the three forms. The 14 free-text ones are older
|
||||||
|
Discord Bot thread headers such as `[from: SetSpark coordinator (…) -> to:
|
||||||
|
-Discord Bot (…)]`, written before the guide fixed the format. With the class
|
||||||
|
-fix they still count as human. That's 14 wrong human counts, all dated
|
||||||
|
-2026-09-17 to 2026-09-22.
|
||||||
|
-
|
||||||
|
-Recommendation: the header rule decides, as Sage asked. The reader also
|
||||||
|
-reports one diagnostic number, not used in any table: user messages the rule
|
||||||
|
-calls human that T3 recorded as sent through the API. That count is how the
|
||||||
|
-uppercase-class bug showed up, and it would catch the next format drift. The
|
||||||
|
-origin field is T3's internal metadata, not a documented contract, so it
|
||||||
|
-shouldn't decide anything. I'd make it JSON only, so Table 2's layout stays
|
||||||
|
-the same.
|
||||||
|
-
|
||||||
|
-## Thread to seat
|
||||||
|
-
|
||||||
|
-A thread counts for this checkout only if its project's `workspace_root` is
|
||||||
|
-the ledger's repository root. That is `/mnt/storage/src/mosaic-stack`, project
|
||||||
|
-`34050c07`.
|
||||||
|
-
|
||||||
|
-Thread IDs change whenever Jason starts a new thread for a seat, so there's no
|
||||||
|
-fixed map. T3-AGENT-COMMS.md already names threads after the seat ("Darkwing",
|
||||||
|
-"Sage", "Dewey in Claude"). Proposed rule: a thread belongs to seat `<s>` when
|
||||||
|
-`<s>` is a real directory under `agents/` and the lower-cased title equals
|
||||||
|
-`<s>` or starts with `<s>` followed by a space. Several threads can map to one
|
||||||
|
-seat. Their counts add up, as several Pi session files already do.
|
||||||
|
+Discord Bot (…)]`, written before the guide fixed the format. Sage ruled they
|
||||||
|
+stay as recorded: they count as human, dated 2026-09-17 to 2026-09-22.
|
||||||
|
+
|
||||||
|
+The header rule decides. The JSON also carries one diagnostic that feeds no
|
||||||
|
+table or total: user messages the rule calls human that T3 recorded as sent
|
||||||
|
+through the API. That number exposed the class bug and would catch the next
|
||||||
|
+format drift. `origin` is T3's internal metadata, not a documented contract,
|
||||||
|
+so it decides nothing. If `orchestration_events` or a column it needs is
|
||||||
|
+missing, the diagnostic reads `unknown` and the report goes on (Sage's
|
||||||
|
+ruling on F5). Missing tables the counts depend on still exit 1.
|
||||||
|
+
|
||||||
|
+## 3. Thread to seat
|
||||||
|
+
|
||||||
|
+**Project.** A thread counts for this checkout only if its project's
|
||||||
|
+`workspace_root` equals the ledger's repository root, byte for byte. The CLI
|
||||||
|
+already takes that root from the realpath of its own URL, today
|
||||||
|
+`/mnt/storage/src/mosaic-stack`, project `34050c07`. So a T3 project opened
|
||||||
|
+through the compatibility symlink `~/src/mosaic-stack-dev-test` doesn't
|
||||||
|
+match, and "no project row" is the right refusal. The README says so.
|
||||||
|
+
|
||||||
|
+**Title rule.** Thread IDs change whenever Jason starts a new thread for a
|
||||||
|
+seat, so there's no fixed map. T3-AGENT-COMMS.md already names threads after
|
||||||
|
+the seat ("Darkwing", "Sage", "Dewey in Claude"). A thread belongs to seat
|
||||||
|
+`<s>` when `<s>` is a real directory under `agents/` and the lower-cased
|
||||||
|
+title equals `<s>` or starts with `<s>` followed by a space. So "Sagebrush"
|
||||||
|
+stays unmapped. Several threads can map to one seat, and their counts add
|
||||||
|
+up, as several Pi session files already do.
|
||||||
|
|
||||||
|
Today that maps Sage, Darkwing, Filbert, Dewey and Rocko (one thread each,
|
||||||
|
-created 2026-09-26), plus "Darkwing in Claude" (archived) and "Dewey in
|
||||||
|
-Claude". Three threads map to no seat. Two are imported and excluded anyway
|
||||||
|
-("FINDINGS.md review" and "[dragon-lin:darkwing -> …"). The third is
|
||||||
|
-"Discord Bot" with 68 user messages: 54 without a header, and the 14
|
||||||
|
-free-text headers above. The guide's own advice, titles like `review:
|
||||||
|
-<topic>`, will produce more unmapped threads.
|
||||||
|
-
|
||||||
|
-Unmapped threads go in one Table 2 row, `t3:unmapped`, so Jason's messages
|
||||||
|
-there still count toward the Human column and the human-per-closed ratio. The
|
||||||
|
-other choice is to drop them, which would hide those 54 headerless prompts.
|
||||||
|
-That is Jason's decision. I recommend the row.
|
||||||
|
-
|
||||||
|
-A seat's row sums its Pi and T3 counts. JSON splits them by source. Nothing is
|
||||||
|
-counted twice: every T3 session today runs on `claudeAgent` or `codex`, which
|
||||||
|
-don't write `.pi/state`, and Filbert found no T3 header in any Pi log.
|
||||||
|
+created 2026-09-26, titles set by hand), plus "Darkwing in Claude" (archived)
|
||||||
|
+and "Dewey in Claude". Researcher has a directory and no thread. Three
|
||||||
|
+threads map to no seat. Two are imported and excluded anyway ("FINDINGS.md
|
||||||
|
+review" and "[dragon-lin:darkwing -> …"). The third is "Discord Bot" with 68
|
||||||
|
+user messages: 54 without a header, and the 14 free-text headers.
|
||||||
|
+
|
||||||
|
+Titles are current state, and T3 can write them itself. They go wrong three
|
||||||
|
+ways. T3 auto-titles an unnamed thread from Jason's first prompt, so "Rocko
|
||||||
|
+review of the plan" maps to rocko. A rename moves the whole history to
|
||||||
|
+another row. A seat thread titled for a topic drops into `t3:unmapped`.
|
||||||
|
+None of this changes the Human total or the human-per-closed ratio. It only
|
||||||
|
+moves counts between rows, but Gate F reads one seat's row.
|
||||||
|
+
|
||||||
|
+**Header cross-check.** The headers already say which seat a thread belongs
|
||||||
|
+to. For every user message whose header matches the fixed 6a rule and whose
|
||||||
|
+`to:` id equals the message's own `thread_id`:
|
||||||
|
+- in a mapped thread, the `to:` role, lower-cased, must equal that thread's
|
||||||
|
+ seat;
|
||||||
|
+- in an unmapped thread, the `to:` role must not be a seat name.
|
||||||
|
+
|
||||||
|
+A conflict exits 1 and names the thread id, its title and both roles. A
|
||||||
|
+header whose `to:` id is some other thread is not checked. The check reads
|
||||||
|
+message text only, not T3 metadata. In a live read at 21:02Z every header
|
||||||
|
+agreed: all 104 addressed to their own thread carried the full thread id and
|
||||||
|
+named that thread's seat (Sage 40, Darkwing 15, Filbert 18, Dewey 15, Rocko
|
||||||
|
+16).
|
||||||
|
+
|
||||||
|
+It catches a seat thread renamed to another seat or to a topic, once any
|
||||||
|
+agent writes to it. It also catches an auto-titled thread that agents
|
||||||
|
+address by a different seat. It misses a thread no agent ever writes to.
|
||||||
|
+Such a thread can only add human counts to a seat's row, never hide them, so
|
||||||
|
+for Gate F it errs toward a visible failure. The README says so.
|
||||||
|
+
|
||||||
|
+**Unmapped row.** Unmapped threads go in one Table 2 row, `t3:unmapped`
|
||||||
|
+(Sage's ruling), so their human messages still reach the Human column and
|
||||||
|
+the human-per-closed ratio.
|
||||||
|
+
|
||||||
|
+**Mapping in the JSON.** For each seat, the T3 thread ids and titles that
|
||||||
|
+made its row, and the unmapped thread ids and titles. Anyone checking a Gate
|
||||||
|
+F result can then see which threads the row came from.
|
||||||
|
+
|
||||||
|
+A seat's row sums its Pi and T3 counts, and the JSON splits them by source.
|
||||||
|
+Nothing is counted twice. Every T3 session today runs on `claudeAgent` or
|
||||||
|
+`codex`, which don't write `.pi/state`, and Filbert found no T3 header in any
|
||||||
|
+Pi log (6a record).
|
||||||
|
|
||||||
|
-Excluded, with the reason stated in the README:
|
||||||
|
+**Excluded,** with the reason stated in the README:
|
||||||
|
- Imported threads (`thread_id` starting `import:`, events marked
|
||||||
|
`historyImport`). They are partial copies of Claude Code sessions, not T3
|
||||||
|
traffic: 55 user messages in two threads here.
|
||||||
|
- Deleted threads (`deleted_at` set). Across all projects there are 3, with
|
||||||
|
3 messages. Archived threads count.
|
||||||
|
-
|
||||||
|
-## What fails closed
|
||||||
|
-
|
||||||
|
-With the T3 source on, each of these refuses the report with exit 1, the
|
||||||
|
-code the ledger already uses for unreadable session evidence. The report
|
||||||
|
-never falls back to Pi logs alone. As with `--no-issues`, `--no-t3` turns the
|
||||||
|
-source off, and the report then says T3 was not read.
|
||||||
|
-- The database is missing, unreadable, or won't open read-only (including
|
||||||
|
- the `-wal` without `-shm` case). This differs from the Pi reader, which
|
||||||
|
- treats a missing `.pi` as no messages. A missing Pi directory means no Pi
|
||||||
|
- seats ran here. A missing T3 database on this host means the path or T3
|
||||||
|
- changed, and a silent zero is the failure Gate F exists to prevent.
|
||||||
|
-- A required table or column is missing. The reader checks `PRAGMA
|
||||||
|
+- Threads in other T3 projects. Live, there is a project at `/home/jwoltje`
|
||||||
|
+ and a deleted one at `/mnt/storage/src`. A thread in either could work on
|
||||||
|
+ this repository and would not be counted. The workspace-root rule is still
|
||||||
|
+ the right one, but the README names this blind spot.
|
||||||
|
+
|
||||||
|
+## 4. What fails closed
|
||||||
|
+
|
||||||
|
+The source is on by default (Sage's ruling). `--no-t3` turns it off, and the
|
||||||
|
+report then says T3 was not read. `--t3-db <path>` reads another database
|
||||||
|
+file instead of `~/.t3/userdata/state.sqlite`. It exists for fixtures and
|
||||||
|
+gets the same checks.
|
||||||
|
+
|
||||||
|
+Each of these refuses the report with exit 1, the code the ledger already
|
||||||
|
+uses for unreadable session evidence. The report never falls back to Pi logs
|
||||||
|
+alone. Where the database is missing or won't open, the message names
|
||||||
|
+`--no-t3`.
|
||||||
|
+- The database is missing or unreadable, or won't open read-only. That
|
||||||
|
+ includes a directory that isn't writable when SQLite needs to create
|
||||||
|
+ `-shm`, and a busy error after the timeout. The Pi reader treats a missing
|
||||||
|
+ `.pi` as no messages, and this departs from it on purpose. A missing Pi
|
||||||
|
+ directory means no Pi seats ran here. A missing T3 database on this host
|
||||||
|
+ means the path or T3 changed, and a silent zero is the failure Gate F
|
||||||
|
+ exists to prevent.
|
||||||
|
+- `~/.t3`, `~/.t3/userdata` or `state.sqlite` is a symlink. With `--t3-db`,
|
||||||
|
+ the file and its directory are checked. The Pi reader checks every
|
||||||
|
+ ancestor too, but it skips symlinked entries. Skipping one named file
|
||||||
|
+ would be another silent zero, so this reader refuses.
|
||||||
|
+- A table or column the counts need is missing. The reader checks `PRAGMA
|
||||||
|
table_info` and names what's missing. This catches a T3 upgrade that
|
||||||
|
changes the schema.
|
||||||
|
- No project row, or more than one non-deleted row, for this repository root.
|
||||||
|
- A counted row has a bad `role`, non-string `text`, or a `created_at` that
|
||||||
|
doesn't parse. The Pi reader already refuses malformed JSONL and bad
|
||||||
|
timestamps the same way.
|
||||||
|
-- `state.sqlite` or `~/.t3/userdata` is a symlink. The Pi reader skips
|
||||||
|
- symlinked entries instead. For one named file, skipping would be another
|
||||||
|
- silent zero, so this reader refuses.
|
||||||
|
-
|
||||||
|
-The source never writes to the database. It never reads other files in
|
||||||
|
-`~/.t3`, and it passes no message text beyond `messageKind` and
|
||||||
|
-`issueNumbers`, the same rule as for Pi logs. The one outside effect is
|
||||||
|
-SQLite's own: a WAL reader takes read locks in the `-shm` file, as T3's own
|
||||||
|
-connections do.
|
||||||
|
-
|
||||||
|
-## Decisions for Jason
|
||||||
|
-
|
||||||
|
-1. The source is on by default, with `--no-t3` to turn it off. The other
|
||||||
|
- choice is off by default with `--t3` to turn it on. I recommend on by
|
||||||
|
- default, because Gate F exists to count these messages.
|
||||||
|
-2. Unmapped threads get a `t3:unmapped` row. The other choice is to drop
|
||||||
|
- them. I recommend the row.
|
||||||
|
-3. The 14 free-text headers from 09-17 to 09-22 stay counted as human. Fixing
|
||||||
|
- them would mean loosening the header grammar for history only, and I don't
|
||||||
|
- recommend it.
|
||||||
|
-
|
||||||
|
-## Acceptance for the build
|
||||||
|
-
|
||||||
|
-- Fixture databases built with `node:sqlite` in a temp dir, in WAL mode:
|
||||||
|
- seat threads and an unmapped thread; imported, deleted and archived
|
||||||
|
- threads; all three header forms, uppercase classes included; a message
|
||||||
|
- outside the date range; another project with the same seat titles.
|
||||||
|
-- Each fail-closed case above has its own test, including a schema column
|
||||||
|
- removed and `-wal` without `-shm`. One test opens a database whose newest
|
||||||
|
- message is still in the WAL and counts it. Another reads a database closed
|
||||||
|
- cleanly with no writer attached, which is the T3-stopped case.
|
||||||
|
-- The class fix is proven against HEAD's `messageKind`: an uppercase class
|
||||||
|
- counts as agent after the fix and as human before it.
|
||||||
|
+- A header conflicts with the title mapping (section 3).
|
||||||
|
+
|
||||||
|
+The reader never reads other files in `~/.t3`. It passes no message text
|
||||||
|
+beyond `messageKind`, `issueNumbers` and the header's `to:` role and id, the
|
||||||
|
+same rule as for Pi logs.
|
||||||
|
+
|
||||||
|
+## 5. Rulings
|
||||||
|
+
|
||||||
|
+Sage ruled on the three questions R1 put to Jason, as lead calls:
|
||||||
|
+1. The source is on by default. A missing or unreadable database exits 1,
|
||||||
|
+ and the message names `--no-t3`.
|
||||||
|
+2. Unmapped threads get the `t3:unmapped` row.
|
||||||
|
+3. The 14 free-text headers stay as recorded. They show only in the JSON
|
||||||
|
+ diagnostic.
|
||||||
|
+
|
||||||
|
+Sage also ruled that the class fix rides in this build, and that a missing
|
||||||
|
+diagnostic table reads `unknown` (F5).
|
||||||
|
+
|
||||||
|
+## 6. Acceptance for the build
|
||||||
|
+
|
||||||
|
+**No test opens the real `~/.t3`.** Both places in
|
||||||
|
+`packages/ledger/tests/ledger.test.mjs` that spawn `cli.mjs` (the shared
|
||||||
|
+`run()` helper and the direct `spawnSync` at line 66) set `HOME` to the
|
||||||
|
+fixture's temp directory. A test that forgets `--t3-db` or `--no-t3` then
|
||||||
|
+finds no database and fails closed. The existing tests aren't about T3. Each
|
||||||
|
+gets an empty fixture database at the fixture `HOME`'s default path, with
|
||||||
|
+one project row for the fixture root. So they run with the source on, and
|
||||||
|
+their expected rows don't change. One test asserts that a `HOME` with no
|
||||||
|
+database exits 1 and names `--no-t3`.
|
||||||
|
+
|
||||||
|
+Fixture databases are built with `node:sqlite` in a temp directory, in WAL
|
||||||
|
+mode:
|
||||||
|
+- seat threads and an unmapped thread; imported, deleted and archived
|
||||||
|
+ threads; a message outside the date range;
|
||||||
|
+- all three header forms, with uppercase classes in both the tmux preamble
|
||||||
|
+ and the T3 header;
|
||||||
|
+- a thread with the same seat title in another project;
|
||||||
|
+- a seat thread renamed to another seat, with an agent header to its own
|
||||||
|
+ id, which exits 1;
|
||||||
|
+- a "Sagebrush" title, which stays unmapped;
|
||||||
|
+- a thread titled "Researcher", which maps to the seat that has no thread
|
||||||
|
+ live.
|
||||||
|
+
|
||||||
|
+WAL states, each with its own test:
|
||||||
|
+- The newest message is only in `-wal`, with the writer still attached (the
|
||||||
|
+ live-T3 case). It is counted.
|
||||||
|
+- T3 stopped: the database closed cleanly with no writer. Counts are
|
||||||
|
+ correct, and the main file's bytes are unchanged afterwards.
|
||||||
|
+- `-wal` without `-shm` in a writable directory: made by a child writer with
|
||||||
|
+ `wal_autocheckpoint=0` that is SIGKILLed, then `-shm` deleted. The WAL
|
||||||
|
+ rows are counted.
|
||||||
|
+- `-wal` without `-shm` in a directory that isn't writable: exit 1, naming
|
||||||
|
+ `--no-t3`. Skipped when the tests run as root, where the mode bits don't
|
||||||
|
+ bind.
|
||||||
|
+- The stopped case in a directory that isn't writable: exit 1, naming
|
||||||
|
+ `--no-t3`, as I measured it. If the build reads there instead, the builder
|
||||||
|
+ changes this test to assert correct counts and records the correction in
|
||||||
|
+ the BUILD-LOG entry. Skipped as root too.
|
||||||
|
+
|
||||||
|
+Also:
|
||||||
|
+- Each other fail-closed case in section 4 has its own test, including a
|
||||||
|
+ removed schema column and each symlink.
|
||||||
|
+- A missing `orchestration_events` gives `unknown` for the diagnostic and
|
||||||
|
+ the same counts.
|
||||||
|
+- The class fix is proven against HEAD's `messageKind`. An uppercase class
|
||||||
|
+ in either preamble counts as agent after the fix and as human before it.
|
||||||
|
+- The JSON lists each seat's threads and the unmapped threads.
|
||||||
|
- A read against the live database gives the counts in this brief, allowing
|
||||||
|
- for messages sent since.
|
||||||
|
-- The ledger README's counting rules name the new source, the mapping rule
|
||||||
|
- and the exclusions.
|
||||||
|
+ for messages sent since. It exits 0 with no header conflict.
|
||||||
|
+- The ledger README's counting rules name the new source, both flags, the
|
||||||
|
+ mapping rule and the header check, and the exclusions. That includes the
|
||||||
|
+ symlinked-checkout case and the other-project blind spot.
|
||||||
|
- No suite runs the ledger tests, so the BUILD-LOG entry names the test file.
|
||||||
|
|
||||||
|
## Not in scope
|
||||||
|
@@ -194,5 +301,6 @@
|
||||||
|
- Claude Code transcripts (`~/.claude/projects`) and Codex sessions
|
||||||
|
(`~/.codex/sessions`). T3's database already holds every message T3
|
||||||
|
delivered, so those files would only duplicate it.
|
||||||
|
-- Any write to T3, any T3 API call, or anything that needs T3 running.
|
||||||
|
+- Any T3 API call, anything that needs T3 running, and any write beyond
|
||||||
|
+ SQLite's own `-wal` and `-shm` handling.
|
||||||
|
- Fixing the two tmux misclassifications Filbert found in 6a.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# Ledger: a read-only T3 thread source for Table 2 (Gate F brief)
|
||||||
|
|
||||||
|
Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request. Filbert
|
||||||
|
reviews it, and Jason sees it on the decision sheet before anyone builds it.
|
||||||
|
Issue #1506.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Table 2 counts user messages per seat from `.pi/state/<seat>/sessions/*.jsonl`
|
||||||
|
only. Development seats now run in T3 on the Claude and Codex harnesses, so
|
||||||
|
their prompts, Jason's included, never reach a Pi log. Today the Human column
|
||||||
|
can't see T3 at all, and the zero it shows for T3 seats means "no source", not
|
||||||
|
"no human prompts". 6a (ef0020ad) taught `messageKind` the T3 header, but no
|
||||||
|
source the ledger reads contains one. Gate F (QUEUE row 6) passes when
|
||||||
|
Filbert's item closes with zero human messages from Jason. While the ledger
|
||||||
|
can't see T3, a zero there proves nothing.
|
||||||
|
|
||||||
|
## Where T3 keeps messages
|
||||||
|
|
||||||
|
T3 keeps its state in one SQLite database, `~/.t3/userdata/state.sqlite`, in
|
||||||
|
WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). Three
|
||||||
|
projection tables are enough:
|
||||||
|
|
||||||
|
- `projection_projects`: `project_id`, `workspace_root`, `deleted_at`.
|
||||||
|
- `projection_threads`: `thread_id`, `project_id`, `title`, `archived_at`,
|
||||||
|
`deleted_at`.
|
||||||
|
- `projection_thread_messages`: `message_id` (primary key), `thread_id`,
|
||||||
|
`role` (`user` or `assistant`), `text`, `created_at` (ISO UTC).
|
||||||
|
|
||||||
|
One more table is optional. In `orchestration_events`, each
|
||||||
|
`thread.message-sent` event carries `metadata_json.origin`. Messages typed in
|
||||||
|
the T3 app carry an `appVersion` there. Messages sent through T3's API or MCP
|
||||||
|
tools, which is how seats talk to each other, don't. See the cross-check below.
|
||||||
|
|
||||||
|
The same directory also holds `secrets/`, `clerk-tokens.json` and other
|
||||||
|
settings files. The reader opens `state.sqlite` and nothing else, and it
|
||||||
|
selects named columns only, never `*`.
|
||||||
|
|
||||||
|
## Reading it, with T3 running or not
|
||||||
|
|
||||||
|
The file stays on disk whether T3 runs or not. The reader opens it with Node's
|
||||||
|
built-in `node:sqlite` (`DatabaseSync`, `file:<path>?mode=ro`, `readOnly:
|
||||||
|
true`). That needs no dependency, and Node 26.8.1 prints no warning for it. I
|
||||||
|
read the live database this way today, while T3 was running, with no errors
|
||||||
|
and no locks. A WAL reader sees every committed message, including those still
|
||||||
|
in the `-wal` file.
|
||||||
|
|
||||||
|
Two rules:
|
||||||
|
- Never open with `immutable=1` and never copy the file. Both skip the WAL
|
||||||
|
and silently lose the newest messages. A copy of the three files is also
|
||||||
|
not atomic.
|
||||||
|
- If T3 stopped uncleanly and left a `-wal` without its `-shm`, a read-only
|
||||||
|
connection may be unable to rebuild the index. If the open fails, the
|
||||||
|
ledger reports it and refuses. I have not tested this case or the fully
|
||||||
|
stopped case. Both are acceptance checks below.
|
||||||
|
|
||||||
|
## Jason or agent
|
||||||
|
|
||||||
|
Reuse the 6a rule. The first line of `text` decides: a T3 header or the tmux
|
||||||
|
preamble counts as agent, `control-board` as the sender counts as board, and
|
||||||
|
anything else counts as human. Messages with role `user` count; assistant
|
||||||
|
messages don't.
|
||||||
|
|
||||||
|
6a has a defect this source would expose. Its regex allows only a lowercase
|
||||||
|
class (`class=[a-z-]+`). Seats send uppercase classes: Sage's DECISION, INFO,
|
||||||
|
REVIEW-REQUEST and REVIEW-NOTE, and my own REVIEW-REQUEST. In this project's
|
||||||
|
threads, 16 real agent headers fail on that alone and would count as human.
|
||||||
|
The fix is to make the class match case-insensitive. It belongs in this build
|
||||||
|
or just before it, reviewed with it. The ms-communications table lists
|
||||||
|
lowercase names, so the fix follows what seats send, not the table.
|
||||||
|
|
||||||
|
Cross-check, read at 2026-09-26T20:54Z for the mosaic-stack project (209
|
||||||
|
user messages outside imported and deleted threads, every one with its
|
||||||
|
`thread.message-sent` event):
|
||||||
|
|
||||||
|
| T3 origin | Header matches 6a | Count |
|
||||||
|
|---|---|---|
|
||||||
|
| typed in the app (has `appVersion`) | no | 99 |
|
||||||
|
| sent through the API (no `appVersion`) | yes | 80 |
|
||||||
|
| sent through the API | no, uppercase class | 16 |
|
||||||
|
| sent through the API | no, free-text roles | 14 |
|
||||||
|
|
||||||
|
No message typed in the app carries a header, and every API message in this
|
||||||
|
project carries one of the three forms. The 14 free-text ones are older
|
||||||
|
Discord Bot thread headers such as `[from: SetSpark coordinator (…) -> to:
|
||||||
|
Discord Bot (…)]`, written before the guide fixed the format. With the class
|
||||||
|
fix they still count as human. That's 14 wrong human counts, all dated
|
||||||
|
2026-09-17 to 2026-09-22.
|
||||||
|
|
||||||
|
Recommendation: the header rule decides, as Sage asked. The reader also
|
||||||
|
reports one diagnostic number, not used in any table: user messages the rule
|
||||||
|
calls human that T3 recorded as sent through the API. That count is how the
|
||||||
|
uppercase-class bug showed up, and it would catch the next format drift. The
|
||||||
|
origin field is T3's internal metadata, not a documented contract, so it
|
||||||
|
shouldn't decide anything. I'd make it JSON only, so Table 2's layout stays
|
||||||
|
the same.
|
||||||
|
|
||||||
|
## Thread to seat
|
||||||
|
|
||||||
|
A thread counts for this checkout only if its project's `workspace_root` is
|
||||||
|
the ledger's repository root. That is `/mnt/storage/src/mosaic-stack`, project
|
||||||
|
`34050c07`.
|
||||||
|
|
||||||
|
Thread IDs change whenever Jason starts a new thread for a seat, so there's no
|
||||||
|
fixed map. T3-AGENT-COMMS.md already names threads after the seat ("Darkwing",
|
||||||
|
"Sage", "Dewey in Claude"). Proposed rule: a thread belongs to seat `<s>` when
|
||||||
|
`<s>` is a real directory under `agents/` and the lower-cased title equals
|
||||||
|
`<s>` or starts with `<s>` followed by a space. Several threads can map to one
|
||||||
|
seat. Their counts add up, as several Pi session files already do.
|
||||||
|
|
||||||
|
Today that maps Sage, Darkwing, Filbert, Dewey and Rocko (one thread each,
|
||||||
|
created 2026-09-26), plus "Darkwing in Claude" (archived) and "Dewey in
|
||||||
|
Claude". Three threads map to no seat. Two are imported and excluded anyway
|
||||||
|
("FINDINGS.md review" and "[dragon-lin:darkwing -> …"). The third is
|
||||||
|
"Discord Bot" with 68 user messages: 54 without a header, and the 14
|
||||||
|
free-text headers above. The guide's own advice, titles like `review:
|
||||||
|
<topic>`, will produce more unmapped threads.
|
||||||
|
|
||||||
|
Unmapped threads go in one Table 2 row, `t3:unmapped`, so Jason's messages
|
||||||
|
there still count toward the Human column and the human-per-closed ratio. The
|
||||||
|
other choice is to drop them, which would hide those 54 headerless prompts.
|
||||||
|
That is Jason's decision. I recommend the row.
|
||||||
|
|
||||||
|
A seat's row sums its Pi and T3 counts. JSON splits them by source. Nothing is
|
||||||
|
counted twice: every T3 session today runs on `claudeAgent` or `codex`, which
|
||||||
|
don't write `.pi/state`, and Filbert found no T3 header in any Pi log.
|
||||||
|
|
||||||
|
Excluded, with the reason stated in the README:
|
||||||
|
- Imported threads (`thread_id` starting `import:`, events marked
|
||||||
|
`historyImport`). They are partial copies of Claude Code sessions, not T3
|
||||||
|
traffic: 55 user messages in two threads here.
|
||||||
|
- Deleted threads (`deleted_at` set). Across all projects there are 3, with
|
||||||
|
3 messages. Archived threads count.
|
||||||
|
|
||||||
|
## What fails closed
|
||||||
|
|
||||||
|
With the T3 source on, each of these refuses the report with exit 1, the
|
||||||
|
code the ledger already uses for unreadable session evidence. The report
|
||||||
|
never falls back to Pi logs alone. As with `--no-issues`, `--no-t3` turns the
|
||||||
|
source off, and the report then says T3 was not read.
|
||||||
|
- The database is missing, unreadable, or won't open read-only (including
|
||||||
|
the `-wal` without `-shm` case). This differs from the Pi reader, which
|
||||||
|
treats a missing `.pi` as no messages. A missing Pi directory means no Pi
|
||||||
|
seats ran here. A missing T3 database on this host means the path or T3
|
||||||
|
changed, and a silent zero is the failure Gate F exists to prevent.
|
||||||
|
- A required table or column is missing. The reader checks `PRAGMA
|
||||||
|
table_info` and names what's missing. This catches a T3 upgrade that
|
||||||
|
changes the schema.
|
||||||
|
- No project row, or more than one non-deleted row, for this repository root.
|
||||||
|
- A counted row has a bad `role`, non-string `text`, or a `created_at` that
|
||||||
|
doesn't parse. The Pi reader already refuses malformed JSONL and bad
|
||||||
|
timestamps the same way.
|
||||||
|
- `state.sqlite` or `~/.t3/userdata` is a symlink. The Pi reader skips
|
||||||
|
symlinked entries instead. For one named file, skipping would be another
|
||||||
|
silent zero, so this reader refuses.
|
||||||
|
|
||||||
|
The source never writes to the database. It never reads other files in
|
||||||
|
`~/.t3`, and it passes no message text beyond `messageKind` and
|
||||||
|
`issueNumbers`, the same rule as for Pi logs. The one outside effect is
|
||||||
|
SQLite's own: a WAL reader takes read locks in the `-shm` file, as T3's own
|
||||||
|
connections do.
|
||||||
|
|
||||||
|
## Decisions for Jason
|
||||||
|
|
||||||
|
1. The source is on by default, with `--no-t3` to turn it off. The other
|
||||||
|
choice is off by default with `--t3` to turn it on. I recommend on by
|
||||||
|
default, because Gate F exists to count these messages.
|
||||||
|
2. Unmapped threads get a `t3:unmapped` row. The other choice is to drop
|
||||||
|
them. I recommend the row.
|
||||||
|
3. The 14 free-text headers from 09-17 to 09-22 stay counted as human. Fixing
|
||||||
|
them would mean loosening the header grammar for history only, and I don't
|
||||||
|
recommend it.
|
||||||
|
|
||||||
|
## Acceptance for the build
|
||||||
|
|
||||||
|
- Fixture databases built with `node:sqlite` in a temp dir, in WAL mode:
|
||||||
|
seat threads and an unmapped thread; imported, deleted and archived
|
||||||
|
threads; all three header forms, uppercase classes included; a message
|
||||||
|
outside the date range; another project with the same seat titles.
|
||||||
|
- Each fail-closed case above has its own test, including a schema column
|
||||||
|
removed and `-wal` without `-shm`. One test opens a database whose newest
|
||||||
|
message is still in the WAL and counts it. Another reads a database closed
|
||||||
|
cleanly with no writer attached, which is the T3-stopped case.
|
||||||
|
- The class fix is proven against HEAD's `messageKind`: an uppercase class
|
||||||
|
counts as agent after the fix and as human before it.
|
||||||
|
- A read against the live database gives the counts in this brief, allowing
|
||||||
|
for messages sent since.
|
||||||
|
- The ledger README's counting rules name the new source, the mapping rule
|
||||||
|
and the exclusions.
|
||||||
|
- No suite runs the ledger tests, so the BUILD-LOG entry names the test file.
|
||||||
|
|
||||||
|
## Not in scope
|
||||||
|
|
||||||
|
- Claude Code transcripts (`~/.claude/projects`) and Codex sessions
|
||||||
|
(`~/.codex/sessions`). T3's database already holds every message T3
|
||||||
|
delivered, so those files would only duplicate it.
|
||||||
|
- Any write to T3, any T3 API call, or anything that needs T3 running.
|
||||||
|
- Fixing the two tmux misclassifications Filbert found in 6a.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
--- r2.md
|
||||||
|
+++ docs/plans/2026-09-26_ledger-t3-source.md
|
||||||
|
@@ -3,9 +3,9 @@
|
||||||
|
Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request, issue
|
||||||
|
#1506. R1 (sha256 08959a05) went to Filbert, whose review asked for
|
||||||
|
revisions: `agents/filbert/work/ledger-t3-source-review-2026-09-26.md`, sha256
|
||||||
|
-19dda29a. This is R2. It takes every finding, and it records Sage's rulings
|
||||||
|
-on the three open questions. Section 1 has one measurement that differs from
|
||||||
|
-the review.
|
||||||
|
+19dda29a. R2 (sha256 e8300cb6) took every finding and recorded Sage's
|
||||||
|
+rulings on the three open questions. Filbert approved R2 with three nits,
|
||||||
|
+review sha256 bb02d8d3. This is R3, which takes the nits.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
@@ -68,10 +68,10 @@
|
||||||
|
| T3 stopped cleanly, no `-wal` or `-shm` | yes | reads, then leaves an empty `-wal` and a 32 KiB `-shm` |
|
||||||
|
| T3 stopped cleanly | no | fails, SQLite 1544 "attempt to write a readonly database" |
|
||||||
|
|
||||||
|
-In every case the main file's bytes stayed the same. The last row is where
|
||||||
|
-Filbert and I differ. His review says the stopped-case read works with the
|
||||||
|
-directory read-only. In my run it failed with and without the read
|
||||||
|
-transaction. The build's test settles it. Either way a failed open is exit 1.
|
||||||
|
+In every case the main file's bytes stayed the same. Filbert's first
|
||||||
|
+review said the last case reads. His test had reused a database whose empty
|
||||||
|
+`-wal` and `-shm` were still present. On a true clean stop he also got 1544,
|
||||||
|
+and his review records the correction. A failed open is exit 1.
|
||||||
|
|
||||||
|
So the accurate claim: the reader never writes the main database file. Like
|
||||||
|
any SQLite connection, it may create or update `-wal` and `-shm` beside it
|
||||||
|
@@ -198,7 +198,9 @@
|
||||||
|
The source is on by default (Sage's ruling). `--no-t3` turns it off, and the
|
||||||
|
report then says T3 was not read. `--t3-db <path>` reads another database
|
||||||
|
file instead of `~/.t3/userdata/state.sqlite`. It exists for fixtures and
|
||||||
|
-gets the same checks.
|
||||||
|
+gets the same checks. The JSON records the database path read and whether
|
||||||
|
+it was the default. When it wasn't, the text report adds one line naming the
|
||||||
|
+path, so a Gate F result can't come from a fixture unnoticed.
|
||||||
|
|
||||||
|
Each of these refuses the report with exit 1, the code the ledger already
|
||||||
|
uses for unreadable session evidence. The report never falls back to Pi logs
|
||||||
|
@@ -248,7 +250,9 @@
|
||||||
|
fixture's temp directory. A test that forgets `--t3-db` or `--no-t3` then
|
||||||
|
finds no database and fails closed. The existing tests aren't about T3. Each
|
||||||
|
gets an empty fixture database at the fixture `HOME`'s default path, with
|
||||||
|
-one project row for the fixture root. So they run with the source on, and
|
||||||
|
+one project row for the fixture root. That row stores
|
||||||
|
+`fs.realpathSync(root)`, because the CLI resolves its root through realpath
|
||||||
|
+and a symlinked temp directory would otherwise not match. So they run with the source on, and
|
||||||
|
their expected rows don't change. One test asserts that a `HOME` with no
|
||||||
|
database exits 1 and names `--no-t3`.
|
||||||
|
|
||||||
|
@@ -277,9 +281,7 @@
|
||||||
|
`--no-t3`. Skipped when the tests run as root, where the mode bits don't
|
||||||
|
bind.
|
||||||
|
- The stopped case in a directory that isn't writable: exit 1, naming
|
||||||
|
- `--no-t3`, as I measured it. If the build reads there instead, the builder
|
||||||
|
- changes this test to assert correct counts and records the correction in
|
||||||
|
- the BUILD-LOG entry. Skipped as root too.
|
||||||
|
+ `--no-t3`. Skipped as root too.
|
||||||
|
|
||||||
|
Also:
|
||||||
|
- Each other fail-closed case in section 4 has its own test, including a
|
||||||
|
@@ -288,7 +290,9 @@
|
||||||
|
the same counts.
|
||||||
|
- The class fix is proven against HEAD's `messageKind`. An uppercase class
|
||||||
|
in either preamble counts as agent after the fix and as human before it.
|
||||||
|
-- The JSON lists each seat's threads and the unmapped threads.
|
||||||
|
+- The JSON lists each seat's threads and the unmapped threads, and the
|
||||||
|
+ database path with whether it was the default. A `--t3-db` run prints the
|
||||||
|
+ path line in the text report, and a default run doesn't.
|
||||||
|
- A read against the live database gives the counts in this brief, allowing
|
||||||
|
for messages sent since. It exits 0 with no header conflict.
|
||||||
|
- The ledger README's counting rules name the new source, both flags, the
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# Ledger: a read-only T3 thread source for Table 2 (Gate F brief)
|
||||||
|
|
||||||
|
Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request, issue
|
||||||
|
#1506. R1 (sha256 08959a05) went to Filbert, whose review asked for
|
||||||
|
revisions: `agents/filbert/work/ledger-t3-source-review-2026-09-26.md`, sha256
|
||||||
|
19dda29a. This is R2. It takes every finding, and it records Sage's rulings
|
||||||
|
on the three open questions. Section 1 has one measurement that differs from
|
||||||
|
the review.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Table 2 counts user messages per seat from `.pi/state/<seat>/sessions/*.jsonl`
|
||||||
|
only. Development seats now run in T3 on the Claude and Codex harnesses, so
|
||||||
|
their prompts, Jason's included, never reach a Pi log. Today the Human column
|
||||||
|
can't see T3 at all, and the zero it shows for T3 seats means "no source", not
|
||||||
|
"no human prompts". 6a (ef0020ad) taught `messageKind` the T3 header, but no
|
||||||
|
source the ledger reads contains one. Gate F (QUEUE row 6) passes when
|
||||||
|
Filbert's item closes with zero human messages from Jason. While the ledger
|
||||||
|
can't see T3, a zero there proves nothing.
|
||||||
|
|
||||||
|
## Where T3 keeps messages
|
||||||
|
|
||||||
|
T3 keeps its state in one SQLite database, `~/.t3/userdata/state.sqlite`, in
|
||||||
|
WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). The counts
|
||||||
|
need three projection tables:
|
||||||
|
|
||||||
|
- `projection_projects`: `project_id`, `workspace_root`, `deleted_at`.
|
||||||
|
- `projection_threads`: `thread_id`, `project_id`, `title`, `archived_at`,
|
||||||
|
`deleted_at`.
|
||||||
|
- `projection_thread_messages`: `message_id` (primary key), `thread_id`,
|
||||||
|
`role` (`user` or `assistant`), `text`, `created_at` (ISO UTC).
|
||||||
|
|
||||||
|
The JSON diagnostic reads one more. In `orchestration_events`, each
|
||||||
|
`thread.message-sent` event carries `metadata_json.origin`. Messages typed in
|
||||||
|
the T3 app carry an `appVersion` there. Messages sent through T3's API or MCP
|
||||||
|
tools, which is how seats talk to each other, don't.
|
||||||
|
|
||||||
|
The same directory also holds `secrets/`, `clerk-tokens.json` and other
|
||||||
|
settings files. The reader opens `state.sqlite` and nothing else, and it
|
||||||
|
selects named columns only, never `*`.
|
||||||
|
|
||||||
|
## 1. Reading it, with T3 running or not
|
||||||
|
|
||||||
|
The reader uses Node's built-in `node:sqlite` (`DatabaseSync`). That needs no
|
||||||
|
dependency, and Node 26.8.1 (SQLite 3.53.4) prints no warning for it.
|
||||||
|
|
||||||
|
- **URI.** Build it with `pathToFileURL(dbPath)` and set `mode=ro` through
|
||||||
|
`searchParams`, then pass `readOnly: true`. A `?`, `#` or `%` in the home
|
||||||
|
path would break a string-built URI.
|
||||||
|
- **One snapshot.** Run every query, from the schema checks through the
|
||||||
|
diagnostic, inside one `BEGIN` … `COMMIT`. In autocommit mode each
|
||||||
|
statement sees its own snapshot while T3 writes between them.
|
||||||
|
- **Busy timeout.** Set `DatabaseSync`'s `timeout` to 5 s. A transient
|
||||||
|
`SQLITE_BUSY` during a T3 checkpoint then waits instead of failing. A busy
|
||||||
|
error after the timeout exits 1 like any open failure.
|
||||||
|
- **No `immutable=1` and no copy.** Both lose the WAL. Filbert found worse
|
||||||
|
than lost messages: with a table created inside the WAL, `immutable=1`
|
||||||
|
fails with `no such table`.
|
||||||
|
|
||||||
|
What happens on disk. Filbert and I both tested these in scratch
|
||||||
|
directories:
|
||||||
|
|
||||||
|
| State | Directory writable | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| T3 running, writer attached, newest rows only in `-wal` | yes | reads them |
|
||||||
|
| `-wal` without `-shm` (writer killed, `-shm` removed) | yes | reads the WAL rows and creates `-shm` |
|
||||||
|
| `-wal` without `-shm` | no | open fails, SQLite 14 |
|
||||||
|
| T3 stopped cleanly, no `-wal` or `-shm` | yes | reads, then leaves an empty `-wal` and a 32 KiB `-shm` |
|
||||||
|
| T3 stopped cleanly | no | fails, SQLite 1544 "attempt to write a readonly database" |
|
||||||
|
|
||||||
|
In every case the main file's bytes stayed the same. The last row is where
|
||||||
|
Filbert and I differ. His review says the stopped-case read works with the
|
||||||
|
directory read-only. In my run it failed with and without the read
|
||||||
|
transaction. The build's test settles it. Either way a failed open is exit 1.
|
||||||
|
|
||||||
|
So the accurate claim: the reader never writes the main database file. Like
|
||||||
|
any SQLite connection, it may create or update `-wal` and `-shm` beside it
|
||||||
|
and takes read locks in `-shm`. T3 opens normally afterwards.
|
||||||
|
|
||||||
|
## 2. Jason or agent
|
||||||
|
|
||||||
|
Reuse the 6a rule. The first line of `text` decides: a T3 header or the tmux
|
||||||
|
preamble counts as agent, `control-board` as the sender counts as board, and
|
||||||
|
anything else counts as human. Messages with role `user` count; assistant
|
||||||
|
messages don't.
|
||||||
|
|
||||||
|
The class fix rides in this build (Sage's ruling). HEAD's
|
||||||
|
`packages/ledger/src/ledger.mjs:81` (tmux) and `:83` (T3) both allow only
|
||||||
|
`class=[a-z-]+`. Both become case-insensitive. Seats send uppercase classes:
|
||||||
|
Sage's DECISION, INFO, REVIEW-REQUEST and REVIEW-NOTE, and my own
|
||||||
|
REVIEW-REQUEST. In this project's threads 16 real agent headers failed on
|
||||||
|
that alone at 20:54Z. The ms-communications table lists lowercase names, so
|
||||||
|
the fix follows what seats send, not the table.
|
||||||
|
|
||||||
|
Cross-check, read at 2026-09-26T20:54Z for the mosaic-stack project (209
|
||||||
|
user messages outside imported and deleted threads, every one with its
|
||||||
|
`thread.message-sent` event). Filbert's later read agreed, plus messages sent
|
||||||
|
since.
|
||||||
|
|
||||||
|
| T3 origin | Header matches 6a | Count |
|
||||||
|
|---|---|---|
|
||||||
|
| typed in the app (has `appVersion`) | no | 99 |
|
||||||
|
| sent through the API (no `appVersion`) | yes | 80 |
|
||||||
|
| sent through the API | no, uppercase class | 16 |
|
||||||
|
| sent through the API | no, free-text roles | 14 |
|
||||||
|
|
||||||
|
No message typed in the app carries a header, and every API message in this
|
||||||
|
project carries one of the three forms. The 14 free-text ones are older
|
||||||
|
Discord Bot thread headers such as `[from: SetSpark coordinator (…) -> to:
|
||||||
|
Discord Bot (…)]`, written before the guide fixed the format. Sage ruled they
|
||||||
|
stay as recorded: they count as human, dated 2026-09-17 to 2026-09-22.
|
||||||
|
|
||||||
|
The header rule decides. The JSON also carries one diagnostic that feeds no
|
||||||
|
table or total: user messages the rule calls human that T3 recorded as sent
|
||||||
|
through the API. That number exposed the class bug and would catch the next
|
||||||
|
format drift. `origin` is T3's internal metadata, not a documented contract,
|
||||||
|
so it decides nothing. If `orchestration_events` or a column it needs is
|
||||||
|
missing, the diagnostic reads `unknown` and the report goes on (Sage's
|
||||||
|
ruling on F5). Missing tables the counts depend on still exit 1.
|
||||||
|
|
||||||
|
## 3. Thread to seat
|
||||||
|
|
||||||
|
**Project.** A thread counts for this checkout only if its project's
|
||||||
|
`workspace_root` equals the ledger's repository root, byte for byte. The CLI
|
||||||
|
already takes that root from the realpath of its own URL, today
|
||||||
|
`/mnt/storage/src/mosaic-stack`, project `34050c07`. So a T3 project opened
|
||||||
|
through the compatibility symlink `~/src/mosaic-stack-dev-test` doesn't
|
||||||
|
match, and "no project row" is the right refusal. The README says so.
|
||||||
|
|
||||||
|
**Title rule.** Thread IDs change whenever Jason starts a new thread for a
|
||||||
|
seat, so there's no fixed map. T3-AGENT-COMMS.md already names threads after
|
||||||
|
the seat ("Darkwing", "Sage", "Dewey in Claude"). A thread belongs to seat
|
||||||
|
`<s>` when `<s>` is a real directory under `agents/` and the lower-cased
|
||||||
|
title equals `<s>` or starts with `<s>` followed by a space. So "Sagebrush"
|
||||||
|
stays unmapped. Several threads can map to one seat, and their counts add
|
||||||
|
up, as several Pi session files already do.
|
||||||
|
|
||||||
|
Today that maps Sage, Darkwing, Filbert, Dewey and Rocko (one thread each,
|
||||||
|
created 2026-09-26, titles set by hand), plus "Darkwing in Claude" (archived)
|
||||||
|
and "Dewey in Claude". Researcher has a directory and no thread. Three
|
||||||
|
threads map to no seat. Two are imported and excluded anyway ("FINDINGS.md
|
||||||
|
review" and "[dragon-lin:darkwing -> …"). The third is "Discord Bot" with 68
|
||||||
|
user messages: 54 without a header, and the 14 free-text headers.
|
||||||
|
|
||||||
|
Titles are current state, and T3 can write them itself. They go wrong three
|
||||||
|
ways. T3 auto-titles an unnamed thread from Jason's first prompt, so "Rocko
|
||||||
|
review of the plan" maps to rocko. A rename moves the whole history to
|
||||||
|
another row. A seat thread titled for a topic drops into `t3:unmapped`.
|
||||||
|
None of this changes the Human total or the human-per-closed ratio. It only
|
||||||
|
moves counts between rows, but Gate F reads one seat's row.
|
||||||
|
|
||||||
|
**Header cross-check.** The headers already say which seat a thread belongs
|
||||||
|
to. For every user message whose header matches the fixed 6a rule and whose
|
||||||
|
`to:` id equals the message's own `thread_id`:
|
||||||
|
- in a mapped thread, the `to:` role, lower-cased, must equal that thread's
|
||||||
|
seat;
|
||||||
|
- in an unmapped thread, the `to:` role must not be a seat name.
|
||||||
|
|
||||||
|
A conflict exits 1 and names the thread id, its title and both roles. A
|
||||||
|
header whose `to:` id is some other thread is not checked. The check reads
|
||||||
|
message text only, not T3 metadata. In a live read at 21:02Z every header
|
||||||
|
agreed: all 104 addressed to their own thread carried the full thread id and
|
||||||
|
named that thread's seat (Sage 40, Darkwing 15, Filbert 18, Dewey 15, Rocko
|
||||||
|
16).
|
||||||
|
|
||||||
|
It catches a seat thread renamed to another seat or to a topic, once any
|
||||||
|
agent writes to it. It also catches an auto-titled thread that agents
|
||||||
|
address by a different seat. It misses a thread no agent ever writes to.
|
||||||
|
Such a thread can only add human counts to a seat's row, never hide them, so
|
||||||
|
for Gate F it errs toward a visible failure. The README says so.
|
||||||
|
|
||||||
|
**Unmapped row.** Unmapped threads go in one Table 2 row, `t3:unmapped`
|
||||||
|
(Sage's ruling), so their human messages still reach the Human column and
|
||||||
|
the human-per-closed ratio.
|
||||||
|
|
||||||
|
**Mapping in the JSON.** For each seat, the T3 thread ids and titles that
|
||||||
|
made its row, and the unmapped thread ids and titles. Anyone checking a Gate
|
||||||
|
F result can then see which threads the row came from.
|
||||||
|
|
||||||
|
A seat's row sums its Pi and T3 counts, and the JSON splits them by source.
|
||||||
|
Nothing is counted twice. Every T3 session today runs on `claudeAgent` or
|
||||||
|
`codex`, which don't write `.pi/state`, and Filbert found no T3 header in any
|
||||||
|
Pi log (6a record).
|
||||||
|
|
||||||
|
**Excluded,** with the reason stated in the README:
|
||||||
|
- Imported threads (`thread_id` starting `import:`, events marked
|
||||||
|
`historyImport`). They are partial copies of Claude Code sessions, not T3
|
||||||
|
traffic: 55 user messages in two threads here.
|
||||||
|
- Deleted threads (`deleted_at` set). Across all projects there are 3, with
|
||||||
|
3 messages. Archived threads count.
|
||||||
|
- Threads in other T3 projects. Live, there is a project at `/home/jwoltje`
|
||||||
|
and a deleted one at `/mnt/storage/src`. A thread in either could work on
|
||||||
|
this repository and would not be counted. The workspace-root rule is still
|
||||||
|
the right one, but the README names this blind spot.
|
||||||
|
|
||||||
|
## 4. What fails closed
|
||||||
|
|
||||||
|
The source is on by default (Sage's ruling). `--no-t3` turns it off, and the
|
||||||
|
report then says T3 was not read. `--t3-db <path>` reads another database
|
||||||
|
file instead of `~/.t3/userdata/state.sqlite`. It exists for fixtures and
|
||||||
|
gets the same checks.
|
||||||
|
|
||||||
|
Each of these refuses the report with exit 1, the code the ledger already
|
||||||
|
uses for unreadable session evidence. The report never falls back to Pi logs
|
||||||
|
alone. Where the database is missing or won't open, the message names
|
||||||
|
`--no-t3`.
|
||||||
|
- The database is missing or unreadable, or won't open read-only. That
|
||||||
|
includes a directory that isn't writable when SQLite needs to create
|
||||||
|
`-shm`, and a busy error after the timeout. The Pi reader treats a missing
|
||||||
|
`.pi` as no messages, and this departs from it on purpose. A missing Pi
|
||||||
|
directory means no Pi seats ran here. A missing T3 database on this host
|
||||||
|
means the path or T3 changed, and a silent zero is the failure Gate F
|
||||||
|
exists to prevent.
|
||||||
|
- `~/.t3`, `~/.t3/userdata` or `state.sqlite` is a symlink. With `--t3-db`,
|
||||||
|
the file and its directory are checked. The Pi reader checks every
|
||||||
|
ancestor too, but it skips symlinked entries. Skipping one named file
|
||||||
|
would be another silent zero, so this reader refuses.
|
||||||
|
- A table or column the counts need is missing. The reader checks `PRAGMA
|
||||||
|
table_info` and names what's missing. This catches a T3 upgrade that
|
||||||
|
changes the schema.
|
||||||
|
- No project row, or more than one non-deleted row, for this repository root.
|
||||||
|
- A counted row has a bad `role`, non-string `text`, or a `created_at` that
|
||||||
|
doesn't parse. The Pi reader already refuses malformed JSONL and bad
|
||||||
|
timestamps the same way.
|
||||||
|
- A header conflicts with the title mapping (section 3).
|
||||||
|
|
||||||
|
The reader never reads other files in `~/.t3`. It passes no message text
|
||||||
|
beyond `messageKind`, `issueNumbers` and the header's `to:` role and id, the
|
||||||
|
same rule as for Pi logs.
|
||||||
|
|
||||||
|
## 5. Rulings
|
||||||
|
|
||||||
|
Sage ruled on the three questions R1 put to Jason, as lead calls:
|
||||||
|
1. The source is on by default. A missing or unreadable database exits 1,
|
||||||
|
and the message names `--no-t3`.
|
||||||
|
2. Unmapped threads get the `t3:unmapped` row.
|
||||||
|
3. The 14 free-text headers stay as recorded. They show only in the JSON
|
||||||
|
diagnostic.
|
||||||
|
|
||||||
|
Sage also ruled that the class fix rides in this build, and that a missing
|
||||||
|
diagnostic table reads `unknown` (F5).
|
||||||
|
|
||||||
|
## 6. Acceptance for the build
|
||||||
|
|
||||||
|
**No test opens the real `~/.t3`.** Both places in
|
||||||
|
`packages/ledger/tests/ledger.test.mjs` that spawn `cli.mjs` (the shared
|
||||||
|
`run()` helper and the direct `spawnSync` at line 66) set `HOME` to the
|
||||||
|
fixture's temp directory. A test that forgets `--t3-db` or `--no-t3` then
|
||||||
|
finds no database and fails closed. The existing tests aren't about T3. Each
|
||||||
|
gets an empty fixture database at the fixture `HOME`'s default path, with
|
||||||
|
one project row for the fixture root. So they run with the source on, and
|
||||||
|
their expected rows don't change. One test asserts that a `HOME` with no
|
||||||
|
database exits 1 and names `--no-t3`.
|
||||||
|
|
||||||
|
Fixture databases are built with `node:sqlite` in a temp directory, in WAL
|
||||||
|
mode:
|
||||||
|
- seat threads and an unmapped thread; imported, deleted and archived
|
||||||
|
threads; a message outside the date range;
|
||||||
|
- all three header forms, with uppercase classes in both the tmux preamble
|
||||||
|
and the T3 header;
|
||||||
|
- a thread with the same seat title in another project;
|
||||||
|
- a seat thread renamed to another seat, with an agent header to its own
|
||||||
|
id, which exits 1;
|
||||||
|
- a "Sagebrush" title, which stays unmapped;
|
||||||
|
- a thread titled "Researcher", which maps to the seat that has no thread
|
||||||
|
live.
|
||||||
|
|
||||||
|
WAL states, each with its own test:
|
||||||
|
- The newest message is only in `-wal`, with the writer still attached (the
|
||||||
|
live-T3 case). It is counted.
|
||||||
|
- T3 stopped: the database closed cleanly with no writer. Counts are
|
||||||
|
correct, and the main file's bytes are unchanged afterwards.
|
||||||
|
- `-wal` without `-shm` in a writable directory: made by a child writer with
|
||||||
|
`wal_autocheckpoint=0` that is SIGKILLed, then `-shm` deleted. The WAL
|
||||||
|
rows are counted.
|
||||||
|
- `-wal` without `-shm` in a directory that isn't writable: exit 1, naming
|
||||||
|
`--no-t3`. Skipped when the tests run as root, where the mode bits don't
|
||||||
|
bind.
|
||||||
|
- The stopped case in a directory that isn't writable: exit 1, naming
|
||||||
|
`--no-t3`, as I measured it. If the build reads there instead, the builder
|
||||||
|
changes this test to assert correct counts and records the correction in
|
||||||
|
the BUILD-LOG entry. Skipped as root too.
|
||||||
|
|
||||||
|
Also:
|
||||||
|
- Each other fail-closed case in section 4 has its own test, including a
|
||||||
|
removed schema column and each symlink.
|
||||||
|
- A missing `orchestration_events` gives `unknown` for the diagnostic and
|
||||||
|
the same counts.
|
||||||
|
- The class fix is proven against HEAD's `messageKind`. An uppercase class
|
||||||
|
in either preamble counts as agent after the fix and as human before it.
|
||||||
|
- The JSON lists each seat's threads and the unmapped threads.
|
||||||
|
- A read against the live database gives the counts in this brief, allowing
|
||||||
|
for messages sent since. It exits 0 with no header conflict.
|
||||||
|
- The ledger README's counting rules name the new source, both flags, the
|
||||||
|
mapping rule and the header check, and the exclusions. That includes the
|
||||||
|
symlinked-checkout case and the other-project blind spot.
|
||||||
|
- No suite runs the ledger tests, so the BUILD-LOG entry names the test file.
|
||||||
|
|
||||||
|
## Not in scope
|
||||||
|
|
||||||
|
- Claude Code transcripts (`~/.claude/projects`) and Codex sessions
|
||||||
|
(`~/.codex/sessions`). T3's database already holds every message T3
|
||||||
|
delivered, so those files would only duplicate it.
|
||||||
|
- Any T3 API call, anything that needs T3 running, and any write beyond
|
||||||
|
SQLite's own `-wal` and `-shm` handling.
|
||||||
|
- Fixing the two tmux misclassifications Filbert found in 6a.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Queue row 33, round 1 review (#1508)
|
||||||
|
|
||||||
|
Darkwing, 2026-10-04. Request: #1508 comment 26648. Brief:
|
||||||
|
`docs/plans/2026-10-04_queue-follow-ups.md`. Candidate:
|
||||||
|
`agents/filbert/work/queue-33/candidate-manifest.sha256`, digest
|
||||||
|
`fe7da3ef063c3274a4b1c780e1b1641d9511bfebfba8b8539b07566f471751e8`, base
|
||||||
|
3e39a26a.
|
||||||
|
|
||||||
|
Verdict: approve.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
- The manifest hashes to fe7da3ef. All 10 listed files match it in the
|
||||||
|
canonical working tree. Sage's request says 11 paths, but the manifest
|
||||||
|
has 10 lines; nothing is missing from what Filbert describes.
|
||||||
|
- Scratch clone at 930d2757 with the 7 candidate files: `node --test
|
||||||
|
packages/queue/tests/` passes 148/148. `bash scripts/test-queue.sh`
|
||||||
|
gives 27/0; the live checks skip there, as in an export.
|
||||||
|
- Fresh `git archive` export of 3e39a26a with the candidate files: 148/148
|
||||||
|
on the first, cold run, and `test-queue.sh` 27/0.
|
||||||
|
- `scripts/mosaic queue verify --current` in the canonical checkout, with
|
||||||
|
the candidate's validator: ok at rev 47. The live log replays under the
|
||||||
|
calendar check.
|
||||||
|
|
||||||
|
## The five items
|
||||||
|
|
||||||
|
1. Genesis. `parseMigrationMap` has one caller, `genesis` in store.mjs,
|
||||||
|
and it runs after the retry check. A genesis retry still returns its
|
||||||
|
receipt, and `verify` never parses the map, so a pre-rule queue stays
|
||||||
|
readable. C1 is right.
|
||||||
|
2. Assign wording matches the brief.
|
||||||
|
3. HEAD moved. `head_moved` runs before each canary and again when the
|
||||||
|
clean run fails. The step-7 check now catches a move that update-ref
|
||||||
|
used to catch. Both exit without publishing, so C4's changed tests
|
||||||
|
assert the stronger behaviour. C2 ("another commit landed") is right:
|
||||||
|
the check fires for any commit. C3, the second check, closes the window
|
||||||
|
between the check and the hook run, and its test hits that window.
|
||||||
|
4. Calendar. I parsed every hour 00 to 99 with minutes and seconds 0, 59,
|
||||||
|
60 and 99 in V8. Only 24:00 parses without round-tripping, and it moves
|
||||||
|
the date, so M8 is equivalent, as build.md says. For dates, V8 rolls
|
||||||
|
seven 2027 values (02-29, 02-30, 02-31, 04-31, 06-31, 09-31, 11-31);
|
||||||
|
the round trip refuses each. Year 0000 and 9999-12-31T23:59:59.999Z
|
||||||
|
round-trip and pass. Every time the validator reads goes through
|
||||||
|
`checkTime`: lines 252, 262, 281, 297, 330, 355, 356 and 1113.
|
||||||
|
5. Cold runs: 20 recorded, all 148/148. The brief's rule closes the item.
|
||||||
|
|
||||||
|
## My mutants
|
||||||
|
|
||||||
|
Ten, on the candidate. Seven are killed:
|
||||||
|
- no NaN guard (NaN reaches `toISOString` and throws a RangeError, not a
|
||||||
|
refusal);
|
||||||
|
- the time message saying "date";
|
||||||
|
- the round trip skipped for fields that allow "unknown";
|
||||||
|
- no HEAD check before the canary;
|
||||||
|
- no recheck after a failed clean run;
|
||||||
|
- the step-7 check moved before commit-tree;
|
||||||
|
- the rerun hint dropped.
|
||||||
|
|
||||||
|
Three survive:
|
||||||
|
- `Date.parse(v)` on the bare date in place of the `T00:00:00.000Z`
|
||||||
|
suffix. Equivalent: a bare ISO date parses as UTC midnight.
|
||||||
|
- The genesis rule checking only `reviewers[0]`. Every new test puts the
|
||||||
|
owner first. The code uses `includes`, so this isn't a defect.
|
||||||
|
- `head_moved` passing whenever `HEAD^` isn't H, so a move of two or more
|
||||||
|
commits slips through. Every test moves HEAD by one commit. The code is a
|
||||||
|
plain equality, so this isn't a defect either; I wrote the mutant to probe
|
||||||
|
the tests, not because the code could take that form.
|
||||||
|
|
||||||
|
## Non-blocking
|
||||||
|
|
||||||
|
- n1. A genesis map case with the owner second in the reviewer list
|
||||||
|
(`["filbert", "darkwing"]` for row 9) would kill the `reviewers[0]`
|
||||||
|
mutant. Optional.
|
||||||
|
- n2. The 20 cold runs ran one at a time on an idle machine. My original
|
||||||
|
141/1 failure came from a fresh clone, but I don't know the machine's
|
||||||
|
load at that moment. If it was load-dependent, idle runs wouldn't show
|
||||||
|
it. The brief's rule is met, and I agree with closing the item. If the
|
||||||
|
failure comes back, capture the test name and the load.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
dfd9be08c4a31ee536bbcd9bfd8e73cd8baa4114086b55845627ea0c97023e2a agents/dewey/work/queue-47/evidence.md
|
||||||
|
061ca09644d03690c92b2d09f3b13d407f194fe0b590125692c359d10bace395 packages/conversation/tests/claim.test.mjs
|
||||||
|
9bb32c730e6fd1d6648ff623cd6ded71cda300ede2d618e28ee6b682626d4d01 packages/conversation/tests/cohort.test.mjs
|
||||||
|
896f09586916ffdb399b2b5bd04770473a6dd62938d1e9cc0a7e0239f32a36ef packages/conversation/tests/flows.test.mjs
|
||||||
|
ca003075749dec9368c91f282c8218d602eeb8c6290282a884876be5af2d87e6 packages/conversation/tests/harness.mjs
|
||||||
|
2ae5d7b8119f29654e1672d1948992be79e973049a50da99250de7ade26b2071 packages/conversation/tests/races.test.mjs
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
agents/dewey/work/queue-47/evidence.md
|
||||||
|
packages/conversation/tests/claim.test.mjs
|
||||||
|
packages/conversation/tests/cohort.test.mjs
|
||||||
|
packages/conversation/tests/flows.test.mjs
|
||||||
|
packages/conversation/tests/harness.mjs
|
||||||
|
packages/conversation/tests/races.test.mjs
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
export TMPDIR=~/darkwing-scratch/r47a/tmp DOCKER_HOST=unix:///nonexistent.sock
|
||||||
|
cd ~/darkwing-scratch/r47a/wt
|
||||||
|
O=~/darkwing-scratch/r47a/out
|
||||||
|
: > $O/summary.txt
|
||||||
|
shims() { ps -eo pid,args | grep -F "$TMPDIR" | grep -F shim.mjs | grep -v grep | wc -l; }
|
||||||
|
for p in conversation webui; do
|
||||||
|
node --test "packages/$p/tests/*.test.mjs" > $O/node-$p.txt 2>&1; e=$?
|
||||||
|
echo "node-$p exit=$e $(grep -E '^ℹ (pass|fail)' $O/node-$p.txt | tr '\n' ' ') shims-left=$(shims)" >> $O/summary.txt
|
||||||
|
done
|
||||||
|
for f in scripts/test-*.sh; do
|
||||||
|
s=$(basename $f .sh)
|
||||||
|
$f > $O/$s.txt 2>&1; e=$?
|
||||||
|
echo "$s exit=$e $(grep -E 'passed, [0-9]+ failed' $O/$s.txt | tail -1)" >> $O/summary.txt
|
||||||
|
done
|
||||||
|
echo "shims-left-end=$(shims)" >> $O/summary.txt
|
||||||
|
echo DONE >> $O/summary.txt
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
✔ W1: two processes acquire the same pair at once; exactly one claim (230.761469ms)
|
||||||
|
✔ W1: two writers publish the same revision at once: one wins, the other gets null, the winner's record stays (35.283345ms)
|
||||||
|
✔ W1: a revision name appears only after its bytes are synced; before that, only a temp file exists (19.246256ms)
|
||||||
|
✔ W2: acquire while a claim is reserved or active refuses already-active (666.451415ms)
|
||||||
|
✔ W3: acquire while stopping, uncertain, or stopped without proof refuses unsafe-replacement (419.502539ms)
|
||||||
|
✔ W4: same session with another seat tuple, and the reverse, both refuse; a loser on the seat key closes it no-unit (259.427764ms)
|
||||||
|
✔ W4: a hard link of one session under another seat is the same session: the second controller refuses already-active and launches nothing (243.237404ms)
|
||||||
|
✔ W4: a copy of one session under another seat is the same session: the second controller refuses already-active and launches nothing (216.032408ms)
|
||||||
|
✔ W4: a session header ID that changes after construction refuses target; nothing is claimed or launched (2.493472ms)
|
||||||
|
✔ W5: SIGKILL between every publication barrier of acquire and transition; restart never finds two holders or a lost claim (12101.571707ms)
|
||||||
|
✔ W5: SIGKILL between every publication barrier of release; restart finishes or holds the release (35852.52336ms)
|
||||||
|
✔ W6: controller killed mid-turn while the engine lives; restart is uncertain, no launch, prompts refuse (364.603499ms)
|
||||||
|
✔ W12: a live owner paused with SIGSTOP; a second controller refuses already-active and changes nothing (157.89886ms)
|
||||||
|
✔ W13: crash after the engine spawns, before active; restart finds the live unit: uncertain, no second spawn, force stop only (696.888506ms)
|
||||||
|
✔ W14: crash after reservation, before the spawn marker: stopped with a no-unit observation; the pair is free (353.745861ms)
|
||||||
|
✔ W20: crash after the spawn marker, scope collected; uncertain in both runs, the marker is copied, no launch until a boot proof (432.046961ms)
|
||||||
|
✔ W15: crash between the two keys during release; restart finishes it under the same claim ID (176.123441ms)
|
||||||
|
✔ W7: recorded boot ID differs on the same machine: stopped with a boot proof; open tool calls become uncertain (237.115041ms)
|
||||||
|
✔ W8: resume after a proven stop with the same pins: new claim ID, generation +1, same conversation, branch and leaf (443.951742ms)
|
||||||
|
✔ W9: resume with a changed binary, argv digest, branch or leaf is refused and the claim is unchanged (1335.729283ms)
|
||||||
|
✔ W11: the controller writes no session file; only the fake engine's own appends appear (399.160544ms)
|
||||||
|
✔ W16: a highest revision that won't parse holds the pair uncertain; the older stopped revision is not reused (128.725923ms)
|
||||||
|
✔ W17: a claim root copied from another host refuses foreign-host and promotes nothing (69.244187ms)
|
||||||
|
✔ G1: a session path or claim root under .pi/state, ~/.claude, the data root or a registration refuses at construction (3.523388ms)
|
||||||
|
✔ G2: a symlink inside the fixture root to a live session file is refused by the real-path check (1.102658ms)
|
||||||
|
✔ G3: a fixture path swapped for a live path after construction is refused at bind (1.859532ms)
|
||||||
|
✔ no shim from this file's tests is left running for the after hook (25.021175ms)
|
||||||
|
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2907.900269ms)
|
||||||
|
✔ K2: K1 on the process-group fallback ends uncertain, never stopped (662.084573ms)
|
||||||
|
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2648.481561ms)
|
||||||
|
✔ K4: two engines; force stop one; the other survives by independent observation (4971.590724ms)
|
||||||
|
✔ K5: a stop during a tool call leaves the effect uncertain, and it is shown (2671.091036ms)
|
||||||
|
✔ K12: a member forking in a loop: the freeze stops it, enumeration is complete, populated 0 after cgroup.kill (2543.899927ms)
|
||||||
|
✔ K13: a member writing its pid into another cgroup is refused by the namespace; the kill is complete (2497.760482ms)
|
||||||
|
✔ K15: the shim gone, engine/cgroup.events unreadable, or the engine cgroup missing: evidence unavailable, not empty; uncertain (5061.043083ms)
|
||||||
|
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (944.475903ms)
|
||||||
|
✔ K11: controller killed after the confirmation is recorded, before TERM: restart checks the invocation ID and re-runs from TERM for the same stop (579.802852ms)
|
||||||
|
✔ K14: a unit with the recorded name but another invocation ID: evidence unavailable, no signals, uncertain (510.816848ms)
|
||||||
|
✔ K6: recover without proof, without confirmation, or with changed pins is refused (853.259964ms)
|
||||||
|
✔ K7: recover after proof, then launch: new claim and execution, generation +1, same leaf; the cancelled prompt is not replayed (570.67324ms)
|
||||||
|
✔ K8: an engine that loads another leaf on resume is refused before admission; it stays claimed until a proven stop (748.384126ms)
|
||||||
|
✔ K9: an interrupt that never settles stays uncertain; force stop stays available; takeover is refused while fenced (3374.04811ms)
|
||||||
|
✔ K16: a claim from another machine ID refuses foreign-host; no boot proof is issued (27.290911ms)
|
||||||
|
✔ K17: two launcher calls with one eligibility record: one launch, the other refuses, no second engine (633.85929ms)
|
||||||
|
✔ K18: the leaf changes after eligibility: launch refused; the reservation stays until released with proof (650.888402ms)
|
||||||
|
✔ K19: a scope launched with only the engine environment still reaches the user manager; the engine sees no other names (103.278418ms)
|
||||||
|
✔ no shim from this file's tests is left running for the after hook (26.447839ms)
|
||||||
|
✔ S1: `/goal x`, with leading spaces or a tab, refuses text-policy at admission; zero engine bytes (219.066131ms)
|
||||||
|
✔ S2: every prefix pinned Pi interprets is refused, from the list the code uses; the rest reach the engine exactly (486.569612ms)
|
||||||
|
✔ S3: `/goal` on the second line is pinned from the source: Pi checks only index 0, so it is admitted and sent exactly (237.738849ms)
|
||||||
|
✔ S4: a `/` left in the composer is cleared when control transfers and returns; the next submit sends only the new text (344.619515ms)
|
||||||
|
✔ S5: an observer terminal gets a paste then Enter, as send-message.sh does: not admitted: controller, nothing sent (230.932458ms)
|
||||||
|
✔ S6: a mediated-shaped registration (no tmux) passed to the board's replyToRow: 409 no tmux session; exec never runs (0.452415ms)
|
||||||
|
✔ S7: ESC, bracketed-paste markers and U+2028/U+2029 travel as one JSON string; the engine receives the exact text in one record (242.54138ms)
|
||||||
|
✔ P3: a Pi confirm, select, input or editor dialog is shown disabled with a reason and never answered (326.810099ms)
|
||||||
|
✔ E1: send, ack, user, toolCall, toolResult, final answer: shown once, no refresh, draft and reading position kept (250.642243ms)
|
||||||
|
✔ E2: U+2028, U+2029 inside JSON strings and CRLF line ends each parse as one record, on the splitter and through the controller (222.955355ms)
|
||||||
|
✔ E3: a multipart final, two blocks, null request correlation and duplicate delivery (302.713109ms)
|
||||||
|
✔ 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 (343.538094ms)
|
||||||
|
✔ E4: a gap or a new epoch also reconciles; nothing is concatenated across a gap (11.0646ms)
|
||||||
|
✔ E5: an unknown native event gives no client event; evidence records its type and bytes; the terminal count goes up (302.801926ms)
|
||||||
|
✔ E6: a tool result delayed across a pause and a reconnect is reconciled without a manual refresh (249.456734ms)
|
||||||
|
✔ E7: the terminal renders the same stream as the library client, as observer and then as controller, and submits only as controller (315.374632ms)
|
||||||
|
✔ terminal: engine control characters are made visible; a lost connection refuses submit (225.411308ms)
|
||||||
|
✔ terminal: outcome unknown is shown as such, with no resend offer, and nothing is resent (0.373986ms)
|
||||||
|
✔ terminal: text after Enter in the same input chunk starts the next message; it never joins the one submitted (0.257853ms)
|
||||||
|
✔ terminal: a paste-start marker split right after its ESC still opens the paste; the Enter inside it never submits (0.32094ms)
|
||||||
|
✔ terminal: Ctrl-T then Enter in one chunk is judged after the takeover, as if typed one key at a time (Filbert F2, #1507) (0.481082ms)
|
||||||
|
✔ terminal: input held behind Ctrl-T waits for that takeover while an earlier action is still pending (Darkwing T1 on #1522) (30.859499ms)
|
||||||
|
✖ terminal: a hold inside held input holds again, and once it is drained later input and Ctrl-C still reach the terminal (Darkwing note 1 on #1522) (1.169348ms)
|
||||||
|
✔ terminal: an action that throws still releases the input held behind it, in order, then rethrows (6.289639ms)
|
||||||
|
✔ terminal: after an action throws, later input still runs; input() puts the error in the status line (Filbert N1 on #1522) (0.414834ms)
|
||||||
|
✔ terminal: input() reports an error when it happens, so it never overwrites a later status; held input's promise doesn't carry the holder's error (Filbert N4 on #1522) (0.419486ms)
|
||||||
|
✔ terminal: invisible and bidi characters are made visible; head, status and notice lines stay one line (0.101127ms)
|
||||||
|
✔ every record these fixtures produced is a valid CHAT-01 record (E5: no record fails the schema) (224.439992ms)
|
||||||
|
✔ H1: two takeovers with the same expected generation: one wins, +1; the other refuses generation (265.687996ms)
|
||||||
|
✔ H2: the old controller's prompt after a takeover commits is refused with zero engine bytes (602.750029ms)
|
||||||
|
✔ H3: a takeover while a prompt holds the dispatch lock: written under the old actor, or refused; never both (980.633425ms)
|
||||||
|
✔ H4: self-takeover is refused (211.637746ms)
|
||||||
|
✔ H9: Interrupt racing a prompt's dispatch: before the write, dispatch-refused and no-turn; after, §3 rules (506.871423ms)
|
||||||
|
✔ H10: Interrupt and force stop together: one stop chain, force stop supersedes (1488.148474ms)
|
||||||
|
✔ H10: an overlap during the pause before the abort: no abort, the stop ends uncertain (257.919317ms)
|
||||||
|
✔ H10: a no-turn Interrupt lifts only its own fence; admission stays closed under force stop, overlap or revocation (807.188663ms)
|
||||||
|
✔ H11: the controller disconnects mid-turn: work continues, the claim is unchanged, control stays put (345.183361ms)
|
||||||
|
✔ H12: an exact retry after reconnecting to the same incarnation returns the same receipt; one dispatch (230.52151ms)
|
||||||
|
✔ H13: a retry with the same request ID and different text is refused (187.355213ms)
|
||||||
|
✔ H14: late stdout from the old engine after a replacement is dropped by incarnation, counted, never rendered (695.840348ms)
|
||||||
|
✔ H15: a revoked connection's command is refused; the revocation fence holds (392.932221ms)
|
||||||
|
✔ H16: a second controller for the same session refuses already-active; the first is untouched (219.444268ms)
|
||||||
|
✔ H10: a second force stop while the first escalation runs refuses fenced; one escalation, and the claim records only the first stop's phases (986.019029ms)
|
||||||
|
✔ a force stop whose fence throws leaves no escalation flag behind, so the next force stop runs (Darkwing F2, #1507) (368.285501ms)
|
||||||
|
✔ H17: a confirmation reused, answered from another connection, or used after the stop changed is refused (809.335038ms)
|
||||||
|
✔ H18: two prompts before any native output: the second refuses busy; one engine write (217.250935ms)
|
||||||
|
✔ H19: the pipe fails mid-line under a large prompt: delivery-unknown transport-unknown, poisoned, no later write (337.785107ms)
|
||||||
|
✔ H19: the link itself never writes again after an unknown outcome, whoever calls it (0.629978ms)
|
||||||
|
✔ H19: the controller dies mid-write of a large line: after restart the outcome is unknown and nothing is resent (681.233021ms)
|
||||||
|
✔ H20: the line is written but the ack is lost when the controller dies: orphan, outcome unknown, nothing resent (588.806469ms)
|
||||||
|
✔ H21: a retry of the exact request with the old token after a crash is stale-incarnation; no second write (712.524709ms)
|
||||||
|
✔ H22: after H21 and a valid recovery, a new request with the new token is admitted (2943.496164ms)
|
||||||
|
✔ H23: requests pending at a restart are not resent; each shows outcome unknown (678.292339ms)
|
||||||
|
✔ no shim from this file's tests is left running for the after hook (26.16412ms)
|
||||||
|
✔ a plain conversation: catalogue row, one page, CHAT-01 records (8.500467ms)
|
||||||
|
✔ native entries map to blocks: tools, thinking, bash, notices, ids that do not fit (2.946212ms)
|
||||||
|
✔ F1: a malformed line is an unavailable part at its position, and reading continues (3.038266ms)
|
||||||
|
✔ F1: a missing parent stops the history with a notice that names the unreadable lines (3.460723ms)
|
||||||
|
✔ F1: an unreadable fork is never merged into another branch's history (2.377455ms)
|
||||||
|
✔ F1: a follow stays on its branch when the next entry's parent is unreadable (3.037382ms)
|
||||||
|
✔ F1: a file whose entries are all unreadable shows a notice per line (1.323094ms)
|
||||||
|
✔ F2: a truncated trailing line marks the view incomplete, not an error (2.118799ms)
|
||||||
|
✔ pagination: 100 parts, then the rest; parts concatenate to the whole branch (5.420763ms)
|
||||||
|
✔ F3: a replaced file (new inode) refuses old cursors with reconcile (6.4095ms)
|
||||||
|
✔ F4: a same-inode rewrite of the prefix refuses old cursors with reconcile (5.711012ms)
|
||||||
|
✔ F5: growth between pages keeps the epoch and the page stops at the pinned length (6.202576ms)
|
||||||
|
✔ F6: unknown, foreign and expired cursors refuse and leave the cursor usable (8.351461ms)
|
||||||
|
✔ F7: a symlinked file and a symlinked directory component are refused, never opened (8.80748ms)
|
||||||
|
✔ F8: a file swapped for a symlink after the catalogue is refused (1.879545ms)
|
||||||
|
✔ F9: registrations never add or redirect a root (1.819782ms)
|
||||||
|
✔ F10: a header cwd naming another project is refused (4.267714ms)
|
||||||
|
✔ F11: parentSession renders with a marker and the parent is never opened (0.734978ms)
|
||||||
|
✔ F12: two leaves: the default leaf is shown and the other branch reads alone (4.160641ms)
|
||||||
|
✔ F12: a follow refuses when an appended duplicate id changes the branch's earlier parts (1.942174ms)
|
||||||
|
✔ F12: a second root (Pi's resetLeaf) starts its own branch (1.400315ms)
|
||||||
|
✔ F13: compaction is a marker in place, then the retained content (0.72091ms)
|
||||||
|
✔ F14: long strings split into fragments and parts, reassemble exactly, and pages respect the byte cap (541.577382ms)
|
||||||
|
✔ fragments never cut a surrogate pair and keep an empty string (4.290547ms)
|
||||||
|
✔ F15: a Claude seat is an unsupported-harness placeholder whose directory is never read (1.426317ms)
|
||||||
|
✔ unknown conversations, empty files and non-Pi files refuse (2.189166ms)
|
||||||
|
✔ an unreadable file or root inside the roots is refused per row, not a failed catalogue (1.018126ms)
|
||||||
|
✔ a seat directory without search permission refuses that root, not the catalogue (1.785298ms)
|
||||||
|
✔ every page and cursor is a valid CHAT-01 record (734.907993ms)
|
||||||
|
✔ the engine pin holds for the installed package (3.14919ms)
|
||||||
|
✔ pinned Pi, sealed and without credentials, answers the controller's commands with the shapes the fake models (289.737014ms)
|
||||||
|
✔ sealed, pinned Pi ignores a trusted project's .pi resources; --approve past the seal would load them, and checkSeal refuses it (Filbert F2 on #1522) (607.426468ms)
|
||||||
|
✔ pinned Pi appends thinking_level_change at start when the branch lacks one, so the leaf moves (K8 then fails closed) (240.104396ms)
|
||||||
|
✔ N25: ordinary Interrupt reconciles; a non-empty queue_update in the window is O5 (819.982387ms)
|
||||||
|
✔ N1: an extension's follow-up queued after the fence is cleared before any abort; O5, Unknown (520.700411ms)
|
||||||
|
✔ N1: a follow-up queued before the fence is O5 at once; the Interrupt refuses fenced (292.365153ms)
|
||||||
|
✔ N2: with abort first, the fake runs the external item (the ordering guard has teeth) (20.764736ms)
|
||||||
|
✔ N3: the fence lands in preflight, preflight errors, no run: failed, No run, uncertain (410.320626ms)
|
||||||
|
✔ N4: the ack arrives after the first abort and a run starts: clear and abort again; Interrupted (234.058416ms)
|
||||||
|
✔ N5: an input handler takes the prompt: ack, no run, delivery-unknown handled-without-run (327.369494ms)
|
||||||
|
✔ N6: an extension queues between clear_queue and abort: O5 and O6, Unknown (752.757501ms)
|
||||||
|
✔ N7: clear_queue times out: no abort, nativeQueue unknown, force stop still ends it (1969.460632ms)
|
||||||
|
✔ N7: clear_queue answers an error: no abort, nativeQueue unknown, the link not poisoned (228.669663ms)
|
||||||
|
✔ N8: an extension prompt starts a run during Mosaic preflight; the losing settle is O3 (312.846973ms)
|
||||||
|
✔ N9: a run that started before the fence and ends aborted: failed interrupted, Interrupted (221.554358ms)
|
||||||
|
✔ N9: decision 34: a run that ends aborted with no stop in progress: aborted-without-stop, uncertain, outcome unknown (269.807001ms)
|
||||||
|
✔ N9: an aborted that lands after the fence but before any abort is written: aborted-without-stop, Unknown (554.763895ms)
|
||||||
|
✔ N10: fake conformance (32.742057ms)
|
||||||
|
✔ N11: the run fails before any user message_start: delivery-unknown ack-without-start, never failed (465.53822ms)
|
||||||
|
✔ N12: input that starts a run after the final empty clear is O1 and not part of the stop's proof (311.07635ms)
|
||||||
|
✔ N13: agent_start with no slot held is O1; a later prompt refuses with zero engine bytes (366.274019ms)
|
||||||
|
✔ N14: the run completes while clear_queue is in flight: finished, Completed first, uncertain (379.816865ms)
|
||||||
|
✔ N14: the run completes after the abort is written, before Pi applies it: finished, never relabelled (381.8195ms)
|
||||||
|
✔ N15: the fence lands in preflight, then an input handler takes it: handled-without-run, No run (380.794236ms)
|
||||||
|
✔ N16: Interrupt with no slot and no run refuses no-turn: no stop, no bytes, admission open (215.48326ms)
|
||||||
|
✔ N17: the run fails on its own during the exchange: failed, Failed on its own (368.478508ms)
|
||||||
|
✔ N18: no final assistant message_end, or a lost line: working stays working; before working, transport-unknown (764.711894ms)
|
||||||
|
✔ N19: a losing extension prompt settles inside the Mosaic run before its user message: O3, run-overlap (601.979351ms)
|
||||||
|
✔ N20: an extension triggerTurn during Mosaic preflight starts first; while streaming it queues with no signal (519.115829ms)
|
||||||
|
✔ N21: a losing settle after the receipt settled finished is O2; the receipt stays finished (210.54534ms)
|
||||||
|
✔ N22: an agent-level custom message is dropped by the clear with no signal; evidence names the seal (191.338939ms)
|
||||||
|
✔ N23: a nextTurn message survives clear and abort and attaches to the next prompt, with no signal (189.870649ms)
|
||||||
|
✔ 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 (237.835355ms)
|
||||||
|
✔ N24b: the seal covers the engine command and environment: config can't name either, the env is built from names, and a mutated command is refused at bind (157.620797ms)
|
||||||
|
ℹ tests 165
|
||||||
|
ℹ suites 0
|
||||||
|
ℹ pass 164
|
||||||
|
ℹ fail 1
|
||||||
|
ℹ cancelled 0
|
||||||
|
ℹ skipped 0
|
||||||
|
ℹ todo 0
|
||||||
|
ℹ duration_ms 58028.081921
|
||||||
|
|
||||||
|
✖ failing tests:
|
||||||
|
|
||||||
|
test at packages/conversation/tests/flows.test.mjs:714:1
|
||||||
|
✖ terminal: a hold inside held input holds again, and once it is drained later input and Ctrl-C still reach the terminal (Darkwing note 1 on #1522) (1.169348ms)
|
||||||
|
AssertionError [ERR_ASSERTION]: ["\u0014","\u000f","hi\r"]
|
||||||
|
+ actual - expected
|
||||||
|
|
||||||
|
+ []
|
||||||
|
- [
|
||||||
|
- 'hi'
|
||||||
|
- ]
|
||||||
|
|
||||||
|
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r47a/wt/packages/conversation/tests/flows.test.mjs:726: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: [],
|
||||||
|
expected: [ 'hi' ],
|
||||||
|
operator: 'deepStrictEqual',
|
||||||
|
diff: 'simple'
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
✔ W1: two processes acquire the same pair at once; exactly one claim (253.899662ms)
|
||||||
|
✔ W1: two writers publish the same revision at once: one wins, the other gets null, the winner's record stays (33.284113ms)
|
||||||
|
✔ W1: a revision name appears only after its bytes are synced; before that, only a temp file exists (19.860532ms)
|
||||||
|
✔ W2: acquire while a claim is reserved or active refuses already-active (734.363814ms)
|
||||||
|
✔ W3: acquire while stopping, uncertain, or stopped without proof refuses unsafe-replacement (524.38725ms)
|
||||||
|
✔ W4: same session with another seat tuple, and the reverse, both refuse; a loser on the seat key closes it no-unit (252.799759ms)
|
||||||
|
✔ W4: a hard link of one session under another seat is the same session: the second controller refuses already-active and launches nothing (222.741713ms)
|
||||||
|
✔ W4: a copy of one session under another seat is the same session: the second controller refuses already-active and launches nothing (234.15361ms)
|
||||||
|
✔ W4: a session header ID that changes after construction refuses target; nothing is claimed or launched (2.493404ms)
|
||||||
|
✔ W5: SIGKILL between every publication barrier of acquire and transition; restart never finds two holders or a lost claim (12258.436675ms)
|
||||||
|
✔ W5: SIGKILL between every publication barrier of release; restart finishes or holds the release (32349.669618ms)
|
||||||
|
✔ W6: controller killed mid-turn while the engine lives; restart is uncertain, no launch, prompts refuse (272.642236ms)
|
||||||
|
✔ W12: a live owner paused with SIGSTOP; a second controller refuses already-active and changes nothing (147.477768ms)
|
||||||
|
✔ W13: crash after the engine spawns, before active; restart finds the live unit: uncertain, no second spawn, force stop only (480.56355ms)
|
||||||
|
✔ W14: crash after reservation, before the spawn marker: stopped with a no-unit observation; the pair is free (338.33334ms)
|
||||||
|
✔ W20: crash after the spawn marker, scope collected; uncertain in both runs, the marker is copied, no launch until a boot proof (399.586115ms)
|
||||||
|
✔ W15: crash between the two keys during release; restart finishes it under the same claim ID (149.751919ms)
|
||||||
|
✔ W7: recorded boot ID differs on the same machine: stopped with a boot proof; open tool calls become uncertain (236.949804ms)
|
||||||
|
✔ W8: resume after a proven stop with the same pins: new claim ID, generation +1, same conversation, branch and leaf (387.390616ms)
|
||||||
|
✔ W9: resume with a changed binary, argv digest, branch or leaf is refused and the claim is unchanged (1038.478309ms)
|
||||||
|
✔ W11: the controller writes no session file; only the fake engine's own appends appear (237.992787ms)
|
||||||
|
✔ W16: a highest revision that won't parse holds the pair uncertain; the older stopped revision is not reused (119.660597ms)
|
||||||
|
✔ W17: a claim root copied from another host refuses foreign-host and promotes nothing (80.974265ms)
|
||||||
|
✔ G1: a session path or claim root under .pi/state, ~/.claude, the data root or a registration refuses at construction (3.444071ms)
|
||||||
|
✔ G2: a symlink inside the fixture root to a live session file is refused by the real-path check (1.109812ms)
|
||||||
|
✔ G3: a fixture path swapped for a live path after construction is refused at bind (1.86441ms)
|
||||||
|
✖ no shim from this file's tests is left running for the after hook (5035.26209ms)
|
||||||
|
✖ /home/jwoltje/darkwing-scratch/r47a/wt/packages/conversation/tests/claim.test.mjs (5043.339958ms)
|
||||||
|
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (3302.420094ms)
|
||||||
|
✔ K2: K1 on the process-group fallback ends uncertain, never stopped (472.970914ms)
|
||||||
|
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2796.728057ms)
|
||||||
|
✔ K4: two engines; force stop one; the other survives by independent observation (5105.353597ms)
|
||||||
|
✔ K5: a stop during a tool call leaves the effect uncertain, and it is shown (2543.819351ms)
|
||||||
|
✔ K12: a member forking in a loop: the freeze stops it, enumeration is complete, populated 0 after cgroup.kill (2510.616024ms)
|
||||||
|
✔ K13: a member writing its pid into another cgroup is refused by the namespace; the kill is complete (2475.701534ms)
|
||||||
|
✔ K15: the shim gone, engine/cgroup.events unreadable, or the engine cgroup missing: evidence unavailable, not empty; uncertain (5063.223619ms)
|
||||||
|
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (771.359208ms)
|
||||||
|
✔ K11: controller killed after the confirmation is recorded, before TERM: restart checks the invocation ID and re-runs from TERM for the same stop (645.101545ms)
|
||||||
|
✔ K14: a unit with the recorded name but another invocation ID: evidence unavailable, no signals, uncertain (506.62451ms)
|
||||||
|
✔ K6: recover without proof, without confirmation, or with changed pins is refused (829.124385ms)
|
||||||
|
✔ K7: recover after proof, then launch: new claim and execution, generation +1, same leaf; the cancelled prompt is not replayed (420.127538ms)
|
||||||
|
✔ K8: an engine that loads another leaf on resume is refused before admission; it stays claimed until a proven stop (685.425895ms)
|
||||||
|
✔ K9: an interrupt that never settles stays uncertain; force stop stays available; takeover is refused while fenced (3650.969116ms)
|
||||||
|
✔ K16: a claim from another machine ID refuses foreign-host; no boot proof is issued (50.323904ms)
|
||||||
|
✔ K17: two launcher calls with one eligibility record: one launch, the other refuses, no second engine (469.843386ms)
|
||||||
|
✔ K18: the leaf changes after eligibility: launch refused; the reservation stays until released with proof (401.628845ms)
|
||||||
|
✔ K19: a scope launched with only the engine environment still reaches the user manager; the engine sees no other names (83.176682ms)
|
||||||
|
✔ no shim from this file's tests is left running for the after hook (28.57982ms)
|
||||||
|
✔ S1: `/goal x`, with leading spaces or a tab, refuses text-policy at admission; zero engine bytes (249.951436ms)
|
||||||
|
✔ S2: every prefix pinned Pi interprets is refused, from the list the code uses; the rest reach the engine exactly (230.734553ms)
|
||||||
|
✔ S3: `/goal` on the second line is pinned from the source: Pi checks only index 0, so it is admitted and sent exactly (529.84075ms)
|
||||||
|
✔ S4: a `/` left in the composer is cleared when control transfers and returns; the next submit sends only the new text (468.686749ms)
|
||||||
|
✔ S5: an observer terminal gets a paste then Enter, as send-message.sh does: not admitted: controller, nothing sent (237.030606ms)
|
||||||
|
✔ S6: a mediated-shaped registration (no tmux) passed to the board's replyToRow: 409 no tmux session; exec never runs (0.410276ms)
|
||||||
|
✔ S7: ESC, bracketed-paste markers and U+2028/U+2029 travel as one JSON string; the engine receives the exact text in one record (217.020169ms)
|
||||||
|
✔ P3: a Pi confirm, select, input or editor dialog is shown disabled with a reason and never answered (315.308644ms)
|
||||||
|
✔ E1: send, ack, user, toolCall, toolResult, final answer: shown once, no refresh, draft and reading position kept (240.266481ms)
|
||||||
|
✔ E2: U+2028, U+2029 inside JSON strings and CRLF line ends each parse as one record, on the splitter and through the controller (216.341803ms)
|
||||||
|
✔ E3: a multipart final, two blocks, null request correlation and duplicate delivery (277.942621ms)
|
||||||
|
✔ 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 (211.881183ms)
|
||||||
|
✔ E4: a gap or a new epoch also reconciles; nothing is concatenated across a gap (10.956364ms)
|
||||||
|
✔ E5: an unknown native event gives no client event; evidence records its type and bytes; the terminal count goes up (209.424433ms)
|
||||||
|
✔ E6: a tool result delayed across a pause and a reconnect is reconciled without a manual refresh (233.942839ms)
|
||||||
|
✔ E7: the terminal renders the same stream as the library client, as observer and then as controller, and submits only as controller (240.21204ms)
|
||||||
|
✔ terminal: engine control characters are made visible; a lost connection refuses submit (202.309814ms)
|
||||||
|
✔ terminal: outcome unknown is shown as such, with no resend offer, and nothing is resent (0.473741ms)
|
||||||
|
✔ terminal: text after Enter in the same input chunk starts the next message; it never joins the one submitted (0.344905ms)
|
||||||
|
✔ terminal: a paste-start marker split right after its ESC still opens the paste; the Enter inside it never submits (0.345713ms)
|
||||||
|
✔ terminal: Ctrl-T then Enter in one chunk is judged after the takeover, as if typed one key at a time (Filbert F2, #1507) (0.509785ms)
|
||||||
|
✔ terminal: input held behind Ctrl-T waits for that takeover while an earlier action is still pending (Darkwing T1 on #1522) (31.230161ms)
|
||||||
|
✔ terminal: a hold inside held input holds again, and once it is drained later input and Ctrl-C still reach the terminal (Darkwing note 1 on #1522) (0.535526ms)
|
||||||
|
✔ terminal: an action that throws still releases the input held behind it, in order, then rethrows (5.767751ms)
|
||||||
|
✔ terminal: after an action throws, later input still runs; input() puts the error in the status line (Filbert N1 on #1522) (0.427819ms)
|
||||||
|
✔ terminal: input() reports an error when it happens, so it never overwrites a later status; held input's promise doesn't carry the holder's error (Filbert N4 on #1522) (0.374154ms)
|
||||||
|
✔ terminal: invisible and bidi characters are made visible; head, status and notice lines stay one line (0.124458ms)
|
||||||
|
✔ every record these fixtures produced is a valid CHAT-01 record (E5: no record fails the schema) (253.515467ms)
|
||||||
|
✔ H1: two takeovers with the same expected generation: one wins, +1; the other refuses generation (294.243499ms)
|
||||||
|
✔ H2: the old controller's prompt after a takeover commits is refused with zero engine bytes (643.849856ms)
|
||||||
|
✔ H3: a takeover while a prompt holds the dispatch lock: written under the old actor, or refused; never both (1035.077009ms)
|
||||||
|
✔ H4: self-takeover is refused (220.514251ms)
|
||||||
|
✔ H9: Interrupt racing a prompt's dispatch: before the write, dispatch-refused and no-turn; after, §3 rules (513.050569ms)
|
||||||
|
✔ H10: Interrupt and force stop together: one stop chain, force stop supersedes (1044.31887ms)
|
||||||
|
✔ H10: an overlap during the pause before the abort: no abort, the stop ends uncertain (200.880629ms)
|
||||||
|
✔ H10: a no-turn Interrupt lifts only its own fence; admission stays closed under force stop, overlap or revocation (646.165518ms)
|
||||||
|
✔ H11: the controller disconnects mid-turn: work continues, the claim is unchanged, control stays put (299.259321ms)
|
||||||
|
✔ H12: an exact retry after reconnecting to the same incarnation returns the same receipt; one dispatch (204.891687ms)
|
||||||
|
✔ H13: a retry with the same request ID and different text is refused (210.61934ms)
|
||||||
|
✔ H14: late stdout from the old engine after a replacement is dropped by incarnation, counted, never rendered (608.700028ms)
|
||||||
|
✔ H15: a revoked connection's command is refused; the revocation fence holds (293.875373ms)
|
||||||
|
✔ H16: a second controller for the same session refuses already-active; the first is untouched (207.822911ms)
|
||||||
|
✔ H10: a second force stop while the first escalation runs refuses fenced; one escalation, and the claim records only the first stop's phases (907.20909ms)
|
||||||
|
✔ a force stop whose fence throws leaves no escalation flag behind, so the next force stop runs (Darkwing F2, #1507) (383.22996ms)
|
||||||
|
✔ H17: a confirmation reused, answered from another connection, or used after the stop changed is refused (780.261155ms)
|
||||||
|
✔ H18: two prompts before any native output: the second refuses busy; one engine write (314.578713ms)
|
||||||
|
✔ H19: the pipe fails mid-line under a large prompt: delivery-unknown transport-unknown, poisoned, no later write (492.689541ms)
|
||||||
|
✔ H19: the link itself never writes again after an unknown outcome, whoever calls it (0.638149ms)
|
||||||
|
✔ H19: the controller dies mid-write of a large line: after restart the outcome is unknown and nothing is resent (745.520193ms)
|
||||||
|
✔ H20: the line is written but the ack is lost when the controller dies: orphan, outcome unknown, nothing resent (614.542673ms)
|
||||||
|
✔ H21: a retry of the exact request with the old token after a crash is stale-incarnation; no second write (775.55878ms)
|
||||||
|
✔ H22: after H21 and a valid recovery, a new request with the new token is admitted (2983.804441ms)
|
||||||
|
✔ H23: requests pending at a restart are not resent; each shows outcome unknown (663.639159ms)
|
||||||
|
✔ no shim from this file's tests is left running for the after hook (29.149662ms)
|
||||||
|
✔ a plain conversation: catalogue row, one page, CHAT-01 records (6.901379ms)
|
||||||
|
✔ native entries map to blocks: tools, thinking, bash, notices, ids that do not fit (2.757561ms)
|
||||||
|
✔ F1: a malformed line is an unavailable part at its position, and reading continues (3.006657ms)
|
||||||
|
✔ F1: a missing parent stops the history with a notice that names the unreadable lines (4.500776ms)
|
||||||
|
✔ F1: an unreadable fork is never merged into another branch's history (1.961886ms)
|
||||||
|
✔ F1: a follow stays on its branch when the next entry's parent is unreadable (2.558189ms)
|
||||||
|
✔ F1: a file whose entries are all unreadable shows a notice per line (1.380572ms)
|
||||||
|
✔ F2: a truncated trailing line marks the view incomplete, not an error (2.109752ms)
|
||||||
|
✔ pagination: 100 parts, then the rest; parts concatenate to the whole branch (3.787822ms)
|
||||||
|
✔ F3: a replaced file (new inode) refuses old cursors with reconcile (4.931861ms)
|
||||||
|
✔ F4: a same-inode rewrite of the prefix refuses old cursors with reconcile (5.60544ms)
|
||||||
|
✔ F5: growth between pages keeps the epoch and the page stops at the pinned length (5.016363ms)
|
||||||
|
✔ F6: unknown, foreign and expired cursors refuse and leave the cursor usable (7.376863ms)
|
||||||
|
✔ F7: a symlinked file and a symlinked directory component are refused, never opened (9.418495ms)
|
||||||
|
✔ F8: a file swapped for a symlink after the catalogue is refused (2.608935ms)
|
||||||
|
✔ F9: registrations never add or redirect a root (2.387521ms)
|
||||||
|
✔ F10: a header cwd naming another project is refused (4.782722ms)
|
||||||
|
✔ F11: parentSession renders with a marker and the parent is never opened (0.704275ms)
|
||||||
|
✔ F12: two leaves: the default leaf is shown and the other branch reads alone (4.158477ms)
|
||||||
|
✔ F12: a follow refuses when an appended duplicate id changes the branch's earlier parts (2.217859ms)
|
||||||
|
✔ F12: a second root (Pi's resetLeaf) starts its own branch (1.398999ms)
|
||||||
|
✔ F13: compaction is a marker in place, then the retained content (0.772587ms)
|
||||||
|
✔ F14: long strings split into fragments and parts, reassemble exactly, and pages respect the byte cap (562.681896ms)
|
||||||
|
✔ fragments never cut a surrogate pair and keep an empty string (5.139319ms)
|
||||||
|
✔ F15: a Claude seat is an unsupported-harness placeholder whose directory is never read (1.62126ms)
|
||||||
|
✔ unknown conversations, empty files and non-Pi files refuse (3.23556ms)
|
||||||
|
✔ an unreadable file or root inside the roots is refused per row, not a failed catalogue (1.209457ms)
|
||||||
|
✔ a seat directory without search permission refuses that root, not the catalogue (2.226314ms)
|
||||||
|
✔ every page and cursor is a valid CHAT-01 record (787.451304ms)
|
||||||
|
✔ the engine pin holds for the installed package (2.048735ms)
|
||||||
|
✔ pinned Pi, sealed and without credentials, answers the controller's commands with the shapes the fake models (285.366263ms)
|
||||||
|
✔ sealed, pinned Pi ignores a trusted project's .pi resources; --approve past the seal would load them, and checkSeal refuses it (Filbert F2 on #1522) (579.211254ms)
|
||||||
|
✔ pinned Pi appends thinking_level_change at start when the branch lacks one, so the leaf moves (K8 then fails closed) (272.40564ms)
|
||||||
|
✔ N25: ordinary Interrupt reconciles; a non-empty queue_update in the window is O5 (577.591802ms)
|
||||||
|
✔ N1: an extension's follow-up queued after the fence is cleared before any abort; O5, Unknown (946.761723ms)
|
||||||
|
✔ N1: a follow-up queued before the fence is O5 at once; the Interrupt refuses fenced (282.830757ms)
|
||||||
|
✔ N2: with abort first, the fake runs the external item (the ordering guard has teeth) (21.001149ms)
|
||||||
|
✔ N3: the fence lands in preflight, preflight errors, no run: failed, No run, uncertain (399.29855ms)
|
||||||
|
✔ N4: the ack arrives after the first abort and a run starts: clear and abort again; Interrupted (230.660155ms)
|
||||||
|
✔ N5: an input handler takes the prompt: ack, no run, delivery-unknown handled-without-run (301.688325ms)
|
||||||
|
✔ N6: an extension queues between clear_queue and abort: O5 and O6, Unknown (545.551867ms)
|
||||||
|
✔ N7: clear_queue times out: no abort, nativeQueue unknown, force stop still ends it (1879.973217ms)
|
||||||
|
✔ N7: clear_queue answers an error: no abort, nativeQueue unknown, the link not poisoned (206.93602ms)
|
||||||
|
✔ N8: an extension prompt starts a run during Mosaic preflight; the losing settle is O3 (301.335987ms)
|
||||||
|
✔ N9: a run that started before the fence and ends aborted: failed interrupted, Interrupted (216.121148ms)
|
||||||
|
✔ N9: decision 34: a run that ends aborted with no stop in progress: aborted-without-stop, uncertain, outcome unknown (246.935251ms)
|
||||||
|
✔ N9: an aborted that lands after the fence but before any abort is written: aborted-without-stop, Unknown (338.923698ms)
|
||||||
|
✔ N10: fake conformance (32.84403ms)
|
||||||
|
✔ N11: the run fails before any user message_start: delivery-unknown ack-without-start, never failed (463.157846ms)
|
||||||
|
✔ N12: input that starts a run after the final empty clear is O1 and not part of the stop's proof (304.192068ms)
|
||||||
|
✔ N13: agent_start with no slot held is O1; a later prompt refuses with zero engine bytes (300.327975ms)
|
||||||
|
✔ N14: the run completes while clear_queue is in flight: finished, Completed first, uncertain (383.033758ms)
|
||||||
|
✔ N14: the run completes after the abort is written, before Pi applies it: finished, never relabelled (376.977901ms)
|
||||||
|
✔ N15: the fence lands in preflight, then an input handler takes it: handled-without-run, No run (372.577696ms)
|
||||||
|
✔ N16: Interrupt with no slot and no run refuses no-turn: no stop, no bytes, admission open (279.493416ms)
|
||||||
|
✔ N17: the run fails on its own during the exchange: failed, Failed on its own (661.735582ms)
|
||||||
|
✔ N18: no final assistant message_end, or a lost line: working stays working; before working, transport-unknown (826.188509ms)
|
||||||
|
✔ N19: a losing extension prompt settles inside the Mosaic run before its user message: O3, run-overlap (576.149478ms)
|
||||||
|
✔ N20: an extension triggerTurn during Mosaic preflight starts first; while streaming it queues with no signal (487.44522ms)
|
||||||
|
✔ N21: a losing settle after the receipt settled finished is O2; the receipt stays finished (249.137762ms)
|
||||||
|
✔ N22: an agent-level custom message is dropped by the clear with no signal; evidence names the seal (216.081275ms)
|
||||||
|
✔ N23: a nextTurn message survives clear and abort and attaches to the next prompt, with no signal (213.406251ms)
|
||||||
|
✔ 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 (229.431708ms)
|
||||||
|
✔ N24b: the seal covers the engine command and environment: config can't name either, the env is built from names, and a mutated command is refused at bind (235.869084ms)
|
||||||
|
ℹ tests 166
|
||||||
|
ℹ suites 0
|
||||||
|
ℹ pass 164
|
||||||
|
ℹ fail 2
|
||||||
|
ℹ cancelled 0
|
||||||
|
ℹ skipped 0
|
||||||
|
ℹ todo 0
|
||||||
|
ℹ duration_ms 60982.361112
|
||||||
|
|
||||||
|
✖ failing tests:
|
||||||
|
|
||||||
|
test at packages/conversation/tests/claim.test.mjs:714:1
|
||||||
|
✖ no shim from this file's tests is left running for the after hook (5035.26209ms)
|
||||||
|
AssertionError [ERR_ASSERTION]: a test left its shim running
|
||||||
|
+ actual - expected
|
||||||
|
|
||||||
|
+ [
|
||||||
|
+ {
|
||||||
|
+ pid: 3459119,
|
||||||
|
+ socket: '/home/jwoltje/darkwing-scratch/r47a/tmp/mut-afterleak/chat03-lQNQDH/f55/sock/shim-cede1ee324e5.sock',
|
||||||
|
+ unit: 'mosaic-chat-cede1ee324e5b263ef2671152'
|
||||||
|
+ }
|
||||||
|
+ ]
|
||||||
|
- []
|
||||||
|
|
||||||
|
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r47a/wt/packages/conversation/tests/claim.test.mjs:715:10)
|
||||||
|
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: [ { pid: 3459119, socket: '/home/jwoltje/darkwing-scratch/r47a/tmp/mut-afterleak/chat03-lQNQDH/f55/sock/shim-cede1ee324e5.sock', unit: 'mosaic-chat-cede1ee324e5b263ef2671152' } ],
|
||||||
|
expected: [],
|
||||||
|
operator: 'deepStrictEqual',
|
||||||
|
diff: 'simple'
|
||||||
|
}
|
||||||
|
|
||||||
|
test at packages/conversation/tests/claim.test.mjs:28:1
|
||||||
|
✖ /home/jwoltje/darkwing-scratch/r47a/wt/packages/conversation/tests/claim.test.mjs (5043.339958ms)
|
||||||
|
AssertionError [ERR_ASSERTION]: no shim outlives this file (#1533)
|
||||||
|
+ actual - expected
|
||||||
|
|
||||||
|
+ [
|
||||||
|
+ {
|
||||||
|
+ pid: 3459119,
|
||||||
|
+ socket: '/home/jwoltje/darkwing-scratch/r47a/tmp/mut-afterleak/chat03-lQNQDH/f55/sock/shim-cede1ee324e5.sock',
|
||||||
|
+ unit: 'mosaic-chat-cede1ee324e5b263ef2671152'
|
||||||
|
+ }
|
||||||
|
+ ]
|
||||||
|
- []
|
||||||
|
|
||||||
|
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r47a/wt/packages/conversation/tests/claim.test.mjs:32:10)
|
||||||
|
at async TestHook.run (node:internal/test_runner/test:1409:7)
|
||||||
|
at async Test.runHook (node:internal/test_runner/test:1289:9)
|
||||||
|
at async after (node:internal/test_runner/test:1342:9)
|
||||||
|
at async Test.run (node:internal/test_runner/test:1428:7) {
|
||||||
|
generatedMessage: false,
|
||||||
|
code: 'ERR_ASSERTION',
|
||||||
|
actual: [ { pid: 3459119, socket: '/home/jwoltje/darkwing-scratch/r47a/tmp/mut-afterleak/chat03-lQNQDH/f55/sock/shim-cede1ee324e5.sock', unit: 'mosaic-chat-cede1ee324e5b263ef2671152' } ],
|
||||||
|
expected: [],
|
||||||
|
operator: 'deepStrictEqual',
|
||||||
|
diff: 'simple'
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3459119 /usr/bin/node /home/jwoltje/darkwing-scratch/r47a/wt/packages/conversation/src/shim.mjs --socket /home/jwoltje/darkwing-scratch/r47a/tmp/mut-afterleak/chat03-lQNQDH/f55/sock/shim-cede1ee324e5.sock -- /usr/bin/node /home/jwoltje/darkwing-scratch/r47a/wt/packages/conversation/tests/fake-pi.mjs --mode rpc --no-extensions --no-prompt-templates --no-themes --no-approve --session /home/jwoltje/darkwing-scratch/r47a/tmp/mut-afterleak/chat03-lQNQDH/f55/proj/.pi/state/fixture-seat/sessions/s1.jsonl
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
✔ W1: two processes acquire the same pair at once; exactly one claim (197.049296ms)
|
||||||
|
✔ W1: two writers publish the same revision at once: one wins, the other gets null, the winner's record stays (30.2567ms)
|
||||||
|
✔ W1: a revision name appears only after its bytes are synced; before that, only a temp file exists (17.869101ms)
|
||||||
|
✔ W2: acquire while a claim is reserved or active refuses already-active (345.31181ms)
|
||||||
|
✔ W3: acquire while stopping, uncertain, or stopped without proof refuses unsafe-replacement (422.999536ms)
|
||||||
|
✔ W4: same session with another seat tuple, and the reverse, both refuse; a loser on the seat key closes it no-unit (331.462139ms)
|
||||||
|
✔ W4: a hard link of one session under another seat is the same session: the second controller refuses already-active and launches nothing (269.896862ms)
|
||||||
|
✔ W4: a copy of one session under another seat is the same session: the second controller refuses already-active and launches nothing (226.565456ms)
|
||||||
|
✔ W4: a session header ID that changes after construction refuses target; nothing is claimed or launched (2.459269ms)
|
||||||
|
✔ W5: SIGKILL between every publication barrier of acquire and transition; restart never finds two holders or a lost claim (11410.993529ms)
|
||||||
|
✔ W5: SIGKILL between every publication barrier of release; restart finishes or holds the release (33468.852499ms)
|
||||||
|
✔ W6: controller killed mid-turn while the engine lives; restart is uncertain, no launch, prompts refuse (278.035112ms)
|
||||||
|
✔ W12: a live owner paused with SIGSTOP; a second controller refuses already-active and changes nothing (156.636284ms)
|
||||||
|
✔ W13: crash after the engine spawns, before active; restart finds the live unit: uncertain, no second spawn, force stop only (648.670807ms)
|
||||||
|
✔ W14: crash after reservation, before the spawn marker: stopped with a no-unit observation; the pair is free (282.454964ms)
|
||||||
|
✔ W20: crash after the spawn marker, scope collected; uncertain in both runs, the marker is copied, no launch until a boot proof (359.105989ms)
|
||||||
|
✔ W15: crash between the two keys during release; restart finishes it under the same claim ID (98.174892ms)
|
||||||
|
✔ W7: recorded boot ID differs on the same machine: stopped with a boot proof; open tool calls become uncertain (200.317068ms)
|
||||||
|
✔ W8: resume after a proven stop with the same pins: new claim ID, generation +1, same conversation, branch and leaf (257.96069ms)
|
||||||
|
✔ W9: resume with a changed binary, argv digest, branch or leaf is refused and the claim is unchanged (719.726591ms)
|
||||||
|
✔ W11: the controller writes no session file; only the fake engine's own appends appear (173.907622ms)
|
||||||
|
✔ W16: a highest revision that won't parse holds the pair uncertain; the older stopped revision is not reused (88.540111ms)
|
||||||
|
✔ W17: a claim root copied from another host refuses foreign-host and promotes nothing (69.986196ms)
|
||||||
|
✔ G1: a session path or claim root under .pi/state, ~/.claude, the data root or a registration refuses at construction (3.40838ms)
|
||||||
|
✔ G2: a symlink inside the fixture root to a live session file is refused by the real-path check (1.037676ms)
|
||||||
|
✔ G3: a fixture path swapped for a live path after construction is refused at bind (1.732583ms)
|
||||||
|
✔ no shim from this file's tests is left running for the after hook (25.039017ms)
|
||||||
|
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2950.569631ms)
|
||||||
|
✔ K2: K1 on the process-group fallback ends uncertain, never stopped (451.497915ms)
|
||||||
|
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2781.966793ms)
|
||||||
|
✔ K4: two engines; force stop one; the other survives by independent observation (4839.738336ms)
|
||||||
|
✔ K5: a stop during a tool call leaves the effect uncertain, and it is shown (2514.094444ms)
|
||||||
|
✔ K12: a member forking in a loop: the freeze stops it, enumeration is complete, populated 0 after cgroup.kill (2517.229628ms)
|
||||||
|
✔ K13: a member writing its pid into another cgroup is refused by the namespace; the kill is complete (2437.649908ms)
|
||||||
|
✔ K15: the shim gone, engine/cgroup.events unreadable, or the engine cgroup missing: evidence unavailable, not empty; uncertain (5021.280751ms)
|
||||||
|
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (959.412745ms)
|
||||||
|
✔ K11: controller killed after the confirmation is recorded, before TERM: restart checks the invocation ID and re-runs from TERM for the same stop (554.748075ms)
|
||||||
|
✔ K14: a unit with the recorded name but another invocation ID: evidence unavailable, no signals, uncertain (598.302911ms)
|
||||||
|
✔ K6: recover without proof, without confirmation, or with changed pins is refused (646.325558ms)
|
||||||
|
✔ K7: recover after proof, then launch: new claim and execution, generation +1, same leaf; the cancelled prompt is not replayed (328.297709ms)
|
||||||
|
✔ K8: an engine that loads another leaf on resume is refused before admission; it stays claimed until a proven stop (524.693662ms)
|
||||||
|
✔ K9: an interrupt that never settles stays uncertain; force stop stays available; takeover is refused while fenced (3271.735696ms)
|
||||||
|
✔ K16: a claim from another machine ID refuses foreign-host; no boot proof is issued (33.382515ms)
|
||||||
|
✔ K17: two launcher calls with one eligibility record: one launch, the other refuses, no second engine (360.808345ms)
|
||||||
|
✔ K18: the leaf changes after eligibility: launch refused; the reservation stays until released with proof (361.996968ms)
|
||||||
|
✔ K19: a scope launched with only the engine environment still reaches the user manager; the engine sees no other names (82.528117ms)
|
||||||
|
✔ no shim from this file's tests is left running for the after hook (28.074449ms)
|
||||||
|
✔ S1: `/goal x`, with leading spaces or a tab, refuses text-policy at admission; zero engine bytes (194.578162ms)
|
||||||
|
✔ S2: every prefix pinned Pi interprets is refused, from the list the code uses; the rest reach the engine exactly (188.929563ms)
|
||||||
|
✔ S3: `/goal` on the second line is pinned from the source: Pi checks only index 0, so it is admitted and sent exactly (186.630794ms)
|
||||||
|
✔ S4: a `/` left in the composer is cleared when control transfers and returns; the next submit sends only the new text (310.239901ms)
|
||||||
|
✔ S5: an observer terminal gets a paste then Enter, as send-message.sh does: not admitted: controller, nothing sent (250.607896ms)
|
||||||
|
✔ S6: a mediated-shaped registration (no tmux) passed to the board's replyToRow: 409 no tmux session; exec never runs (0.453839ms)
|
||||||
|
✔ S7: ESC, bracketed-paste markers and U+2028/U+2029 travel as one JSON string; the engine receives the exact text in one record (325.324105ms)
|
||||||
|
✔ P3: a Pi confirm, select, input or editor dialog is shown disabled with a reason and never answered (366.090081ms)
|
||||||
|
✔ E1: send, ack, user, toolCall, toolResult, final answer: shown once, no refresh, draft and reading position kept (225.087215ms)
|
||||||
|
✔ E2: U+2028, U+2029 inside JSON strings and CRLF line ends each parse as one record, on the splitter and through the controller (192.31515ms)
|
||||||
|
✔ E3: a multipart final, two blocks, null request correlation and duplicate delivery (209.298056ms)
|
||||||
|
✔ 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 (218.342388ms)
|
||||||
|
✔ E4: a gap or a new epoch also reconciles; nothing is concatenated across a gap (10.97722ms)
|
||||||
|
✔ E5: an unknown native event gives no client event; evidence records its type and bytes; the terminal count goes up (207.199358ms)
|
||||||
|
✔ E6: a tool result delayed across a pause and a reconnect is reconciled without a manual refresh (226.432261ms)
|
||||||
|
✔ E7: the terminal renders the same stream as the library client, as observer and then as controller, and submits only as controller (251.463208ms)
|
||||||
|
✔ terminal: engine control characters are made visible; a lost connection refuses submit (201.15355ms)
|
||||||
|
✔ terminal: outcome unknown is shown as such, with no resend offer, and nothing is resent (0.426921ms)
|
||||||
|
✔ terminal: text after Enter in the same input chunk starts the next message; it never joins the one submitted (0.272057ms)
|
||||||
|
✔ terminal: a paste-start marker split right after its ESC still opens the paste; the Enter inside it never submits (0.326321ms)
|
||||||
|
✔ terminal: Ctrl-T then Enter in one chunk is judged after the takeover, as if typed one key at a time (Filbert F2, #1507) (0.493318ms)
|
||||||
|
✔ terminal: input held behind Ctrl-T waits for that takeover while an earlier action is still pending (Darkwing T1 on #1522) (30.929698ms)
|
||||||
|
✔ terminal: a hold inside held input holds again, and once it is drained later input and Ctrl-C still reach the terminal (Darkwing note 1 on #1522) (0.489135ms)
|
||||||
|
✔ terminal: an action that throws still releases the input held behind it, in order, then rethrows (5.824779ms)
|
||||||
|
✔ terminal: after an action throws, later input still runs; input() puts the error in the status line (Filbert N1 on #1522) (0.428027ms)
|
||||||
|
✔ terminal: input() reports an error when it happens, so it never overwrites a later status; held input's promise doesn't carry the holder's error (Filbert N4 on #1522) (0.351569ms)
|
||||||
|
✔ terminal: invisible and bidi characters are made visible; head, status and notice lines stay one line (0.110321ms)
|
||||||
|
✔ every record these fixtures produced is a valid CHAT-01 record (E5: no record fails the schema) (237.286641ms)
|
||||||
|
✔ H1: two takeovers with the same expected generation: one wins, +1; the other refuses generation (230.878494ms)
|
||||||
|
✔ H2: the old controller's prompt after a takeover commits is refused with zero engine bytes (277.834974ms)
|
||||||
|
✔ H3: a takeover while a prompt holds the dispatch lock: written under the old actor, or refused; never both (1060.632333ms)
|
||||||
|
✔ H4: self-takeover is refused (224.791538ms)
|
||||||
|
✔ H9: Interrupt racing a prompt's dispatch: before the write, dispatch-refused and no-turn; after, §3 rules (446.212191ms)
|
||||||
|
✔ H10: Interrupt and force stop together: one stop chain, force stop supersedes (1052.06213ms)
|
||||||
|
✔ H10: an overlap during the pause before the abort: no abort, the stop ends uncertain (231.205097ms)
|
||||||
|
✔ H10: a no-turn Interrupt lifts only its own fence; admission stays closed under force stop, overlap or revocation (660.604274ms)
|
||||||
|
✔ H11: the controller disconnects mid-turn: work continues, the claim is unchanged, control stays put (278.293002ms)
|
||||||
|
✔ H12: an exact retry after reconnecting to the same incarnation returns the same receipt; one dispatch (207.032141ms)
|
||||||
|
✔ H13: a retry with the same request ID and different text is refused (196.251993ms)
|
||||||
|
✔ H14: late stdout from the old engine after a replacement is dropped by incarnation, counted, never rendered (727.62471ms)
|
||||||
|
✔ H15: a revoked connection's command is refused; the revocation fence holds (370.396901ms)
|
||||||
|
✔ H16: a second controller for the same session refuses already-active; the first is untouched (192.209941ms)
|
||||||
|
✔ H10: a second force stop while the first escalation runs refuses fenced; one escalation, and the claim records only the first stop's phases (876.947035ms)
|
||||||
|
✔ a force stop whose fence throws leaves no escalation flag behind, so the next force stop runs (Darkwing F2, #1507) (321.051889ms)
|
||||||
|
✔ H17: a confirmation reused, answered from another connection, or used after the stop changed is refused (758.666111ms)
|
||||||
|
✔ H18: two prompts before any native output: the second refuses busy; one engine write (360.368072ms)
|
||||||
|
✔ H19: the pipe fails mid-line under a large prompt: delivery-unknown transport-unknown, poisoned, no later write (335.060909ms)
|
||||||
|
✔ H19: the link itself never writes again after an unknown outcome, whoever calls it (0.612822ms)
|
||||||
|
✔ H19: the controller dies mid-write of a large line: after restart the outcome is unknown and nothing is resent (661.027296ms)
|
||||||
|
✔ H20: the line is written but the ack is lost when the controller dies: orphan, outcome unknown, nothing resent (566.594343ms)
|
||||||
|
✔ H21: a retry of the exact request with the old token after a crash is stale-incarnation; no second write (708.560077ms)
|
||||||
|
✔ H22: after H21 and a valid recovery, a new request with the new token is admitted (2938.033537ms)
|
||||||
|
✔ H23: requests pending at a restart are not resent; each shows outcome unknown (655.993875ms)
|
||||||
|
✖ no shim from this file's tests is left running for the after hook (5056.941433ms)
|
||||||
|
✔ a plain conversation: catalogue row, one page, CHAT-01 records (9.45792ms)
|
||||||
|
✔ native entries map to blocks: tools, thinking, bash, notices, ids that do not fit (2.653489ms)
|
||||||
|
✔ F1: a malformed line is an unavailable part at its position, and reading continues (2.771711ms)
|
||||||
|
✔ F1: a missing parent stops the history with a notice that names the unreadable lines (3.721783ms)
|
||||||
|
✔ F1: an unreadable fork is never merged into another branch's history (1.744085ms)
|
||||||
|
✔ F1: a follow stays on its branch when the next entry's parent is unreadable (2.950975ms)
|
||||||
|
✔ F1: a file whose entries are all unreadable shows a notice per line (1.033733ms)
|
||||||
|
✔ F2: a truncated trailing line marks the view incomplete, not an error (1.299469ms)
|
||||||
|
✔ pagination: 100 parts, then the rest; parts concatenate to the whole branch (4.631965ms)
|
||||||
|
✔ F3: a replaced file (new inode) refuses old cursors with reconcile (4.083972ms)
|
||||||
|
✔ F4: a same-inode rewrite of the prefix refuses old cursors with reconcile (4.777326ms)
|
||||||
|
✔ F5: growth between pages keeps the epoch and the page stops at the pinned length (4.450746ms)
|
||||||
|
✔ F6: unknown, foreign and expired cursors refuse and leave the cursor usable (7.504334ms)
|
||||||
|
✔ F7: a symlinked file and a symlinked directory component are refused, never opened (11.504271ms)
|
||||||
|
✔ F8: a file swapped for a symlink after the catalogue is refused (2.590604ms)
|
||||||
|
✔ F9: registrations never add or redirect a root (2.60161ms)
|
||||||
|
✔ F10: a header cwd naming another project is refused (5.312424ms)
|
||||||
|
✔ F11: parentSession renders with a marker and the parent is never opened (0.833801ms)
|
||||||
|
✔ F12: two leaves: the default leaf is shown and the other branch reads alone (4.576443ms)
|
||||||
|
✔ F12: a follow refuses when an appended duplicate id changes the branch's earlier parts (2.031185ms)
|
||||||
|
✔ F12: a second root (Pi's resetLeaf) starts its own branch (1.540383ms)
|
||||||
|
✔ F13: compaction is a marker in place, then the retained content (1.055277ms)
|
||||||
|
✔ F14: long strings split into fragments and parts, reassemble exactly, and pages respect the byte cap (495.761506ms)
|
||||||
|
✔ fragments never cut a surrogate pair and keep an empty string (4.530977ms)
|
||||||
|
✔ F15: a Claude seat is an unsupported-harness placeholder whose directory is never read (1.508889ms)
|
||||||
|
✔ unknown conversations, empty files and non-Pi files refuse (2.332009ms)
|
||||||
|
✔ an unreadable file or root inside the roots is refused per row, not a failed catalogue (1.215739ms)
|
||||||
|
✔ a seat directory without search permission refuses that root, not the catalogue (2.130551ms)
|
||||||
|
✔ every page and cursor is a valid CHAT-01 record (791.701227ms)
|
||||||
|
✔ the engine pin holds for the installed package (2.128908ms)
|
||||||
|
✔ pinned Pi, sealed and without credentials, answers the controller's commands with the shapes the fake models (263.839834ms)
|
||||||
|
✔ sealed, pinned Pi ignores a trusted project's .pi resources; --approve past the seal would load them, and checkSeal refuses it (Filbert F2 on #1522) (503.901361ms)
|
||||||
|
✔ pinned Pi appends thinking_level_change at start when the branch lacks one, so the leaf moves (K8 then fails closed) (264.144541ms)
|
||||||
|
✔ N25: ordinary Interrupt reconciles; a non-empty queue_update in the window is O5 (460.491405ms)
|
||||||
|
✔ N1: an extension's follow-up queued after the fence is cleared before any abort; O5, Unknown (459.366987ms)
|
||||||
|
✔ N1: a follow-up queued before the fence is O5 at once; the Interrupt refuses fenced (331.634788ms)
|
||||||
|
✔ N2: with abort first, the fake runs the external item (the ordering guard has teeth) (20.888495ms)
|
||||||
|
✔ N3: the fence lands in preflight, preflight errors, no run: failed, No run, uncertain (490.102814ms)
|
||||||
|
✔ N4: the ack arrives after the first abort and a run starts: clear and abort again; Interrupted (213.93758ms)
|
||||||
|
✔ N5: an input handler takes the prompt: ack, no run, delivery-unknown handled-without-run (293.015714ms)
|
||||||
|
✔ N6: an extension queues between clear_queue and abort: O5 and O6, Unknown (477.191806ms)
|
||||||
|
✔ N7: clear_queue times out: no abort, nativeQueue unknown, force stop still ends it (1888.727614ms)
|
||||||
|
✔ N7: clear_queue answers an error: no abort, nativeQueue unknown, the link not poisoned (203.998308ms)
|
||||||
|
✔ N8: an extension prompt starts a run during Mosaic preflight; the losing settle is O3 (316.971117ms)
|
||||||
|
✔ N9: a run that started before the fence and ends aborted: failed interrupted, Interrupted (282.097455ms)
|
||||||
|
✔ N9: decision 34: a run that ends aborted with no stop in progress: aborted-without-stop, uncertain, outcome unknown (256.140276ms)
|
||||||
|
✔ N9: an aborted that lands after the fence but before any abort is written: aborted-without-stop, Unknown (477.083588ms)
|
||||||
|
✔ N10: fake conformance (32.934542ms)
|
||||||
|
✔ N11: the run fails before any user message_start: delivery-unknown ack-without-start, never failed (450.496013ms)
|
||||||
|
✔ N12: input that starts a run after the final empty clear is O1 and not part of the stop's proof (281.090048ms)
|
||||||
|
✔ N13: agent_start with no slot held is O1; a later prompt refuses with zero engine bytes (298.332038ms)
|
||||||
|
✔ N14: the run completes while clear_queue is in flight: finished, Completed first, uncertain (355.356665ms)
|
||||||
|
✔ N14: the run completes after the abort is written, before Pi applies it: finished, never relabelled (358.037348ms)
|
||||||
|
✔ N15: the fence lands in preflight, then an input handler takes it: handled-without-run, No run (524.641792ms)
|
||||||
|
✔ N16: Interrupt with no slot and no run refuses no-turn: no stop, no bytes, admission open (195.587322ms)
|
||||||
|
✔ N17: the run fails on its own during the exchange: failed, Failed on its own (285.42895ms)
|
||||||
|
✔ N18: no final assistant message_end, or a lost line: working stays working; before working, transport-unknown (632.572932ms)
|
||||||
|
✔ N19: a losing extension prompt settles inside the Mosaic run before its user message: O3, run-overlap (497.638133ms)
|
||||||
|
✔ N20: an extension triggerTurn during Mosaic preflight starts first; while streaming it queues with no signal (464.937522ms)
|
||||||
|
✔ N21: a losing settle after the receipt settled finished is O2; the receipt stays finished (222.460525ms)
|
||||||
|
✔ N22: an agent-level custom message is dropped by the clear with no signal; evidence names the seal (153.565035ms)
|
||||||
|
✔ N23: a nextTurn message survives clear and abort and attaches to the next prompt, with no signal (166.396372ms)
|
||||||
|
✔ 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 (224.677414ms)
|
||||||
|
✔ N24b: the seal covers the engine command and environment: config can't name either, the env is built from names, and a mutated command is refused at bind (248.72541ms)
|
||||||
|
ℹ tests 165
|
||||||
|
ℹ suites 0
|
||||||
|
ℹ pass 164
|
||||||
|
ℹ fail 1
|
||||||
|
ℹ cancelled 0
|
||||||
|
ℹ skipped 0
|
||||||
|
ℹ todo 0
|
||||||
|
ℹ duration_ms 53159.579855
|
||||||
|
|
||||||
|
✖ failing tests:
|
||||||
|
|
||||||
|
test at packages/conversation/tests/races.test.mjs:902:1
|
||||||
|
✖ no shim from this file's tests is left running for the after hook (5056.941433ms)
|
||||||
|
AssertionError [ERR_ASSERTION]: a test left its shim running
|
||||||
|
+ actual - expected
|
||||||
|
|
||||||
|
+ [
|
||||||
|
+ {
|
||||||
|
+ pid: 3568275,
|
||||||
|
+ socket: '/home/jwoltje/darkwing-scratch/r47a/tmp/mut-h22noreap/chat03-9zq6wq/f33/sock/shim-cfdfa3433345.sock',
|
||||||
|
+ unit: 'mosaic-chat-cfdfa3433345b9db00c5d3051'
|
||||||
|
+ },
|
||||||
|
+ {
|
||||||
|
+ pid: 3568508,
|
||||||
|
+ socket: '/home/jwoltje/darkwing-scratch/r47a/tmp/mut-h22noreap/chat03-9zq6wq/f33/sock/shim-cd552eea1f4f.sock',
|
||||||
|
+ unit: 'mosaic-chat-cd552eea1f4f3842f83cdde65'
|
||||||
|
+ }
|
||||||
|
+ ]
|
||||||
|
- []
|
||||||
|
|
||||||
|
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r47a/wt/packages/conversation/tests/races.test.mjs:903:10)
|
||||||
|
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: [ { pid: 3568275, socket: '/home/jwoltje/darkwing-scratch/r47a/tmp/mut-h22noreap/chat03-9zq6wq/f33/sock/shim-cfdfa3433345.sock', unit: 'mosaic-chat-cfdfa3433345b9db00c5d3051' }, { pid: 3568508, socket: '/home/jwoltje/darkwing-scratch/r47a/tmp/mut-h22noreap/chat03-9zq6wq/f33/sock/shim-cd552eea1f4f.sock', unit: 'mosaic-chat-cd552eea1f4f3842f83cdde65' } ],
|
||||||
|
expected: [],
|
||||||
|
operator: 'deepStrictEqual',
|
||||||
|
diff: 'simple'
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user