@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.
- package/README.md +155 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# @ai-dossier/sched
|
|
2
|
+
|
|
3
|
+
[](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
|
+
}
|