@ai-dossier/sched 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +155 -0
  2. package/package.json +49 -0
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # @ai-dossier/sched
2
+
3
+ [![npm](https://img.shields.io/npm/v/@ai-dossier/sched.svg)](https://www.npmjs.com/package/@ai-dossier/sched)
4
+
5
+ Deterministic scheduler core for dossier batch cycles — queue, worker slots, typed state
6
+ machines, crash-safe persistence, and (since #464) the **dispatch engine**: spawning agent
7
+ processes, verifying their completion against ground truth, and mechanizing the
8
+ stall/escalation ladder. The scheduler itself **never invokes an LLM** — it spawns the
9
+ agent process the operator configured and reconciles the durable record
10
+ (`ai-dossier runstate` / `gh` / `git`) that the spawned run leaves behind.
11
+
12
+ Design: RFC-0001 *Batch Cycles* §B/C.1/D (the RFC lives on branch `docs/batch-cycles-rfc`,
13
+ not yet merged to `main`; the §D state machines are frozen verbatim into the types below).
14
+ This package is the deterministic replacement for fleet-cycle's LLM-prose supervision,
15
+ whose named failure — slots sitting idle after a subagent finished — is a scheduling bug
16
+ this state machine makes impossible to forget.
17
+
18
+ ## CLI surface
19
+
20
+ Consumed through the monorepo CLI (`@ai-dossier/cli` ≥ 0.18.0):
21
+
22
+ ```bash
23
+ ai-dossier sched enqueue --issues 101,105..109 --deps 100 --tier strong # flags
24
+ ai-dossier sched enqueue --from-manifest batch-prep.json # batch-prep output
25
+ ai-dossier sched start # the dispatch engine: spawn, verify, escalate (Ctrl-C stops it)
26
+ ai-dossier sched start --once # a single reconcile+refill tick (cron-style)
27
+ ai-dossier sched status # queue, slots (pid/phase/last-progress), batches, blocked/failed
28
+ ai-dossier sched pause # stop NEW assignments; live units keep running
29
+ ai-dossier sched resume
30
+ ai-dossier sched abandon --issue 42 --reason "operator abort"
31
+ ai-dossier sched abandon --batch b1 # dissolve; members requeue as full-cycle
32
+ ```
33
+
34
+ Every subcommand takes `--project <slug>` (default: `owner-repo` of the current directory,
35
+ falling back to the repo basename — fleet-cycle's convention) and `--json`.
36
+
37
+ ## The dispatch engine (#464)
38
+
39
+ `sched start` runs a tick loop (default 60s, `--interval` or `reconcile_interval_ms`)
40
+ where every mechanical supervision decision is code, not remembered prose:
41
+
42
+ 1. **Dispatch (AC1)** — a runnable unit is spawned as a detached agent process
43
+ (`claude -p --output-format json --model <tier model>` by default, opencode fallback;
44
+ command/prompt/tier-models configurable), prompt on stdin, output appended to
45
+ `runs/<unit>.log`. pid, phase, and last-progress are persisted in `state.json`.
46
+ Agents are unref'd: they survive a sched crash (restart reconciles by pid).
47
+ 2. **Completion verification (AC2)** — an agent exiting is never proof of completion.
48
+ On exit, the unit completes only when ground truth confirms it: the issue's latest
49
+ runstate milestone is `report done`, or GitHub says the issue is closed. An unverified
50
+ exit rides the recovery ladder like a stall.
51
+ 3. **Reconciliation tick (AC3)** — every tick detects externally-advanced state (someone
52
+ finished the work outside sched → complete, kill the leftover agent, reclaim the
53
+ slot), orphaned pids after a restart (dead pid on a running slot → exit rail →
54
+ verify), and progress (a new milestone `at=` or a new pushed commit — the branch from
55
+ the setup milestone watched via `git ls-remote`).
56
+ 4. **Stall/escalation ladder (AC4)** — no new milestone AND no new pushed commit for
57
+ `stall_timeout_ms` (default 30 min) → kill the agent and redispatch the same unit one
58
+ tier stronger (mechanical → mid → strong; the resume rails carry work forward). Cap 2
59
+ escalations — or a stall at the strongest tier — fails the unit and blocks its
60
+ TRANSITIVE dependents (`dep-failed:<issue>`).
61
+ 5. **Immediate refill (AC5)** — a slot freed by a terminal state is refilled in the SAME
62
+ tick; a runnable unit never waits while a slot is idle (pinned by a regression test).
63
+ 6. **Journal (AC6)** — every event (assigned, spawned, exit-detected, external-advance,
64
+ progress, stalled, redispatched, unit-failed, dependents-blocked, …) is appended to
65
+ `events.jsonl`; `sched status` shows the live phase per unit.
66
+
67
+ Two engine-safety policies were explicit product decisions on #464:
68
+
69
+ - **Pid identity is hybrid-verified (decision 1, option C).** Every spawn records the
70
+ child's `/proc/<pid>/stat` start-time and persists it in `state.json` (`pid_start`);
71
+ `kill`/`isAlive` refuse a pid whose current start-time no longer matches — a reused
72
+ pid is never signalled, across engine restarts too. Platforms without `/proc`
73
+ (macOS/Windows) and legacy pids without a recorded start-time stay best-effort.
74
+ - **Unreachable ground truth pauses decisions (decision 2, option A).** A FAILED
75
+ milestone poll (`undefined`) is distinct from a verifiably-empty trail (`null`):
76
+ while a poll is unreachable (gh auth expired, `ai-dossier` missing from a cron PATH,
77
+ network down), stall and verify-fail decisions pause for that unit — an outage can
78
+ never kill a healthy agent or fail a unit as "unverified". An agent that exits during
79
+ an outage holds in `verifying` until truth returns. Each pause is journaled as
80
+ `ground-truth-unreachable`.
81
+
82
+ Only `issue:<n>` units are dispatched today — batch member sequencing is a follow-up
83
+ (#464 non-goal).
84
+
85
+ ## API surface
86
+
87
+ ```ts
88
+ import {
89
+ SchedStore, // persistence: load/save/withLock per project dir
90
+ enqueueEntries, // validated queue appends (cycles, dupes, mode/batch rules)
91
+ parseManifest, // batch-prep JSON → EnqueueInput[]
92
+ computeAssignments, // pure: fill idle slots with runnable units, bounded by max_slots
93
+ runnableUnits, // pure: which units may run right now (dep-gated)
94
+ tick, // one engine cycle: reconcile + verify + refill + spawn
95
+ runLoop, // the sched start loop (tick, sleep, repeat)
96
+ type TickResult, // what one tick did (spawned/completed/redispatched/failed/blocked)
97
+ type EngineDeps, // inject everything the engine touches (store/journal/spawn/ground truth/clock)
98
+ createSpawnDeps, // real detached-spawn process I/O
99
+ createExecGroundTruth, // runstate/gh/git ground truth via subprocesses (injectable exec)
100
+ resolveDispatch, // config → resolved command/prompt/tier-models/timers
101
+ Journal, // append-only events.jsonl
102
+ transitionIssue, transitionBatch, transitionSlot, // typed §D transitions
103
+ TRANSITIONS, // the transition tables themselves (for previews)
104
+ buildStatusReport, // machine-readable status incl. blocked/failed sets
105
+ validateState, // strict persisted-state validation (1.0.0 files migrate)
106
+ IllegalTransitionError, EnqueueError, CorruptStateError, LockTimeoutError,
107
+ SchedNotFoundError,
108
+ } from '@ai-dossier/sched';
109
+ ```
110
+
111
+ All state functions are pure (state in, new state out — the worktree-pool pattern);
112
+ `SchedStore` is the only state-I/O boundary and every mutation runs under its lock. The
113
+ engine polls ground truth OUTSIDE the lock and mutates state under it, so a slow `gh`
114
+ call never blocks other sched commands. All process I/O is injectable — the tests spawn
115
+ fake agents and stub ground truth; no LLM calls anywhere.
116
+
117
+ ## State layout
118
+
119
+ ```
120
+ ~/.dossier/sched/<project>/
121
+ ├── state.json # hot operational truth — atomic tmp+fsync+rename writes;
122
+ ├── config.json # durable intent: max_slots, stall_timeout_ms, reconcile_interval_ms, dispatch
123
+ ├── events.jsonl # append-only event journal (the operator's flight recorder)
124
+ ├── runs/ # per-unit agent output logs (issue-<n>.log)
125
+ └── .sched-lock/ # cross-process directory mutex (pid; stolen from dead holders)
126
+ ```
127
+
128
+ - **Crash safety**: a process killed between writes leaves the previous complete state,
129
+ never a partial file; restart resumes identically (proved by `restart.test.ts`) —
130
+ running slots with dead pids are re-detected and verified, slots left `assigned` by a
131
+ crash between assign and spawn are spawned, and a dispatched entry no slot holds is
132
+ requeued.
133
+ - **Corrupt state is loud**: `load()` throws `CorruptStateError` naming the file —
134
+ never a silent queue reset. `state.json` is deletable and rebuildable from GitHub,
135
+ which remains the system of record.
136
+ - **Schema**: state/config files from #460 (schema 1.0.0) load and migrate to 1.1.0
137
+ automatically (slot `branch`/`last_head` backfill to null).
138
+ - **`max_slots`** bounds live units (`assigned | running | recovering`); dependency
139
+ edges gate readiness — an issue with an unmerged dependency, and a batch behind an
140
+ unmerged batch, are never runnable.
141
+ - **Pause** stops new assignments only; abandon routes through the typed failure rails
142
+ (`evicted → requeued{full}` for batch members — nothing green is discarded).
143
+
144
+ ## Development
145
+
146
+ ```bash
147
+ cd packages/sched
148
+ npm run build # tsc → dist/
149
+ npm test # vitest — state machines, persistence, crash/restart, engine,
150
+ # and integration tests with real spawned fake agents (no LLM calls)
151
+ ```
152
+
153
+ No network, no GitHub in unit tests — persistence tests run on temp directories; the
154
+ integration tests spawn fake-agent fixtures against a scratch git repo with stubbed
155
+ ground truth.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@ai-dossier/sched",
3
+ "version": "0.2.0",
4
+ "description": "Deterministic scheduler core for dossier batch cycles \u2014 queue, slots, persistent state machine, dispatch engine with completion verification and stall/escalation ladder. The scheduler never invokes an LLM.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist/",
9
+ "README.md"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "dev": "tsc --watch",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "test:coverage": "vitest run --coverage"
17
+ },
18
+ "keywords": [
19
+ "scheduler",
20
+ "state-machine",
21
+ "dossier",
22
+ "batch",
23
+ "orchestration"
24
+ ],
25
+ "author": "Yuval Dimnik <yuval.dimnik@gmail.com>",
26
+ "license": "AGPL-3.0-only",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/imboard-ai/ai-dossier.git",
30
+ "directory": "packages/sched"
31
+ },
32
+ "homepage": "https://github.com/imboard-ai/ai-dossier#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/imboard-ai/ai-dossier/issues"
35
+ },
36
+ "engines": {
37
+ "node": ">=20.0.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "registry": "https://registry.npmjs.org"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^24.10.0",
45
+ "@vitest/coverage-v8": "^4.0.9",
46
+ "typescript": "^5.9.3",
47
+ "vitest": "^4.0.9"
48
+ }
49
+ }