peaks-loop 3.0.3 → 3.1.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/CHANGELOG.md +35 -0
- package/dist/src/cli/commands/job-commands.d.ts +3 -0
- package/dist/src/cli/commands/job-commands.js +236 -0
- package/dist/src/cli/program.js +6 -0
- package/dist/src/services/job/job-event-emitter.d.ts +30 -0
- package/dist/src/services/job/job-event-emitter.js +5 -0
- package/dist/src/services/job/job-orchestrator.d.ts +50 -0
- package/dist/src/services/job/job-orchestrator.js +104 -0
- package/dist/src/services/job/job-resource-snapshot.d.ts +7 -0
- package/dist/src/services/job/job-resource-snapshot.js +47 -0
- package/dist/src/services/job/job-rotation.d.ts +17 -0
- package/dist/src/services/job/job-rotation.js +30 -0
- package/dist/src/services/job/job-state-store.d.ts +24 -0
- package/dist/src/services/job/job-state-store.js +60 -0
- package/dist/src/services/job/job-types.d.ts +444 -0
- package/dist/src/services/job/job-types.js +87 -0
- package/dist/src/services/job/subagent-job-wrapper.d.ts +35 -0
- package/dist/src/services/job/subagent-job-wrapper.js +30 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-solo/SKILL.md +38 -13
- package/skills/peaks-solo/references/job-loop.md +68 -0
- package/skills/peaks-solo/references/runbook.md +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.1.0 — 2026-07-03
|
|
4
|
+
|
|
5
|
+
### Fixed — peaks-solo external-references authority declaration
|
|
6
|
+
|
|
7
|
+
- `skills/peaks-solo/SKILL.md` External-references paragraph now ends with `Peaks-Loop Solo gates and artifacts remain authoritative.` to satisfy `tests/unit/skill-external-invocation.test.ts:63` (`PEAKS_AUTHORITATIVE_PATTERN`). One-line surgical patch; no behaviour change.
|
|
8
|
+
|
|
9
|
+
### Added — Peaks-Loop Job
|
|
10
|
+
|
|
11
|
+
- New `peaks job *` subcommand family: init / status / checkpoint / continue / resume / block / handoff / rotate-now / subagent-cleanup (9 commands).
|
|
12
|
+
- New CLI flag `--main-loop-strategy single|rotating` on `job init`, with rotating-mode hard default for ≥3 slices and `rotateEvery=3` cadence.
|
|
13
|
+
- New `--watch` poll mode + statusline event stub (`emitJobEvent`) for ambient progress visibility. Real statusline wire-up deferred to a follow-up slice.
|
|
14
|
+
- Sub-agent wrapper `SubAgentJobWrapper` enforces `--budget-mb 512` default + cleanup gate before slice checkpoint.
|
|
15
|
+
- New peaks-solo SKILL.md Steps 0.8 / 0.81 / 0.85 / 0.86 / 0.87 wrapping the existing single-rid runbook for multi-slice jobs.
|
|
16
|
+
- 9 hard-red-line rules embedded in the skill prose (cost re-ask ban, slice-coalesce ban, fake-completion ban, detached-mode ban, cleanup-skip ban, rotate-skip ban, etc).
|
|
17
|
+
|
|
18
|
+
### New services
|
|
19
|
+
|
|
20
|
+
- `src/services/job/job-types.ts` — Zod schemas + types for SliceState, JobState, ResourceSnapshot, JobStatusSummary, plus CLI input schemas (JobInitInput, JobCheckpointInput, JobBlockInput).
|
|
21
|
+
- `src/services/job/job-state-store.ts` — on-disk state store with per-job lockfile.
|
|
22
|
+
- `src/services/job/job-orchestrator.ts` — state machine with strict / best-effort exit policies.
|
|
23
|
+
- `src/services/job/job-rotation.ts` — main-session rotation cadence (single / rotating mode).
|
|
24
|
+
- `src/services/job/subagent-job-wrapper.ts` — budget + cleanup gate.
|
|
25
|
+
- `src/services/job/job-resource-snapshot.ts` — cpu / mem / disk / context collector.
|
|
26
|
+
- `src/services/job/job-event-emitter.ts` — stderr-stub event emit (real statusline wire-up pending).
|
|
27
|
+
|
|
28
|
+
### Migration
|
|
29
|
+
|
|
30
|
+
- Existing single-rid flows are unchanged. The Job is opt-in via peaks-solo Step 0.8 detection; users who do not invoke multi-slice requests see no behavior difference.
|
|
31
|
+
- The `peaks session rotate` and `peaks session cycle-summary` subcommands referenced in the spec are NOT shipped in v1; the rotation path uses constructor-injected stub callbacks. M6.5 follow-up will add the real subcommands.
|
|
32
|
+
|
|
33
|
+
### Spec / plan
|
|
34
|
+
|
|
35
|
+
- Design: `docs/superpowers/specs/2026-07-03-peaks-loop-job-design.md` (v3).
|
|
36
|
+
- Plans: `docs/superpowers/plans/2026-07-03-peaks-job/` (M1-M7 + README).
|
|
37
|
+
|
|
3
38
|
## [3.0.3] — 2026-07-02 — auto-compact zero-pause (ide-native pathway)
|
|
4
39
|
|
|
5
40
|
**PATCH bump from 3.0.2**. Closes the user-pause gap on long Claude Code sessions: at context ratio ≥ 0.95 the runner now compacts itself via a PreToolUse hook, no human intervention required.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// src/cli/commands/job-commands.ts
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { fail, ok } from '../../shared/result.js';
|
|
5
|
+
import { addJsonOption, printResult } from '../cli-helpers.js';
|
|
6
|
+
import { JobStateStore } from '../../services/job/job-state-store.js';
|
|
7
|
+
import { JobOrchestrator } from '../../services/job/job-orchestrator.js';
|
|
8
|
+
import { JobRotation } from '../../services/job/job-rotation.js';
|
|
9
|
+
import { SubAgentJobWrapper } from '../../services/job/subagent-job-wrapper.js';
|
|
10
|
+
import { emitJobEvent } from '../../services/job/job-event-emitter.js';
|
|
11
|
+
import { JobInitInputSchema, JobCheckpointInputSchema, JobBlockInputSchema, } from '../../services/job/job-types.js';
|
|
12
|
+
import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
|
|
13
|
+
function projectRoot(opts) {
|
|
14
|
+
// Reuse the workspace root resolver from peaks CLI; for now, CWD as a safe placeholder.
|
|
15
|
+
return opts.project ?? process.cwd();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolves the on-disk root for Job state files.
|
|
19
|
+
*
|
|
20
|
+
* Per spec §3.3 + §4.5 (2.7.1 single-scope-axis layout), Job state lives at:
|
|
21
|
+
* `<projectRoot>/.peaks/_runtime/<sessionId>/job/<jobId>/state.json`
|
|
22
|
+
*
|
|
23
|
+
* The `JobStateStore` itself only knows its `rootDir` + `jobId` and joins them. We
|
|
24
|
+
* compute the canonical root here (per-call) so the store can stay layout-agnostic.
|
|
25
|
+
*
|
|
26
|
+
* Resolution order:
|
|
27
|
+
* 1. `--session-id` flag (explicit override)
|
|
28
|
+
* 2. `getCurrentSessionId(project)` — reads `.peaks/_runtime/session.json` per peaks-solo
|
|
29
|
+
* 3. Error (NO_ACTIVE_SESSION) — must never silently fall back to a random uuid
|
|
30
|
+
*/
|
|
31
|
+
function resolveJobStateRoot(opts) {
|
|
32
|
+
const project = projectRoot(opts);
|
|
33
|
+
const sessionId = opts.sessionId ?? getCurrentSessionId(project);
|
|
34
|
+
if (!sessionId) {
|
|
35
|
+
throw new Error('NO_ACTIVE_SESSION: peaks job requires --session-id or an active peaks-solo session via peaks workspace init');
|
|
36
|
+
}
|
|
37
|
+
const rootDir = join(project, '.peaks', '_runtime', sessionId, 'job');
|
|
38
|
+
return { rootDir, sessionId, projectRoot: project };
|
|
39
|
+
}
|
|
40
|
+
export function registerJobCommands(program, io = { stdout: (t) => process.stdout.write(t), stderr: (t) => process.stderr.write(t) }) {
|
|
41
|
+
const job = new Command('job').description('Drive long multi-slice work as one Job (peaks-solo Step 0.8+)');
|
|
42
|
+
job
|
|
43
|
+
.command('init')
|
|
44
|
+
.requiredOption('--job-id <jid>')
|
|
45
|
+
.requiredOption('--slice-list <list>')
|
|
46
|
+
.option('--parallelism-hint <serial|llm-decides>', 'llm-decides')
|
|
47
|
+
.option('--exit-policy <strict|best-effort>', 'strict')
|
|
48
|
+
.option('--main-loop-strategy <single|rotating>', 'rotating')
|
|
49
|
+
.option('--rotate-every <n>', 'rotate every N slices (rotating mode)', '3')
|
|
50
|
+
.option('--session-id <sid>', 'session id (default: read from .peaks/_runtime/session.json; required to land in the 2.7.1 single-scope-axis layout)')
|
|
51
|
+
.option('--project <repo>')
|
|
52
|
+
.action(async (opts) => {
|
|
53
|
+
const project = projectRoot(opts);
|
|
54
|
+
// Resolve sessionId: explicit flag > canonical session binding > FAIL.
|
|
55
|
+
// Per spec §3.3, Job state lives at .peaks/_runtime/<sessionId>/job/<jobId>/state.json —
|
|
56
|
+
// a random UUID would scatter state across dirs and break resume/auto-compact.
|
|
57
|
+
let sessionId = opts.sessionId ?? getCurrentSessionId(project);
|
|
58
|
+
if (!sessionId) {
|
|
59
|
+
return printResult(io, fail('init', 'NO_ACTIVE_SESSION', 'peaks job init requires --session-id (or an active peaks-solo session via peaks workspace init)', { project }, [
|
|
60
|
+
'Re-run with --session-id <sid>',
|
|
61
|
+
'Or run `peaks workspace init` to create a session first'
|
|
62
|
+
]), opts);
|
|
63
|
+
}
|
|
64
|
+
const parsed = JobInitInputSchema.safeParse({
|
|
65
|
+
jobId: opts.jobId,
|
|
66
|
+
sessionId,
|
|
67
|
+
sliceList: opts.sliceList.split(',').map((s) => s.trim()).filter(Boolean),
|
|
68
|
+
parallelismHint: opts.parallelismHint,
|
|
69
|
+
exitPolicy: opts.exitPolicy,
|
|
70
|
+
mainLoopStrategy: opts.mainLoopStrategy,
|
|
71
|
+
rotateEvery: Number(opts.rotateEvery),
|
|
72
|
+
project,
|
|
73
|
+
json: opts.json,
|
|
74
|
+
});
|
|
75
|
+
if (!parsed.success)
|
|
76
|
+
return printResult(io, fail('init', 'INVALID_INIT', parsed.error.message, {}), opts);
|
|
77
|
+
const jobRoot = resolveJobStateRoot(opts);
|
|
78
|
+
const store = new JobStateStore(jobRoot.rootDir);
|
|
79
|
+
const orch = new JobOrchestrator(store);
|
|
80
|
+
const state = orch.init({
|
|
81
|
+
jobId: parsed.data.jobId,
|
|
82
|
+
sessionId: parsed.data.sessionId,
|
|
83
|
+
sliceList: parsed.data.sliceList,
|
|
84
|
+
parallelismHint: parsed.data.parallelismHint,
|
|
85
|
+
exitPolicy: parsed.data.exitPolicy,
|
|
86
|
+
mainLoopStrategy: parsed.data.mainLoopStrategy,
|
|
87
|
+
rotateEvery: parsed.data.rotateEvery,
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
emitJobEvent({ kind: 'job-started', jobId: state.jobId, total: state.slices.length, strategy: state.mainLoopStrategy });
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
// best-effort: event emission failures must not abort job init
|
|
94
|
+
void e;
|
|
95
|
+
}
|
|
96
|
+
printResult(io, ok('init', { jobId: state.jobId, sliceCount: state.slices.length, statePath: `${jobRoot.rootDir}/${state.jobId}/state.json` }), opts);
|
|
97
|
+
});
|
|
98
|
+
addJsonOption(job.commands.find(c => c.name() === 'init'));
|
|
99
|
+
job
|
|
100
|
+
.command('status')
|
|
101
|
+
.requiredOption('--job-id <jid>')
|
|
102
|
+
.option('--watch', 'poll every 3s')
|
|
103
|
+
.option('--show-cost', 'overlay cost from peaks budget')
|
|
104
|
+
.option('--project <repo>')
|
|
105
|
+
.action(async (opts) => {
|
|
106
|
+
const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
|
|
107
|
+
const orch = new JobOrchestrator(store);
|
|
108
|
+
const s = orch.status(opts.jobId);
|
|
109
|
+
if (opts.watch) {
|
|
110
|
+
const draw = () => {
|
|
111
|
+
const bar = `[${'='.repeat(s.done)}${' '.repeat(s.total - s.done)}]`;
|
|
112
|
+
process.stdout.write(`\rjob ${opts.jobId}: ${bar} ${s.done}/${s.total}${s.currentSlice ? ` next=${s.currentSlice}` : ''} `);
|
|
113
|
+
};
|
|
114
|
+
draw();
|
|
115
|
+
const iv = setInterval(() => { const u = orch.status(opts.jobId); Object.assign(s, u); draw(); if (u.done + u.failed + u.skipped + u.blocked >= u.total)
|
|
116
|
+
clearInterval(iv); }, 3000);
|
|
117
|
+
process.on('SIGINT', () => { clearInterval(iv); process.stdout.write('\n'); process.exit(0); });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
emitJobEvent({ kind: 'job-progress', jobId: opts.jobId, done: s.done, total: s.total, ...(s.currentSlice ? { currentSlice: s.currentSlice } : {}) });
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
// best-effort: event emission failures must not abort job status
|
|
125
|
+
void e;
|
|
126
|
+
}
|
|
127
|
+
printResult(io, ok('status', s), opts);
|
|
128
|
+
});
|
|
129
|
+
addJsonOption(job.commands.find(c => c.name() === 'status'));
|
|
130
|
+
// M4.2: wire rotate-now to JobRotation (session-rotate callbacks are stubs pending M6.5 batch-fix).
|
|
131
|
+
job.command('rotate-now')
|
|
132
|
+
.requiredOption('--job-id <jid>')
|
|
133
|
+
.option('--project <repo>')
|
|
134
|
+
.action(async (opts) => {
|
|
135
|
+
const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
|
|
136
|
+
const rotation = new JobRotation(store, async (_jid) => { /* delegate to peaks session rotate — implementation wired in M6.5 batch-fix */ return { rotated: true }; }, async (jid) => ({ jobId: jid, cycle: 0 }));
|
|
137
|
+
const r = await rotation.rotateNow(opts.jobId);
|
|
138
|
+
printResult(io, ok('rotate-now', r), opts);
|
|
139
|
+
});
|
|
140
|
+
addJsonOption(job.commands.find(c => c.name() === 'rotate-now'));
|
|
141
|
+
job.command('subagent-cleanup')
|
|
142
|
+
.requiredOption('--job-id <jid>')
|
|
143
|
+
.requiredOption('--batch-id <bid>')
|
|
144
|
+
.option('--force')
|
|
145
|
+
.option('--project <repo>')
|
|
146
|
+
.action(async (opts) => {
|
|
147
|
+
const wrapper = new SubAgentJobWrapper(new JobStateStore(resolveJobStateRoot(opts).rootDir), async () => ({ batchId: opts.batchId }));
|
|
148
|
+
const r = await wrapper.cleanup({ jobId: opts.jobId, batchId: opts.batchId, force: !!opts.force });
|
|
149
|
+
printResult(io, ok('subagent-cleanup', r), opts);
|
|
150
|
+
});
|
|
151
|
+
addJsonOption(job.commands.find(c => c.name() === 'subagent-cleanup'));
|
|
152
|
+
// M3.2: wire the remaining 5 subcommand slots — block, checkpoint, continue, handoff, resume.
|
|
153
|
+
job
|
|
154
|
+
.command('checkpoint')
|
|
155
|
+
.requiredOption('--job-id <jid>')
|
|
156
|
+
.requiredOption('--slice-id <rid>')
|
|
157
|
+
.requiredOption('--state <done|failed|skipped>')
|
|
158
|
+
.option('--commit-sha <sha>')
|
|
159
|
+
.option('--reason <text>')
|
|
160
|
+
.option('--project <repo>')
|
|
161
|
+
.action(async (opts) => {
|
|
162
|
+
const parsed = JobCheckpointInputSchema.safeParse({
|
|
163
|
+
jobId: opts.jobId, sliceId: opts.sliceId, state: opts.state,
|
|
164
|
+
commitSha: opts.commitSha, reason: opts.reason,
|
|
165
|
+
project: projectRoot(opts), json: opts.json,
|
|
166
|
+
});
|
|
167
|
+
if (!parsed.success)
|
|
168
|
+
return printResult(io, fail('checkpoint', 'INVALID_CHECKPOINT', parsed.error.message, {}), opts);
|
|
169
|
+
const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
|
|
170
|
+
const orch = new JobOrchestrator(store);
|
|
171
|
+
if (parsed.data.state === 'done') {
|
|
172
|
+
await orch.checkpointDone({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, ...(parsed.data.commitSha ? { commitSha: parsed.data.commitSha } : {}) });
|
|
173
|
+
}
|
|
174
|
+
else if (parsed.data.state === 'skipped') {
|
|
175
|
+
await orch.checkpointSkipped({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
await orch.checkpointFailed({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
|
|
179
|
+
}
|
|
180
|
+
printResult(io, ok('checkpoint', { sliceId: parsed.data.sliceId, status: parsed.data.state }), opts);
|
|
181
|
+
});
|
|
182
|
+
addJsonOption(job.commands.find(c => c.name() === 'checkpoint'));
|
|
183
|
+
job
|
|
184
|
+
.command('block')
|
|
185
|
+
.requiredOption('--job-id <jid>')
|
|
186
|
+
.requiredOption('--slice-id <rid>')
|
|
187
|
+
.requiredOption('--reason <text>')
|
|
188
|
+
.option('--project <repo>')
|
|
189
|
+
.action(async (opts) => {
|
|
190
|
+
const parsed = JobBlockInputSchema.safeParse({
|
|
191
|
+
jobId: opts.jobId, sliceId: opts.sliceId, reason: opts.reason,
|
|
192
|
+
project: projectRoot(opts), json: opts.json,
|
|
193
|
+
});
|
|
194
|
+
if (!parsed.success)
|
|
195
|
+
return printResult(io, fail('block', 'INVALID_BLOCK', parsed.error.message, {}), opts);
|
|
196
|
+
const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
|
|
197
|
+
const orch = new JobOrchestrator(store);
|
|
198
|
+
await orch.blockSlice(parsed.data);
|
|
199
|
+
printResult(io, ok('block', { blocked: parsed.data.sliceId, reason: parsed.data.reason }), opts);
|
|
200
|
+
});
|
|
201
|
+
addJsonOption(job.commands.find(c => c.name() === 'block'));
|
|
202
|
+
job
|
|
203
|
+
.command('continue')
|
|
204
|
+
.requiredOption('--job-id <jid>')
|
|
205
|
+
.option('--project <repo>')
|
|
206
|
+
.action(async (opts) => {
|
|
207
|
+
const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
|
|
208
|
+
const orch = new JobOrchestrator(store);
|
|
209
|
+
const r = orch.continueNow(opts.jobId);
|
|
210
|
+
printResult(io, ok('continue', r), opts);
|
|
211
|
+
});
|
|
212
|
+
addJsonOption(job.commands.find(c => c.name() === 'continue'));
|
|
213
|
+
job
|
|
214
|
+
.command('resume')
|
|
215
|
+
.requiredOption('--job-id <jid>')
|
|
216
|
+
.option('--project <repo>')
|
|
217
|
+
.action(async (opts) => {
|
|
218
|
+
const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
|
|
219
|
+
const orch = new JobOrchestrator(store);
|
|
220
|
+
const s = orch.status(opts.jobId);
|
|
221
|
+
printResult(io, ok('resume', { resumed: opts.jobId, ...s }), opts);
|
|
222
|
+
});
|
|
223
|
+
addJsonOption(job.commands.find(c => c.name() === 'resume'));
|
|
224
|
+
job
|
|
225
|
+
.command('handoff')
|
|
226
|
+
.requiredOption('--job-id <jid>')
|
|
227
|
+
.option('--project <repo>')
|
|
228
|
+
.action(async (opts) => {
|
|
229
|
+
const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
|
|
230
|
+
const orch = new JobOrchestrator(store);
|
|
231
|
+
const s = orch.status(opts.jobId);
|
|
232
|
+
printResult(io, ok('handoff', { handoffFor: opts.jobId, ...s }), opts);
|
|
233
|
+
});
|
|
234
|
+
addJsonOption(job.commands.find(c => c.name() === 'handoff'));
|
|
235
|
+
program.addCommand(job);
|
|
236
|
+
}
|
package/dist/src/cli/program.js
CHANGED
|
@@ -74,6 +74,8 @@ import { registerObservabilityCommands } from './commands/observability-commands
|
|
|
74
74
|
// CLI primitives. See src/services/compact/ and
|
|
75
75
|
// src/cli/commands/compact-command.ts.
|
|
76
76
|
import { registerCompactCommands } from './commands/compact-command.js';
|
|
77
|
+
// Slice 2026-07-01-job-m3.1: `peaks job init|status|...` — Job orchestration CLI surface.
|
|
78
|
+
import { registerJobCommands } from './commands/job-commands.js';
|
|
77
79
|
import { applyRetention } from '../services/log/retention.js';
|
|
78
80
|
import { writeLogEntry, maybeWriteStderr } from '../services/log/logger.js';
|
|
79
81
|
export { printResult } from './cli-helpers.js';
|
|
@@ -341,5 +343,9 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
|
341
343
|
// suggest|recommend|survival|dry-run|force` — strategic-compact CLI
|
|
342
344
|
// primitives. See src/cli/commands/compact-command.ts.
|
|
343
345
|
registerCompactCommands(program, io);
|
|
346
|
+
// Slice 2026-07-01-job-m3.1: `peaks job init|status|...` — Job
|
|
347
|
+
// orchestration CLI surface. M3.1 wires init + status + 2 stubs;
|
|
348
|
+
// the remaining 5 subcommands land in M3.2.
|
|
349
|
+
registerJobCommands(program, io);
|
|
344
350
|
return program;
|
|
345
351
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub Job event emitter. Logs to stderr so Job lifecycle is visible during CLI runs.
|
|
3
|
+
* M7.5 batch-fix task will wire to a real statusline event bus if/when the project
|
|
4
|
+
* adds one. Until then, this is the only Job-event surface.
|
|
5
|
+
*/
|
|
6
|
+
export type JobEvent = {
|
|
7
|
+
kind: 'job-started';
|
|
8
|
+
jobId: string;
|
|
9
|
+
total: number;
|
|
10
|
+
strategy: 'single' | 'rotating';
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'job-progress';
|
|
13
|
+
jobId: string;
|
|
14
|
+
done: number;
|
|
15
|
+
total: number;
|
|
16
|
+
currentSlice?: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'job-blocked';
|
|
19
|
+
jobId: string;
|
|
20
|
+
sliceId: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'job-completed';
|
|
24
|
+
jobId: string;
|
|
25
|
+
done: number;
|
|
26
|
+
failed: number;
|
|
27
|
+
blocked: number;
|
|
28
|
+
skipped: number;
|
|
29
|
+
};
|
|
30
|
+
export declare function emitJobEvent(event: JobEvent): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { JobStateStore, type JobInitInput } from './job-state-store.js';
|
|
2
|
+
import { type JobState, type JobStatusSummary } from './job-types.js';
|
|
3
|
+
export interface CheckpointDoneInput {
|
|
4
|
+
jobId: string;
|
|
5
|
+
sliceId: string;
|
|
6
|
+
commitSha?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CheckpointSkipInput {
|
|
9
|
+
jobId: string;
|
|
10
|
+
sliceId: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
}
|
|
13
|
+
export interface BlockInput {
|
|
14
|
+
jobId: string;
|
|
15
|
+
sliceId: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
export declare class JobOrchestrator {
|
|
19
|
+
private readonly store;
|
|
20
|
+
constructor(store: JobStateStore);
|
|
21
|
+
init(input: JobInitInput): JobState;
|
|
22
|
+
/**
|
|
23
|
+
* Atomically apply a mutation to a job's state under a single-process lock.
|
|
24
|
+
*
|
|
25
|
+
* Uses the M2.1 single-process lock model (lock file checked with `existsSync`).
|
|
26
|
+
* State is loaded before lock acquisition, so `fn` receives a consistent snapshot;
|
|
27
|
+
* if `tryAcquireLock` throws because the job is already locked, `fn` is never called
|
|
28
|
+
* and the error bubbles to the caller. Concurrent `mutate` calls in the same process
|
|
29
|
+
* will collide, so callers MUST serialize externally or wrap calls in a higher-level
|
|
30
|
+
* mutex if multi-process support is added later.
|
|
31
|
+
*
|
|
32
|
+
* @param jobId - The job identifier whose state will be mutated.
|
|
33
|
+
* @param fn - Pure transform applied to the loaded state to produce the next state.
|
|
34
|
+
* @returns The new state after the mutation is persisted.
|
|
35
|
+
*/
|
|
36
|
+
private mutate;
|
|
37
|
+
checkpointDone(input: CheckpointDoneInput): Promise<JobState>;
|
|
38
|
+
checkpointSkipped(input: CheckpointSkipInput): Promise<JobState>;
|
|
39
|
+
checkpointFailed(input: {
|
|
40
|
+
jobId: string;
|
|
41
|
+
sliceId: string;
|
|
42
|
+
reason: string;
|
|
43
|
+
}): Promise<JobState>;
|
|
44
|
+
blockSlice(input: BlockInput): Promise<JobState>;
|
|
45
|
+
status(jobId: string): JobStatusSummary;
|
|
46
|
+
continueNow(jobId: string): {
|
|
47
|
+
remaining: number;
|
|
48
|
+
next: string | undefined;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export class JobOrchestrator {
|
|
2
|
+
store;
|
|
3
|
+
constructor(store) {
|
|
4
|
+
this.store = store;
|
|
5
|
+
}
|
|
6
|
+
init(input) {
|
|
7
|
+
return this.store.init(input);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Atomically apply a mutation to a job's state under a single-process lock.
|
|
11
|
+
*
|
|
12
|
+
* Uses the M2.1 single-process lock model (lock file checked with `existsSync`).
|
|
13
|
+
* State is loaded before lock acquisition, so `fn` receives a consistent snapshot;
|
|
14
|
+
* if `tryAcquireLock` throws because the job is already locked, `fn` is never called
|
|
15
|
+
* and the error bubbles to the caller. Concurrent `mutate` calls in the same process
|
|
16
|
+
* will collide, so callers MUST serialize externally or wrap calls in a higher-level
|
|
17
|
+
* mutex if multi-process support is added later.
|
|
18
|
+
*
|
|
19
|
+
* @param jobId - The job identifier whose state will be mutated.
|
|
20
|
+
* @param fn - Pure transform applied to the loaded state to produce the next state.
|
|
21
|
+
* @returns The new state after the mutation is persisted.
|
|
22
|
+
*/
|
|
23
|
+
mutate(jobId, fn) {
|
|
24
|
+
const state = this.store.load(jobId);
|
|
25
|
+
const lock = this.store.tryAcquireLock(jobId);
|
|
26
|
+
try {
|
|
27
|
+
const next = fn(state);
|
|
28
|
+
this.store.save(next);
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
this.store.releaseLock(lock);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async checkpointDone(input) {
|
|
36
|
+
return this.mutate(input.jobId, (s) => {
|
|
37
|
+
if (!input.commitSha || input.commitSha.length < 7) {
|
|
38
|
+
throw new Error('checkpointDone: commitSha required (≥7 hex)');
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
...s,
|
|
42
|
+
lastCheckpointAt: new Date().toISOString(),
|
|
43
|
+
slices: s.slices.map((sl) => sl.sliceId === input.sliceId
|
|
44
|
+
? { ...sl, status: 'done', commitSha: input.commitSha, finishedAt: new Date().toISOString() }
|
|
45
|
+
: sl),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async checkpointSkipped(input) {
|
|
50
|
+
return this.mutate(input.jobId, (s) => ({
|
|
51
|
+
...s,
|
|
52
|
+
lastCheckpointAt: new Date().toISOString(),
|
|
53
|
+
slices: s.slices.map((sl) => sl.sliceId === input.sliceId ? { ...sl, status: 'skipped' } : sl),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
async checkpointFailed(input) {
|
|
57
|
+
return this.mutate(input.jobId, (s) => ({
|
|
58
|
+
...s,
|
|
59
|
+
lastCheckpointAt: new Date().toISOString(),
|
|
60
|
+
slices: s.slices.map((sl) => sl.sliceId === input.sliceId
|
|
61
|
+
? { ...sl, status: 'failed', failureReason: input.reason, finishedAt: new Date().toISOString() }
|
|
62
|
+
: sl),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
async blockSlice(input) {
|
|
66
|
+
return this.mutate(input.jobId, (s) => ({
|
|
67
|
+
...s,
|
|
68
|
+
lastCheckpointAt: new Date().toISOString(),
|
|
69
|
+
slices: s.slices.map((sl) => sl.sliceId === input.sliceId
|
|
70
|
+
? { ...sl, status: 'blocked', blockedReason: input.reason, finishedAt: new Date().toISOString() }
|
|
71
|
+
: sl),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
status(jobId) {
|
|
75
|
+
const s = this.store.load(jobId);
|
|
76
|
+
const counts = { done: 0, failed: 0, blocked: 0, skipped: 0 };
|
|
77
|
+
for (const sl of s.slices) {
|
|
78
|
+
if (sl.status === 'done')
|
|
79
|
+
counts.done++;
|
|
80
|
+
else if (sl.status === 'failed')
|
|
81
|
+
counts.failed++;
|
|
82
|
+
else if (sl.status === 'blocked')
|
|
83
|
+
counts.blocked++;
|
|
84
|
+
else if (sl.status === 'skipped')
|
|
85
|
+
counts.skipped++;
|
|
86
|
+
}
|
|
87
|
+
const pendingIdx = s.slices.findIndex(sl => sl.status === 'pending' || sl.status === 'in-progress');
|
|
88
|
+
return {
|
|
89
|
+
total: s.slices.length,
|
|
90
|
+
done: counts.done,
|
|
91
|
+
failed: counts.failed,
|
|
92
|
+
blocked: counts.blocked,
|
|
93
|
+
skipped: counts.skipped,
|
|
94
|
+
currentSlice: pendingIdx >= 0 ? s.slices[pendingIdx].label : undefined,
|
|
95
|
+
lastCheckpoint: s.lastCheckpointAt,
|
|
96
|
+
mainLoopStrategy: s.mainLoopStrategy,
|
|
97
|
+
mainSessionCycle: s.mainSessionCycle,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
continueNow(jobId) {
|
|
101
|
+
const summary = this.status(jobId);
|
|
102
|
+
return { remaining: summary.total - summary.done - summary.skipped - summary.blocked - summary.failed, next: summary.currentSlice };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ResourceSnapshot } from './job-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Collects a coarse-grained snapshot of host + project resources.
|
|
4
|
+
* Numeric ranges are clamped to zod's domain — out-of-range values are clamped, not thrown,
|
|
5
|
+
* so callers can decide what to do.
|
|
6
|
+
*/
|
|
7
|
+
export declare function collectResourceSnapshot(jobDir: string): ResourceSnapshot;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Collects a coarse-grained snapshot of host + project resources.
|
|
6
|
+
* Numeric ranges are clamped to zod's domain — out-of-range values are clamped, not thrown,
|
|
7
|
+
* so callers can decide what to do.
|
|
8
|
+
*/
|
|
9
|
+
export function collectResourceSnapshot(jobDir) {
|
|
10
|
+
const cpus = os.cpus();
|
|
11
|
+
const loadAvg = (os.loadavg()[0] ?? 0) / (cpus.length || 1);
|
|
12
|
+
const cpuPercent = Math.max(0, Math.min(100, loadAvg * 100));
|
|
13
|
+
const totalMemMb = os.totalmem() / 1024 / 1024;
|
|
14
|
+
const freeMemMb = os.freemem() / 1024 / 1024;
|
|
15
|
+
const usedMemMb = Math.max(0, totalMemMb - freeMemMb);
|
|
16
|
+
const memMb = Math.round(usedMemMb);
|
|
17
|
+
const diskMb = dirSizeMb(jobDir);
|
|
18
|
+
// contextRatio is best-effort: pull from the same env-var the v2.13.0 auto-compact uses.
|
|
19
|
+
const envVal = process.env.CLAUDE_CONTEXT_USAGE_PERCENT;
|
|
20
|
+
const contextRatio = envVal ? Math.max(0, Math.min(1, Number(envVal) / 100)) : 0;
|
|
21
|
+
return {
|
|
22
|
+
capturedAt: new Date().toISOString(),
|
|
23
|
+
cpuPercent,
|
|
24
|
+
memMb,
|
|
25
|
+
diskMb,
|
|
26
|
+
contextRatio,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function dirSizeMb(dir) {
|
|
30
|
+
let total = 0;
|
|
31
|
+
try {
|
|
32
|
+
for (const name of readdirSync(dir)) {
|
|
33
|
+
try {
|
|
34
|
+
total += statSync(join(dir, name)).size;
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
// best-effort: missing entries (race with deletion) are silently skipped
|
|
38
|
+
void e;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
// best-effort: missing dir is treated as 0 bytes
|
|
44
|
+
void e;
|
|
45
|
+
}
|
|
46
|
+
return Math.round(total / 1024 / 1024);
|
|
47
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { JobStateStore } from './job-state-store.js';
|
|
2
|
+
export interface CycleOutcome {
|
|
3
|
+
rotated: boolean;
|
|
4
|
+
reason?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface CycleSummary {
|
|
7
|
+
jobId: string;
|
|
8
|
+
cycle: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class JobRotation {
|
|
11
|
+
private readonly store;
|
|
12
|
+
private readonly sessionRotateImpl;
|
|
13
|
+
private readonly sessionCycleSummaryImpl;
|
|
14
|
+
constructor(store: JobStateStore, sessionRotateImpl: (jobId: string) => Promise<CycleOutcome>, sessionCycleSummaryImpl: (jobId: string) => Promise<CycleSummary>);
|
|
15
|
+
cycleIfDue(jobId: string, slicesJustCompleted: number): Promise<CycleOutcome>;
|
|
16
|
+
rotateNow(jobId: string): Promise<CycleOutcome>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export class JobRotation {
|
|
2
|
+
store;
|
|
3
|
+
sessionRotateImpl;
|
|
4
|
+
sessionCycleSummaryImpl;
|
|
5
|
+
constructor(store, sessionRotateImpl, sessionCycleSummaryImpl) {
|
|
6
|
+
this.store = store;
|
|
7
|
+
this.sessionRotateImpl = sessionRotateImpl;
|
|
8
|
+
this.sessionCycleSummaryImpl = sessionCycleSummaryImpl;
|
|
9
|
+
}
|
|
10
|
+
async cycleIfDue(jobId, slicesJustCompleted) {
|
|
11
|
+
const state = this.store.load(jobId);
|
|
12
|
+
if (state.mainLoopStrategy !== 'rotating')
|
|
13
|
+
return { rotated: false, reason: 'single-mode' };
|
|
14
|
+
if (slicesJustCompleted <= 0)
|
|
15
|
+
return { rotated: false, reason: 'no-progress' };
|
|
16
|
+
if (slicesJustCompleted % state.rotateEvery !== 0)
|
|
17
|
+
return { rotated: false, reason: 'not-on-cadence' };
|
|
18
|
+
return this.rotateNow(jobId);
|
|
19
|
+
}
|
|
20
|
+
async rotateNow(jobId) {
|
|
21
|
+
const state = this.store.load(jobId);
|
|
22
|
+
const summary = await this.sessionCycleSummaryImpl(jobId);
|
|
23
|
+
const outcome = await this.sessionRotateImpl(jobId);
|
|
24
|
+
if (!outcome.rotated)
|
|
25
|
+
return { rotated: false, reason: 'session-rotate-refused' };
|
|
26
|
+
const nextCycle = state.mainSessionCycle + 1;
|
|
27
|
+
this.store.save({ ...state, mainSessionCycle: nextCycle, lastCheckpointAt: new Date().toISOString() });
|
|
28
|
+
return { rotated: true, reason: `cycle-${nextCycle} (${summary.cycle})` };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type JobState } from './job-types.js';
|
|
2
|
+
export interface JobInitInput {
|
|
3
|
+
jobId: string;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
sliceList: string[];
|
|
6
|
+
parallelismHint: 'serial' | 'llm-decides';
|
|
7
|
+
exitPolicy: 'strict' | 'best-effort';
|
|
8
|
+
mainLoopStrategy: 'single' | 'rotating';
|
|
9
|
+
rotateEvery: number;
|
|
10
|
+
}
|
|
11
|
+
export interface JobLock {
|
|
12
|
+
jobId: string;
|
|
13
|
+
path: string;
|
|
14
|
+
}
|
|
15
|
+
export declare class JobStateStore {
|
|
16
|
+
private readonly rootDir;
|
|
17
|
+
constructor(rootDir: string);
|
|
18
|
+
private jobDir;
|
|
19
|
+
init(input: JobInitInput): JobState;
|
|
20
|
+
load(jobId: string): JobState;
|
|
21
|
+
save(state: JobState): void;
|
|
22
|
+
tryAcquireLock(jobId: string): JobLock;
|
|
23
|
+
releaseLock(lock: JobLock): void;
|
|
24
|
+
}
|