Skip to content

Commit cc1018d

Browse files
edgeheroclaude
andcommitted
chore(plan-exec): 2026-07-17-cron-trigger — phase "Phase 5: Verification surface, contract test, docs & spec hygiene" passed gates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGgfho2J6YfRSQN1bSDhj6
1 parent 4840f31 commit cc1018d

9 files changed

Lines changed: 480 additions & 4 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ 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_SCHEDULES_FILE= # ABSOLUTE path to schedules.json; unset disables cron (a relative path resolves against the worker's WorkingDirectory)
25+
PI_SCHEDULER_STALL_MAX=2 # tear down a scheduler after N consecutive stalls (money backstop)
2426

2527
# --- GitHub trigger (receiver + worker auth) ---
2628
# Webhook receiver

.github/workflows/pi-upgrade-check.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,13 @@ jobs:
107107
# PI_DISPATCH_REQUIRE_*_TESTS=1 turns a skip into a hard failure. A skipped assertion is an
108108
# UNVERIFIED assertion, and "skipped = pass" is precisely the reasoning that lets a
109109
# guardrail-less agent ship green. VALKEY_TEST_URL activates the queue integration test.
110-
- name: Contract tests -- guardrails, -nc, exit codes, env allowlist, queue, receiver enqueue (all required)
110+
#
111+
# Cron/scheduler assumptions this run pins (BullMQ 5.80.4), each SILENT if it drifts:
112+
# - no-backfill / no-overlap / deterministic repeat: jobId -> specs/design.md:206-216
113+
# - must-handle -10/-11 (schedule edit else silent no-op) -> specs/design.md:231
114+
# - scheduler jobs bypass maxStalledCount (stall carve-out) -> specs/constitution.md:203-216
115+
# Exercised by worker/test/cron.integration.test.mjs (VALKEY_TEST_URL + PI_DISPATCH_REQUIRE_WORKER_TESTS gate it live).
116+
- name: Contract tests -- guardrails, -nc, exit codes, env allowlist, queue, receiver enqueue, cron (all required)
111117
env:
112118
PI_DISPATCH_REQUIRE_LOADER_TESTS: "1"
113119
PI_DISPATCH_REQUIRE_WORKER_TESTS: "1"

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ coverage/
2929
.env.*
3030
!.env.example
3131

32+
# Cron schedule list. Holds host paths and per-schedule tasks; the committed
33+
# schedules.example.json is the template.
34+
schedules.json
35+
!schedules.example.json
36+
3237
# Python — bytecode and tool caches from the skill scripts under .claude/skills/
3338
__pycache__/
3439
*.py[cod]

README.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,32 @@ flowchart LR
7272

7373
Read [`SECURITY.md`](SECURITY.md) before you rely on it — it states plainly what is and is not defended.
7474

75+
## Scheduling recurring jobs
76+
77+
A cron schedule is a trigger, not a new job kind: each entry runs a local folder through a flow on a cron
78+
pattern. Cron is **off by default** — the worker reads schedules only when `PI_SCHEDULES_FILE` points at a
79+
file.
80+
81+
```bash
82+
# 1. Copy the template and edit it
83+
cp schedules.example.json schedules.json # then edit "folder" to a REAL absolute path
84+
85+
# 2. Point the worker at it (absolute path), and restart the worker
86+
# In .env: PI_SCHEDULES_FILE=/absolute/path/to/schedules.json
87+
npx pi-dispatch worker
88+
```
89+
90+
Each entry sets `id`, `cron` (5 or 6 fields), `folder`, `flow`, and `task`; `provider`, `model`, and
91+
`maxTurns` are optional and fall back to the worker's defaults. A schedule's `folder` is a **host path**
92+
the worker runs on the host ([`DES-WORKER-ON-HOST`](specs/design.md)) and mounts that folder into the job
93+
container, so it must be readable by the worker's user.
94+
95+
- **Local schedules only this slice.** `kind` must be `"local"`; the loader rejects `kind:"github"` at
96+
startup — a schedule has no webhook delivery, issue number, or body to work.
97+
- **Editing `folder` is mandatory.** Copying `schedules.example.json` verbatim makes the worker **refuse
98+
to start** with `configError: folder does not exist` until `folder` names a real path. This is
99+
fail-loud on purpose: a broken schedule never silently fails to fire.
100+
75101
## Advanced: GitHub automation
76102

77103
pi-dispatch can also be triggered by GitHub — label an issue, and a container works it on a fresh clone,
@@ -132,9 +158,9 @@ minutes.
132158

133159
## Status
134160

135-
The local-folder path (image, worker, `pi-dispatch run` / `worker`) and the GitHub webhook path
136-
(receiver → queue → clone → PR) are built and work. The admin panel and scheduled (cron) triggers are in
137-
progress. The design is specified in
161+
The local-folder path (image, worker, `pi-dispatch run` / `worker`), the GitHub webhook path
162+
(receiver → queue → clone → PR), and scheduled (cron) triggers for local folders are built and work. The
163+
admin panel is in progress. The design is specified in
138164
[`specs/`](specs/) — start with [`specs/constitution.md`](specs/constitution.md) for the non-negotiables
139165
and [`specs/design.md`](specs/design.md) for the decisions and what was rejected.
140166

schedules.example.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"schedules": [
3+
{
4+
"id": "nightly-tidy",
5+
"kind": "local",
6+
"cron": "0 3 * * *",
7+
"folder": "/absolute/path/to/your/project",
8+
"flow": "tidy",
9+
"task": "dedupe imports and run the formatter",
10+
"provider": "anthropic",
11+
"model": "claude-sonnet-4-5-20250929",
12+
"maxTurns": 30
13+
}
14+
]
15+
}

specs/design.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,15 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`).
214214
one under load, so the panel should surface `next` drift rather than let it look healthy.
215215
- **Deterministic `jobId`**`repeat:<schedulerId>:<nextMillis>` — so scheduler jobs get
216216
`REQ-DEDUP-BY-DELIVERY-GUID`-equivalent dedup for free, with no GUID to supply.
217+
- **Local-only this slice.** A `kind:"github"` schedule is rejected at load. A scheduled GitHub job has
218+
no webhook delivery, issue number, title, or body to supply, and post-integration the github path would
219+
perform a real host clone and per-job token mint before failing every tick — spend and side effects for
220+
a trigger that cannot complete. GitHub scheduling is deferred to a later slice.
221+
- **Two distinct enqueue paths, not duplication.** The interactive `enqueueLocalJob` sets `attempts: 2`
222+
with backoff; the scheduled path (`upsertJobScheduler`) passes retention-only opts with a single
223+
attempt. For an unattended recurring trigger the **cadence is the retry**, so a failing tick must not
224+
multiply spend within one tick — two distinct triggers with different retry semantics, not two
225+
implementations of one thing.
217226
**Legacy `repeat:` is deprecated and slated for removal in v6** — starting on it would be adopting a
218227
known-dead API.
219228
- **Evidence (upstream)**: `taskforcesh/bullmq @ v5.80.4 → src/classes/queue.ts:468-495 → upsertJobScheduler`

specs/interfaces.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,12 +468,41 @@ Evidence convention as in `constitution.md`.
468468
- **Traces to**: `CONST-HMAC-OVER-RAW-BODY`, `REQ-TRIGGER-AUTHOR-GATE`, `REQ-DEDUP-BY-DELIVERY-GUID`
469469
- **Acceptance**: Given a payload with unknown extra fields, behaviour is unchanged.
470470

471+
## INT-SCHEDULES-FILE-CONTRACT
472+
473+
**operator → worker.**
474+
475+
- **Contract**:
476+
```
477+
schedules.json (path via PI_SCHEDULES_FILE; absolute; unset = cron disabled)
478+
{ "schedules": [
479+
{ "id": "<[A-Za-z0-9._-]+, no ':' , unique>",
480+
"kind": "local", // only "local"; "github" rejected at load
481+
"cron": "<5 or 6 space-separated fields>",
482+
"folder": "<absolute HOST path, must exist>",
483+
"flow": "<flow name>",
484+
"task": "<operator-authored prompt text — DATA, lands in /job/prompt.md>",
485+
"provider": "<optional>", "model": "<optional>", "maxTurns": <optional> } ] }
486+
```
487+
- **Why**: The operator's schedule set is a host file — diffable, reviewable, and git-trackable — rather
488+
than API state, so a schedule change is a reviewed edit. `id` must be `:`-free because the stall guard
489+
parses BullMQ's deterministic `repeat:<id>:<millis>` job id by splitting on `:`; a colon in the id would
490+
corrupt that parse. `task` is operator-authored natural language and is therefore **DATA**
491+
(`CONST-ISSUE-TEXT-IS-DATA`): it lands in `/job/prompt.md` as the user prompt, never in a system prompt
492+
or persona.
493+
- **Traces to**: `DES-CRON-VIA-BULLMQ-SCHEDULER`, `CONST-ISSUE-TEXT-IS-DATA`
494+
- **Acceptance**: Given an entry that is malformed, has `kind:"github"`, a duplicate `id`, a `:` in its
495+
`id`, or a `folder` that does not exist, when the config loads, then load throws a
496+
`piDispatchConfig`-tagged error. Given a valid `local` entry, when it fires, then the emitted job's
497+
`data` byte-matches the shape produced by the interactive local (`enqueueLocalJob`) path.
498+
471499
---
472500
473501
## Revision History
474502
475503
| Date | Change |
476504
|---|---|
505+
| 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. |
477506
| 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. |
478507
| 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. |
479508
| 2026-07-15 | `INT-RUNNER-EXIT-CODE-PROTOCOL` gained its **mechanism**, which was the missing half. The codes were right; nothing said how to produce them, and **the obvious implementation produces them wrong**. ~~`pi never throws`~~ (**refuted the next day — see above**): `agent.ts:485-491` catches and `handleRunFailure` does not rethrow, so abort / 429 / 5xx / dead network all resolve `await session.prompt()` normally — and `prompt()` returns `Promise<void>`, so there is no return value either. A `try`/`catch` runner exits `0` on every infrastructure failure: queue records success, never retries, job did nothing — verbatim the worst failure class this project names. The exit code must be derived from `stopReason` on the terminal message, captured via `subscribe()`. Also recorded: the `subscribe()` listener is **sync and unawaited**, so a budget check that awaits will overshoot. `INT-CONTAINER-RUNTIME-CONTRACT` gained two runtime facts that fail *inside the container* where no Dockerfile hints at them: the agent dir must be **writable** by the non-root user (pi lazily writes `auth.json` on first credential touch), and Chromium needs **`--no-sandbox`** because `--cap-drop=ALL` denies it the seccomp/`SYS_ADMIN` its own sandbox requires — the container is the sandbox, and re-granting caps to Chromium would invert the security model. Two cited paths were **dead** (`packages/ai/src/api/env-api-keys.ts`, `packages/coding-agent/src/core/config.ts`); claims and line numbers were correct, only the addresses were wrong — the sneakiest defect class, since it reads as verified and cannot be followed. All cited paths now resolve. Good news recorded too: `before_agent_start` fires strictly before any provider HTTP call, so the assembled-prompt assertion costs **zero tokens**. |

specs/requirements.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,40 @@ local), the credential (a short-lived scoped token for GitHub jobs vs none for l
227227
completion or failure line carrying the job id and outcome; during the run, the container's output is
228228
visible there.
229229

230+
## REQ-CRON-SCHEDULED-JOBS
231+
232+
- **Statement**: Scheduled jobs shall be driven by BullMQ **Job Schedulers** (`upsertJobScheduler`), one
233+
per configured schedule. A schedule is a **trigger, not a job kind**: on each tick it emits an ordinary
234+
`kind:"local"` job that flows through the **same** processor as an interactively-triggered local job.
235+
- **Why**: An unattended recurring trigger spends real money against a paid provider with nobody watching,
236+
so every failure mode of the scheduler is a money-or-silence failure. Job Schedulers are a Redis-resident
237+
object (survives worker and Redis-under-AOF restart) and give no-backfill and structural no-overlap for
238+
free — reimplementing those is the four-mechanism drowning `DES-CRON-VIA-BULLMQ-SCHEDULER` refused. The
239+
scheduler is also the one path that **bypasses `maxStalledCount`** (`CONST-RETRY-INFRA-ONLY`), so the
240+
stall backstop must be rebuilt explicitly; and because it fires while nobody watches, a `-10`/`-11`
241+
silent no-op or an in-tick retry storm would be invisible without the loud-surfacing and no-retry rules
242+
below.
243+
- **Traces to**: `DES-CRON-VIA-BULLMQ-SCHEDULER`, `CONST-RETRY-INFRA-ONLY`, `CONST-BUDGET-BEFORE-TOKENS`,
244+
`REQ-RUNNER-TURN-BUDGET`
245+
- **Acceptance**:
246+
- Given a config with N schedules, when the worker loads them, then it calls `upsertJobScheduler` once
247+
per schedule; a `-10` (`SchedulerJobIdCollision`) or `-11` (`SchedulerJobSlotsBusy`) result — whether
248+
thrown or returned — is surfaced loudly (logged and the load fails), never swallowed into a silent
249+
no-op.
250+
- Given a scheduler whose per-scheduler stall counter exceeds `PI_SCHEDULER_STALL_MAX`, when the next
251+
stall is observed, then the scheduler is torn down via `removeJobScheduler` — the explicit backstop for
252+
the `maxStalledCount` carve-out.
253+
- Given a worker that was down across one or more due ticks, when it restarts, then exactly one job is
254+
emitted (no backfill) and no second job for a schedule is created while that schedule's prior job is
255+
still processing (no overlap).
256+
- Given a scheduler resident in Redis but absent from the current config, when the worker performs its
257+
startup reconcile, then the orphaned scheduler is removed.
258+
- Given a schedule entry with `kind:"github"`, when the config loads, then the entry is rejected — a
259+
scheduled trigger supplies no webhook delivery, issue number, title, or body, so only `kind:"local"`
260+
is admissible.
261+
- Given a scheduled occurrence that fails (including an infra fault), when the tick concludes, then the
262+
occurrence is **not** retried within the tick — the schedule's own cadence is the retry.
263+
230264
---
231265

232266
## Notes (not requirements)
@@ -248,4 +282,5 @@ wait-list working as designed, not a failure — see `README.md`.
248282
|---|---|
249283
| 2026-07-15 | Initial. Extracted from `DESIGN.md` v0.1 §1, §5.1–5.2, §5.6, §7, §8. `REQ-RUNNER-TURN-BUDGET` and `REQ-UPSTREAM-CONTRACT-TESTS` are **new** — both exist because source-verification refuted design assumptions the doc had marked "verify". §8's failure-mode table was the richest source; one of its rows ("verify: pi max-turns option") was wrong. |
250284
| 2026-07-17 | Added REQ-BRANCH-PROTECTION-PRECONDITION, formalizing the branch-protection refusal already enforced in `processor.mjs`/`github-host.mjs` (was a dangling code citation). |
285+
| 2026-07-17 | Added REQ-CRON-SCHEDULED-JOBS, formalizing the implemented BullMQ Job Scheduler cron path: `local`-only triggers, loud `-10`/`-11` handling, per-scheduler stall teardown, startup orphan reconcile, and no in-tick retry. |
251286
| 2026-07-16 | **Scope de-GitHub-ified.** It said "triggers on GitHub issue activity" and never mentioned local folders, the CLI/panel, or cron -- stale, since local is now first-class and built. Rewritten as trigger × target. `REQ-JOB-STATUS-COMMENTS` scoped to GitHub jobs explicitly (a local job has no issue). New `REQ-LOCAL-JOB-VISIBILITY`: local jobs surface their outcome on the worker console (and later the panel) -- the local counterpart of the issue comment and the same signal for `CONST-PI-VERSION-PINNED`'s silent-no-op mode. Code updated to match: startWorker now logs one terminal line per job. |

0 commit comments

Comments
 (0)