peaks-loop 3.0.3 → 3.1.1

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.
@@ -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
+ }
@@ -0,0 +1,60 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { JobStateSchema } from './job-types.js';
4
+ export class JobStateStore {
5
+ rootDir;
6
+ constructor(rootDir) {
7
+ this.rootDir = rootDir;
8
+ }
9
+ jobDir(jobId) {
10
+ return join(this.rootDir, jobId);
11
+ }
12
+ init(input) {
13
+ const state = JobStateSchema.parse({
14
+ jobId: input.jobId,
15
+ sessionId: input.sessionId,
16
+ startedAt: new Date().toISOString(),
17
+ lastCheckpointAt: new Date().toISOString(),
18
+ parallelismHint: input.parallelismHint,
19
+ exitPolicy: input.exitPolicy,
20
+ mainLoopStrategy: input.mainLoopStrategy,
21
+ rotateEvery: input.rotateEvery,
22
+ mainSessionCycle: 0,
23
+ slices: input.sliceList.map((label, i) => ({
24
+ sliceId: `slice-${String(i + 1).padStart(3, '0')}`,
25
+ label,
26
+ status: 'pending',
27
+ repairCycles: 0,
28
+ })),
29
+ });
30
+ this.save(state);
31
+ return state;
32
+ }
33
+ load(jobId) {
34
+ const p = join(this.jobDir(jobId), 'state.json');
35
+ if (!existsSync(p))
36
+ throw new Error(`JobStateStore.load: no state for ${jobId} at ${p}`);
37
+ return JobStateSchema.parse(JSON.parse(readFileSync(p, 'utf8')));
38
+ }
39
+ save(state) {
40
+ // Re-parse to fail loudly on schema regression (M2 unit-test expects this).
41
+ const valid = JobStateSchema.parse(state);
42
+ const dir = this.jobDir(valid.jobId);
43
+ mkdirSync(dir, { recursive: true });
44
+ const path = join(dir, 'state.json');
45
+ writeFileSync(path, JSON.stringify(valid, null, 2) + '\n', 'utf8');
46
+ }
47
+ tryAcquireLock(jobId) {
48
+ const lockPath = join(this.jobDir(jobId), 'state.lock');
49
+ mkdirSync(dirname(lockPath), { recursive: true });
50
+ if (existsSync(lockPath))
51
+ throw new Error(`JobStateStore: state already locked at ${lockPath}`);
52
+ writeFileSync(lockPath, `${process.pid}@${new Date().toISOString()}\n`, 'utf8');
53
+ return { jobId, path: lockPath };
54
+ }
55
+ releaseLock(lock) {
56
+ if (existsSync(lock.path)) {
57
+ unlinkSync(lock.path);
58
+ }
59
+ }
60
+ }