Skip to content

Commit cdab35c

Browse files
authored
Merge pull request #14 from edgehero/plan-exec/2026-07-18-run-history-logs
Durable per-job run history + logs (read model for the panel)
2 parents d6c5fce + dcd12a4 commit cdab35c

24 files changed

Lines changed: 1704 additions & 37 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ PI_CONCURRENCY=3 # how many jobs run in parallel
2121
VALKEY_URL=redis://127.0.0.1:6379
2222
PI_JOB_IMAGE=pi-job:latest # the image you built: docker build -f image/Dockerfile -t pi-job:latest .
2323
# PI_JOBS_DIR= # where per-job /job inputs live (default: your OS temp dir)
24+
# PI_LOGS_DIR= # where per-job status records (and optional raw logs) land (default: OS temp /pi-dispatch/logs)
25+
# PI_CAPTURE_JOB_LOGS= # default 0; set 1 to ALSO write raw container output to logs/<jobId>.log -- PII-bearing (issue/comment text), host-only (never mounted into the container), off by default (opt-in)
26+
# PI_LOG_RETENTION_DAYS= # default 30; boot-time prune of logs older than N days; 0 = keep forever
2427
# PI_SCHEDULES_FILE= # ABSOLUTE path to schedules.json; unset disables cron (a relative path resolves against the worker's WorkingDirectory)
2528
PI_SCHEDULER_STALL_MAX=2 # tear down a scheduler after N consecutive stalls (money backstop)
2629

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"generated": "2026-07-18T00:00:00Z",
3+
"planSlug": "2026-07-18-run-history-logs-plan",
4+
"exitCode": 0,
5+
"reqStates": {}
6+
}

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,16 @@ the worker handles and drains gracefully. Task Scheduler is a weaker fallback
152152
hard kill, giving the worker no chance to drain; a job killed mid-flight leaves a stray container that the
153153
worker's **boot reaper** clears on the next start, rather than draining cleanly.
154154

155+
## Run history
156+
157+
The worker keeps a durable, per-job record under `PI_LOGS_DIR` (default: your OS temp dir,
158+
`.../pi-dispatch/logs`). Every job writes an id-only status record `logs/<jobId>.json` — stable ids only
159+
(the delivery GUID, `repo#issue`), never issue or comment text. Set `PI_CAPTURE_JOB_LOGS=1` to **also**
160+
capture the container's raw stdout/stderr to `logs/<jobId>.log`; this is **opt-in and off by default**,
161+
because that raw stream can contain issue and comment text (PII). Both files stay host-side, are never
162+
mounted into the job container, and are gitignored. A boot-time sweep prunes anything older than
163+
`PI_LOG_RETENTION_DAYS` (default 30; `0` keeps them forever).
164+
155165
## Scheduling recurring jobs
156166

157167
A cron schedule is a trigger, not a new job kind: each entry runs a local folder through a flow on a cron

SECURITY.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ Stated openly rather than discovered later:
118118
- **The provider API key is broad.** Unlike the GitHub token it cannot be meaningfully scoped per job —
119119
the agent needs it to function. It is the one broad secret inside the container. **Set a spend limit
120120
on it.**
121+
- **Captured job logs can contain issue and comment text (PII).** By default the worker writes only an
122+
id-only status record per job — `logs/<jobId>.json`, keyed on stable ids (the delivery GUID,
123+
`repo#issue`) and never on issue or comment bodies. With `PI_CAPTURE_JOB_LOGS=1` (opt-in, **off by
124+
default**) it also tees the container's raw stdout/stderr to `logs/<jobId>.log`, and that stream **can**
125+
carry issue/comment text. Both live host-side under `PI_LOGS_DIR`, are **never mounted into the job
126+
container**, and are **gitignored**; a boot-time sweep prunes them (`PI_LOG_RETENTION_DAYS`, `0` = keep
127+
forever). Leave capture off unless you need it, and treat the log directory as personal data while it is on.
121128
- **Prompt injection is not prevented, only bounded.** Untrusted text is kept out of the trusted region
122129
of the prompt by *placement*, not by filtering — content-filtering natural language is not a security
123130
boundary and this project does not pretend otherwise. The bound is the container, the scoped token,

deploy/com.pi-dispatch.worker.plist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
2323
One worker per host (DES-CONCURRENCY-3): parallelism is PI_CONCURRENCY inside the one process.
2424
Requires the AOF-enabled Valkey from deploy/docker-compose.yml.
25+
26+
PI_LOGS_DIR (run-history records; default OS-temp /pi-dispatch/logs) is created and written by the
27+
worker at boot, so it must be writable by the account the daemon runs as. Set via `.env` (the wrapper),
28+
not a plist change; its default is distinct from the StandardOutPath worker.out.log below.
2529
-->
2630
<plist version="1.0">
2731
<dict>

deploy/nssm-install.cmd

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ REM One worker per host (DES-CONCURRENCY-3): parallelism is PI_CONCURRENCY insid
1919
REM multiple services. Requires the AOF-enabled Valkey from deploy/docker-compose.yml.
2020
REM
2121
REM Per-host PLACEHOLDERS: set SERVICE / REPO / LOGDIR below for your host before running.
22+
REM
23+
REM PI_LOGS_DIR (run-history records; default OS-temp \pi-dispatch\logs) is created and written by the
24+
REM worker at boot, so it must be writable by the service account. Set via `.env` (the wrapper), not a
25+
REM change here; its default avoids colliding with the nssm LOGDIR worker.out log set below.
2226

2327
setlocal
2428

deploy/worker.service

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
# Env vars come from your `.env` (see `.env.example`) via EnvironmentFile -- never commit real secrets.
1313
# WorkingDirectory / EnvironmentFile / User / node path below are PLACEHOLDERS: set them to wherever
1414
# you cloned the repo and whoever owns it.
15+
#
16+
# PI_LOGS_DIR (run-history records; default OS-temp /pi-dispatch/logs) is created and written by the
17+
# worker at boot, so it must be writable by User= (pi) -- do NOT pre-create it as root, or the non-root
18+
# worker hits EACCES at boot. Set via `.env` (EnvironmentFile), no unit change needed; the default path
19+
# avoids colliding with the daemon's own logs/worker.out.log.
1520

1621
[Unit]
1722
Description=pi-dispatch worker (drains the job queue on the host; launches job containers via docker)

image/runner/test/compose.test.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from "node:assert/strict";
2+
import { readFileSync } from "node:fs";
23
import { test } from "node:test";
34
import { captureTerminal, decideExit, EXIT_COMPLETED, EXIT_INFRA, EXIT_POLICY } from "../src/outcome.mjs";
45

@@ -43,3 +44,33 @@ test("decideExit: no terminal message and no abort is infra, not success", () =>
4344
// Absence of evidence that the agent ran is not success.
4445
assert.equal(decideExit({ budgetAborted: false, terminal: undefined }).code, EXIT_INFRA);
4546
});
47+
48+
test("decideExit's non-abort branch carries NO turns of its own -- the premise the source-guard rests on", () => {
49+
// WHY the guard below exists: on the success path, `turns` reaches the exit log SOLELY from
50+
// run-job.mjs's `log("exit", { ...outcome, turns: budget.state.turns })` spread. decideExit's
51+
// non-abort branch returns classifyStopReason, which has no `turns` field (only the budget-abort
52+
// branch carries one -- see "a blown budget wins" above). So if that spread ever dropped `turns`,
53+
// nothing in outcome.mjs would put it back, and the worker's parseExitTurns (which requires a
54+
// numeric `turns` on the exit line) would silently read null on every completed run.
55+
const outcome = decideExit({ budgetAborted: false, terminal: { stopReason: "stop" } });
56+
assert.equal(outcome.code, EXIT_COMPLETED);
57+
assert.equal(outcome.turns, undefined, "the non-abort branch of decideExit carries no turns of its own");
58+
});
59+
60+
test("run-job.mjs staples `turns` onto the success-path exit line -- worker parseExitTurns depends on it", () => {
61+
// A stdout-capture test would EXECUTE the runner (main() self-runs on import and `log` is
62+
// unexported), so this guards the worker<->runner contract against the source instead -- the same
63+
// tactic worker/test/wiring.test.mjs uses for wiring it cannot exercise at runtime. The regex
64+
// matches a `log("exit", { ... turns: ... })` call whose object literal carries a `turns:` key
65+
// before its closing brace: specific enough that dropping `turns` from the success spread fails
66+
// it, tolerant of whitespace and property reordering.
67+
const src = readFileSync(new URL("../run-job.mjs", import.meta.url), "utf8");
68+
assert.match(
69+
src,
70+
/log\("exit",\s*\{[^}]*turns:/,
71+
"run-job.mjs exit line must carry turns -- worker parseExitTurns depends on it",
72+
);
73+
// The catch-path exit line (classifyThrow, a preflight throw) legitimately OMITS turns: no budget
74+
// exists when the agent loop never started. The regex above matches the success call and does not
75+
// require every exit line to carry turns, so that omission is allowed by design.
76+
});

specs/design.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,33 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`).
137137
- **Reference** (no authority): `docs.bullmq.io/guide/rate-limiting`, `/guide/workers/pausing-queues`,
138138
`/guide/redis-tm-compatibility`.
139139

140+
## DES-RUN-HISTORY-FLAT-FILES-NO-DB
141+
142+
- **Decision**: The per-job run history is a flat `node:fs` sidecar keyed by job id — an id-only status
143+
record at `logs/<jobId>.json` (written with `fs.writeFileSync`) plus an optional append-only
144+
`logs/<jobId>.log` of raw container output (`fs.createWriteStream`). No database, no logging framework.
145+
Retention is a boot-time age sweep (`makeLogReaper`, window `PI_LOG_RETENTION_DAYS`), not a rotation
146+
library.
147+
- **Why**: The record must **outlive the queue entry**. BullMQ evicts completed and failed jobs by age
148+
(`removeOnComplete` / `removeOnFail`), so the retained job cannot be the durable store — and it never
149+
carries `exitCode`, `turns`, or `budgetReserved` in the first place. The sidecar is justified precisely
150+
by what BullMQ lacks: a record that survives eviction and holds the run's outcome fields. Those records
151+
are immutable, filename-keyed, and never queried across each other, so a store with query power earns
152+
nothing. This is `DES-QUEUE-BULLMQ-OVER-CUSTOM`'s library-first ethos one file down, and the design
153+
home for the `document-build-decision` rule that the sidecar's inline `Custom:` comment cites.
154+
- **Rejected**:
155+
- *Querying BullMQ's retained job instead of a sidecar* — BullMQ evicts by age, so it is not the
156+
*durable* store, and it never carries `exitCode` / `turns` / `budgetReserved`. It cannot be the
157+
record.
158+
- *An embedded database (lowdb / better-sqlite3) or a structured-logging library (pino / winston)*
159+
the records are immutable, filename-keyed, with no cross-record query in scope, so neither earns its
160+
keep. A DB adds a native build (the ARM / musl / glibc pain the job image exists to avoid) and a
161+
second retention authority beside the reaper for zero query benefit; a logging library brings its own
162+
rotation as that same second authority. Both violate the deliberate "no database" thinness
163+
(`interfaces.md` preamble) and the library-first ethos — the same reasoning as
164+
`DES-QUEUE-BULLMQ-OVER-CUSTOM`.
165+
- **Traces to**: `DES-QUEUE-BULLMQ-OVER-CUSTOM`; implemented in `worker/src/run-history.mjs`.
166+
140167
## DES-PERSONA-VIA-APPEND-SYSTEM-MD
141168

142169
- **Decision**: Bake the persona into the image at `~/.pi/agent/APPEND_SYSTEM.md`, **and** pass per-flow
@@ -531,3 +558,4 @@ a tunnel.
531558
| 2026-07-15 | Initial. Extracted from `DESIGN.md` v0.1 (2026-07-14, local, uncommitted) §2, §3, §4, §5, §9, §11. That document recorded "50 claims adversarially verified: 48 confirmed, 2 refuted" — **verified against documentation**. Source-verification at `earendil-works/pi @ 5e336cf` subsequently corrected ~7 points. `DES-PERSONA-VIA-APPEND-SYSTEM-MD` is materially rewritten: the source doc's decisions #1 and #2 were mutually exclusive as written. `DES-NAME-KEEP-PI-DISPATCH` is new. `pi-harness` and `pi-sentry` were absent from the source doc's alternatives and are added. §5.7's "caches roll at midnight" caveat is **dropped** — 0.80.7 removed the date from the default system prompt. |
532559
| 2026-07-15 | An admin panel and cross-platform (Windows/macOS/Linux + Docker) added to scope. Two new decisions and one **security correction**. `DES-PANEL-SEPARATE-FROM-RECEIVER`: the source doc mounted Bull Board on the receiver — defensible for a read-only dashboard, **not** once the same surface sets the model and rewrites flows, because the receiver is the one process that must be internet-reachable. The panel and the receiver have opposite reachability requirements and cannot share a port. `DES-FLOWS-ARE-DATA-PERSONA-IS-CODE`: the panel requirement collided with keeping flows as reviewed repo markdown; resolved by observing that one file was carrying two jobs — hard rules need immutability, task recipes need editability. Architecture diagram and repo layout updated; the public edge is now drawn explicitly. Build order extended with panel and deploy. |
533560
| 2026-07-16 | **Resolved a spec/code contradiction.** `DES-WORKER-ON-HOST` added and `DES-JOB-FILES-VIA-VOLUME-SUBPATH` marked SUPERSEDED: the worker runs on the host (the `docker` CLI translates host paths, the daemon does not, and the VM prefix moved between Docker Desktop versions; local-folder jobs also *require* a host bind mount a named volume cannot give). The committed spec had rejected worker-on-host while the code already did it -- caught by a spec-conformance scan. `DES-CLI-TRIGGER-FOR-LOCAL` added: the CLI producer was built (user-directed) but unspecified; recorded with the check that `CONST-BUDGET-BEFORE-TOKENS` still holds because the cap is enforced in the processor, not the trigger. Repo-layout `deploy/` line corrected (compose runs Valkey only). |
561+
| 2026-07-21 | Added DES-RUN-HISTORY-FLAT-FILES-NO-DB (flat node:fs sidecar over a DB / BullMQ-query for run history). |

specs/interfaces.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,12 +496,62 @@ Evidence convention as in `constitution.md`.
496496
`piDispatchConfig`-tagged error. Given a valid `local` entry, when it fires, then the emitted job's
497497
`data` byte-matches the shape produced by the interactive local (`enqueueLocalJob`) path.
498498
499+
## INT-RUN-HISTORY-FILE-CONTRACT
500+
501+
**worker → panel.**
502+
503+
- **Contract**:
504+
```
505+
<logsDir>/<sanitizedJobId>.log append-only container stdout+stderr; untrusted, PII-bearing; written ONLY when PI_CAPTURE_JOB_LOGS=1
506+
<logsDir>/<sanitizedJobId>.json one JSON object, PII-free, overwritten on each terminal state (last-write-wins across retries)
507+
(logsDir via PI_LOGS_DIR; empty/unset = <OS temp>/pi-dispatch/logs)
508+
{ "jobId": "<raw job id: delivery GUID | local-<hex> | repeat:<sched>:<millis>>",
509+
"kind": "github" | "local" | null,
510+
"target": "<repo>#<issue>" | "local:<basename>" | null,
511+
"flow": "<flow name>" | null,
512+
"startedAt": "<ISO-8601>", "endedAt": "<ISO-8601>",
513+
"outcome": "completed" | "policy" | "failed",
514+
"reason": "<fixed enum: worker-abort|over-budget|unprotected-branch|runner-policy|container-never-started|...>" | null,
515+
"exitCode": <int> | null,
516+
"turns": <int> | null,
517+
"budgetReserved": <bool> | null,
518+
"attempt": <int> }
519+
```
520+
Field order is the serialisation order (`JSON.stringify` emits insertion order). The filename uses the
521+
**sanitized** id (`:` → `_`, because `repeat:<sched>:<millis>` is NTFS-illegal); the record **body**
522+
keeps the raw `jobId`. `reason` is a fixed enum passed through from the terminal outcome — never
523+
free-form and never payload text — and `turns` is `null` when the container died before emitting the
524+
runner `exit` line.
525+
- **Why**: The panel is a separate process (`DES-PANEL-SEPARATE-FROM-RECEIVER`) that reads this as a
526+
read-model it does not share memory with — the worker writes the files, the panel reads them, and
527+
nothing crosses in RAM. The worker writes on both terminal paths: `worker/src/index.mjs` `makeProcessor`
528+
calls `recordRun` on the success (`result`) and the failure (`error`) branch alike, and
529+
`worker/src/run-history.mjs` `makeRecordWriter` serialises the record with a truncating
530+
`fs.writeFileSync`, so a re-run of the same id overwrites — last-write-wins across retries. The `.json`
531+
is PII-free **by construction**: `buildRecord` (`worker/src/run-history.mjs`) is an explicit object
532+
literal over stable id-only fields and never spreads `job.data`, `result`, or `error`, so a GitHub
533+
job's title/body and a local job's `task` or full folder path cannot leak — `target` keeps only
534+
`repo#issue` or the folder `basename` (`no-pii-in-logs`, `REQ-LOCAL-JOB-VISIBILITY`). The `.log` is a
535+
**separate file** from the `.json` precisely so the untrusted, PII-bearing container stream — teed off
536+
each stdout/stderr chunk by the sink in `worker/src/run-container.mjs` — never contaminates the
537+
structured record; it is opt-in, host-side (never mounted into the container), and written only under
538+
`PI_CAPTURE_JOB_LOGS=1`. This is a **flat per-job file, not a database**: one `.json` (plus the optional
539+
`.log`) keyed by the sanitized job id, no schema and no query surface — upholding this file's standing
540+
invariant that **there is deliberately no database**.
541+
- **Traces to**: `REQ-DURABLE-RUN-HISTORY`, `REQ-LOCAL-JOB-VISIBILITY`, `INT-RUNNER-EXIT-CODE-PROTOCOL`
542+
- **Acceptance**: Given a job reaching a terminal state, exactly one `.json` keyed by its sanitized job
543+
id exists; its `outcome` matches the queue outcome (`completed` / `policy` / `failed`); no field carries
544+
issue or comment body text (`target` is `repo#issue` / `local:<basename>` only); `turns` is `null` when
545+
the container died before emitting the runner `exit` line; the `.log` exists only when
546+
`PI_CAPTURE_JOB_LOGS` is set.
547+
499548
---
500549
501550
## Revision History
502551
503552
| Date | Change |
504553
|---|---|
554+
| 2026-07-21 | Added INT-RUN-HISTORY-FILE-CONTRACT (worker→panel run-history read-model files). |
505555
| 2026-07-17 | Added INT-SCHEDULES-FILE-CONTRACT, documenting the implemented `schedules.json` host-file shape (`PI_SCHEDULES_FILE`): `local`-only, `:`-free unique `id`, `task` as DATA, and load-time rejection of malformed/`github`/duplicate/missing-folder entries. |
506556
| 2026-07-15 | Initial. Extracted from `DESIGN.md` v0.1 §5.1, §5.3, §5.4, §5.5. `INT-SDK-SESSION-OPTIONS` is **new** — the source doc left the SDK option set unverified (its §10) and was wrong about the print-mode flag shape and the mode union. `PLAYWRIGHT_BROWSERS_PATH` added to the runtime contract: the source doc's Dockerfile was broken as written for non-root execution. The source doc's code sketches are deliberately **not** carried over — the real Dockerfile and handler are the truth, and a spec that mirrors them drifts on the first commit. |
507557
| 2026-07-16 | **Correction — "pi never throws" was FALSE**, and it was in this file for a day as the justification for forbidding `try`/`catch` outright. Adversarial re-verification refuted it: `agent-session.ts:1242-1244` is `catch (error) { preflightResult?.(false); throw error; }`, and pi's **own JSDoc** (`:1099-1100`) documents throws on no-model, no-API-key, and missing `streamingBehavior`; `agent.ts:470-471` throws `"Agent is already processing."` outside the lifecycle try entirely. The rule as written would have produced a runner that dies of an unhandled rejection on a missing API key, exiting Node's default `1` = *retryable*, so the queue pays to retry a job that can never succeed. **Both mechanisms are required and cover disjoint sets: preflight throws, the loop swallows.** Also corrected: `StopReason` has **five** values (`packages/ai/src/types.ts:380`) — the entry handled three, and a default branch silently maps `"length"` (truncated output) to success. `reload()` has **no early return** — a second call fully re-runs everything; the earlier "the `loaded` guard makes a double call safe" framing was wrong. The lesson is the file's own: this entry was written from source and still asserted an absolute from a partial read. `INT-CONTAINER-RUNTIME-CONTRACT` gained `--init`, `--shm-size` (explicitly **not** `--ipc=host`, which Playwright recommends but which would share the host IPC namespace with an adversarial container), fonts (absent ⇒ tofu-box screenshots that silently gut `REQ-FRONTEND-VISUAL-VERIFY`), and the fact that **`COPY --chown` does not fix the EACCES trap** because it skips auto-created parent dirs. |

0 commit comments

Comments
 (0)