infinity-harness 2.6.6 → 2.8.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 +80 -0
- package/README.md +68 -15
- package/extensions/infinity-harness/index.ts +600 -26
- package/harness/docs/ARCHITECTURE.md +13 -7
- package/harness/docs/CONSTRAINTS.md +13 -5
- package/harness/docs/DECISIONS.md +44 -0
- package/harness/docs/DOMAIN.md +44 -8
- package/package.json +1 -1
- package/src/core/config.ts +88 -1
- package/src/core/featureList.ts +85 -17
- package/src/core/gates.ts +8 -6
- package/src/core/init.ts +33 -3
- package/src/core/modelRouter.ts +149 -0
- package/src/core/paths.ts +29 -0
- package/src/core/plan.ts +39 -0
- package/src/core/runState.ts +151 -0
- package/src/core/settings.ts +138 -4
- package/src/core/types.ts +49 -0
- package/src/daemon/budget.ts +94 -0
- package/src/daemon/guard.ts +113 -0
- package/src/daemon/index.ts +421 -0
- package/src/daemon/isolation.ts +95 -0
- package/src/daemon/preflight.ts +132 -0
- package/src/daemon/server.ts +153 -0
- package/src/daemon/supervisorState.ts +83 -0
- package/src/daemon/worker.ts +239 -0
- package/src/daemon/worktree.ts +95 -0
- package/src/exec/piWorker.ts +706 -0
- package/src/goalState.ts +2 -22
- package/src/intake.ts +4 -1
- package/src/loop.ts +35 -34
- package/src/modelRouter.ts +0 -0
- package/src/remote.ts +28 -7
- package/src/replan.ts +7 -3
- package/src/rework.ts +9 -3
- package/src/runState.ts +15 -121
- package/src/scheduler.ts +115 -135
- package/src/supervisor.ts +955 -0
- package/src/taskList.ts +41 -3
- package/src/ui/dashboard.ts +127 -0
- package/src/ui/viewState.ts +77 -0
- package/src/ui/widget.ts +189 -0
- package/src/ui/wizard.ts +43 -7
- package/src/unstuck.ts +0 -0
- package/src/worker.ts +12 -8
package/src/goalState.ts
CHANGED
|
@@ -9,9 +9,8 @@ import {
|
|
|
9
9
|
validateGoalLoopState,
|
|
10
10
|
} from "./goalLoop.ts";
|
|
11
11
|
import { type GoalSpecification, validateGoalSpecification } from "./goalSpec.ts";
|
|
12
|
-
import { existsSync, mkdirSync,
|
|
12
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { dirname, resolve } from "node:path";
|
|
14
|
-
declare const require: any;
|
|
15
14
|
|
|
16
15
|
export const GOAL_STATE_FILE = "GOAL_STATE.json";
|
|
17
16
|
export const GOAL_TRACE_FILE = "GOAL_TRACE.jsonl";
|
|
@@ -25,26 +24,7 @@ export function canonicalGoalSpecPath(projectDir = process.cwd()): string {
|
|
|
25
24
|
return resolve(projectDir, CANONICAL_GOAL_SPEC_DIR, CANONICAL_GOAL_SPEC_FILE);
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
const target = canonicalGoalSpecPath(projectDir);
|
|
30
|
-
// try proper-lockfile sync-ish via dynamic import fallback to plain write
|
|
31
|
-
try {
|
|
32
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
33
|
-
// use proper-lockfile if available (async variant would need async; use sync file write with lock attempt)
|
|
34
|
-
// For sync canonical we rely on atomic tmp+rename and ignore lock if unavailable — async wrapper below handles lock
|
|
35
|
-
const tmp = `${target}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
36
|
-
writeFileSync(tmp, content, "utf8");
|
|
37
|
-
// rename via node:fs renameSync equivalent (import already has rename async, but we use writeFileSync+rename via fs)
|
|
38
|
-
const { renameSync } = require("node:fs");
|
|
39
|
-
renameSync(tmp, target);
|
|
40
|
-
} catch {
|
|
41
|
-
// fallback simple write
|
|
42
|
-
try {
|
|
43
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
44
|
-
writeFileSync(target, content, "utf8");
|
|
45
|
-
} catch {}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
27
|
+
|
|
48
28
|
|
|
49
29
|
|
|
50
30
|
export interface GoalStateStoreOptions {
|
package/src/intake.ts
CHANGED
|
@@ -56,6 +56,8 @@ export type IntakeAnswers = {
|
|
|
56
56
|
/** Session handoff policy. Defaults to a fresh session per phase. */
|
|
57
57
|
handoff?: SessionPolicy["handoff"];
|
|
58
58
|
parallelAt?: import("./core/types.ts").HandoffGranularity;
|
|
59
|
+
/** Where the work runs. Defaults to background — its own pi session per unit. */
|
|
60
|
+
engine?: import("./core/types.ts").ExecutionEngine;
|
|
59
61
|
maxWorkers?: number;
|
|
60
62
|
/** What the surfaces should draw. Defaults to the `focus` template. */
|
|
61
63
|
display?: DisplayPolicy;
|
|
@@ -126,6 +128,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
126
128
|
carryNotes: true,
|
|
127
129
|
};
|
|
128
130
|
const parallelAt = answers.parallelAt ?? "task";
|
|
131
|
+
const engine = answers.engine ?? "background";
|
|
129
132
|
const maxWorkers = Math.max(1, Math.min(16, Number.isFinite(answers.maxWorkers as number) ? Math.floor(answers.maxWorkers as number) : 3));
|
|
130
133
|
|
|
131
134
|
const display = normalizeDisplay(answers.display ?? defaultDisplay());
|
|
@@ -174,7 +177,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
174
177
|
plan: phaseModes.plan === "copilot",
|
|
175
178
|
},
|
|
176
179
|
session,
|
|
177
|
-
execution: { parallelAt, maxWorkers },
|
|
180
|
+
execution: { engine, parallelAt, maxWorkers, isolation: "worktree" as const },
|
|
178
181
|
display,
|
|
179
182
|
router: answers.router,
|
|
180
183
|
summary: summarize(workflow, phases, phaseModes, session, display, brief, _researchDepth),
|
package/src/loop.ts
CHANGED
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
describeEscalation,
|
|
39
39
|
type EscalationState,
|
|
40
40
|
} from "./escalate.ts";
|
|
41
|
-
import { executionPolicyOf, pickRunnableTasks } from "./scheduler.ts";
|
|
42
41
|
import { loadGoal, recordPipelinePass, viewOf } from "./goal.ts";
|
|
43
42
|
import {
|
|
44
43
|
needsApproval,
|
|
@@ -95,9 +94,29 @@ export type LoopBudget = {
|
|
|
95
94
|
noProgressLimit: number;
|
|
96
95
|
};
|
|
97
96
|
|
|
97
|
+
/**
|
|
98
|
+
* What the supervisor needs to know about an escalation, without re-deriving
|
|
99
|
+
* it. A rung that names a stronger model is a model switch, and a model
|
|
100
|
+
* switch is a new worker session — the supervisor cannot act on `reason`.
|
|
101
|
+
*/
|
|
102
|
+
export type DecisionEscalation = { strategy: string; model: string | null; level: string | null };
|
|
103
|
+
|
|
98
104
|
export type LoopDecision =
|
|
99
|
-
| {
|
|
100
|
-
|
|
105
|
+
| {
|
|
106
|
+
action: "continue";
|
|
107
|
+
message: string;
|
|
108
|
+
reason: string;
|
|
109
|
+
/**
|
|
110
|
+
* The instruction on its own, without the brief appended.
|
|
111
|
+
*
|
|
112
|
+
* A worker that is already on this unit has the brief in its context
|
|
113
|
+
* from the turn before; re-sending several kilobytes of it every cycle
|
|
114
|
+
* is the single largest avoidable cost in a long run.
|
|
115
|
+
*/
|
|
116
|
+
headline?: string;
|
|
117
|
+
escalation?: DecisionEscalation | null;
|
|
118
|
+
}
|
|
119
|
+
| { action: "advanced"; toPhase: Phase; message: string; reason: string; headline?: string }
|
|
101
120
|
| { action: "stop"; reason: string; detail: string }
|
|
102
121
|
| { action: "wait"; reason: string; detail: string }
|
|
103
122
|
/**
|
|
@@ -440,6 +459,9 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
440
459
|
action: "advanced",
|
|
441
460
|
toPhase: upcoming,
|
|
442
461
|
message: renderBrief(brief, moved.config ?? undefined),
|
|
462
|
+
headline:
|
|
463
|
+
`The ${phase.toUpperCase()} gate passed and the run has advanced to ` +
|
|
464
|
+
`${upcoming.toUpperCase()}. Work the new phase now.`,
|
|
443
465
|
reason: `gate passed on ${phase}`,
|
|
444
466
|
});
|
|
445
467
|
}
|
|
@@ -550,44 +572,19 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
550
572
|
|
|
551
573
|
// Charge retries at the active level as well as the legacy phase counter.
|
|
552
574
|
const fresh = loadConfig(targetDir);
|
|
553
|
-
let execPolicy: { parallelAt: string; maxWorkers: number } | null = null;
|
|
554
575
|
if (fresh.ok) {
|
|
555
|
-
execPolicy = executionPolicyOf(fresh.config) as { parallelAt: string; maxWorkers: number };
|
|
556
576
|
incrementRetryLevel(fresh.config, retryLevel ?? "phase");
|
|
557
577
|
// Keep phase counter in step as the global guard so existing budgets still fire.
|
|
558
578
|
if ((retryLevel ?? "phase") !== "phase") incrementPhaseRetry(fresh.config);
|
|
559
579
|
saveConfig(targetDir, fresh.config);
|
|
560
580
|
}
|
|
561
581
|
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
try {
|
|
569
|
-
if (execPolicy && execPolicy.parallelAt !== "off" && fresh?.ok) {
|
|
570
|
-
const eligible = pickRunnableTasks({
|
|
571
|
-
targetDir,
|
|
572
|
-
phase: fresh.config.currentPhase as import("./core/types.ts").Phase | null,
|
|
573
|
-
parallelAt: execPolicy.parallelAt as import("./core/types.ts").HandoffGranularity,
|
|
574
|
-
maxWorkers: execPolicy.maxWorkers,
|
|
575
|
-
});
|
|
576
|
-
if (eligible.length > 0) {
|
|
577
|
-
const { spawnWorkers } = await import("./scheduler.ts");
|
|
578
|
-
const curBrief = await buildBrief(targetDir);
|
|
579
|
-
const briefFor = (t: import("./core/featureList.ts").FlatTask): string =>
|
|
580
|
-
`Task ${t.compositeKey} in ${fresh.config.currentPhase}: ${t.description}` +
|
|
581
|
-
`\nAcceptance: ${(t.criteria ?? (curBrief.criteria ?? [])).join("; ")}`;
|
|
582
|
-
// Fire-and-forget so the brief still returns promptly; harness does not
|
|
583
|
-
// depend on the child process (covered by e2e). Errors are best-effort.
|
|
584
|
-
spawnWorkers(targetDir, eligible, {
|
|
585
|
-
runId,
|
|
586
|
-
promptFor: briefFor,
|
|
587
|
-
}).catch(() => {});
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
} catch {}
|
|
582
|
+
// There used to be a fire-and-forget `spawnWorkers` here. It spawned
|
|
583
|
+
// workers with no command, so all it ever did was create an empty
|
|
584
|
+
// `tmp/infinity-harness/<run>/<feature>/<task>/attempt-N/` on every failing
|
|
585
|
+
// gate — litter in the user's tree, an unawaited promise that outlived the
|
|
586
|
+
// call, and no work. Background execution is the supervisor's job now
|
|
587
|
+
// (`src/supervisor.ts`), which spawns a real pi session and waits for it.
|
|
591
588
|
|
|
592
589
|
const brief = await buildBrief(targetDir);
|
|
593
590
|
const failures = gate
|
|
@@ -618,6 +615,10 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
618
615
|
: escalation?.strategy
|
|
619
616
|
? `escalated: ${describeEscalation(escalation)}`
|
|
620
617
|
: "gate failed",
|
|
618
|
+
headline: head,
|
|
619
|
+
escalation: escalation?.strategy
|
|
620
|
+
? { strategy: escalation.strategy, model: escalation.model ?? null, level: retryLevel ?? null }
|
|
621
|
+
: null,
|
|
621
622
|
message: `${head}\n${renderBrief(brief, fresh.ok ? fresh.config : undefined)}`,
|
|
622
623
|
});
|
|
623
624
|
}
|
package/src/modelRouter.ts
CHANGED
|
File without changes
|
package/src/remote.ts
CHANGED
|
@@ -25,7 +25,9 @@ import { modelRouterPath, reworkPath } from "./core/paths.ts";
|
|
|
25
25
|
import { readJsonSafe } from "./core/fsx.ts";
|
|
26
26
|
import { loadRunState } from "./runState.ts";
|
|
27
27
|
import { normalizeDisplay } from "./ui/display.ts";
|
|
28
|
+
import { supervisorStatePath, activityPath } from "./supervisor.ts";
|
|
28
29
|
import { renderDashboard, escapeHtml, type DashboardState } from "./ui/dashboard.ts";
|
|
30
|
+
import { executionPolicyOf } from "./scheduler.ts";
|
|
29
31
|
|
|
30
32
|
export { escapeHtml };
|
|
31
33
|
|
|
@@ -58,7 +60,13 @@ export interface RemoteState {
|
|
|
58
60
|
/** What the reader has asked the dashboard to draw. */
|
|
59
61
|
display: DisplayPolicy;
|
|
60
62
|
execution: unknown;
|
|
63
|
+
/** Where the work runs: background pi sessions, or this session. */
|
|
64
|
+
engine: "background" | "main-session" | null;
|
|
65
|
+
/** The supervisor's state file, verbatim. Opaque here. */
|
|
66
|
+
supervisor: unknown;
|
|
61
67
|
workers: unknown;
|
|
68
|
+
/** Tail of the background log. */
|
|
69
|
+
activity: unknown;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
export interface RemoteServer {
|
|
@@ -118,14 +126,21 @@ export function buildRemoteState(projectDir?: string): RemoteState {
|
|
|
118
126
|
rework: readJsonSafe<unknown>(reworkPath(dir), null),
|
|
119
127
|
awaitingApproval: config.awaitingApproval ?? null,
|
|
120
128
|
sessions: loadRunState(dir)?.sessions ?? null,
|
|
121
|
-
execution: (() => { try {
|
|
129
|
+
execution: (() => { try { return executionPolicyOf(config); } catch { return null; } })(),
|
|
130
|
+
engine: (() => { try { return executionPolicyOf(config).engine as "background" | "main-session"; } catch { return null; } })(),
|
|
131
|
+
// The supervisor's own state is the truth about what is running. This used
|
|
132
|
+
// to scan the attempt-directory tree, which reported every attempt ever
|
|
133
|
+
// made as a live worker.
|
|
134
|
+
supervisor: readJsonSafe<unknown>(supervisorStatePath(dir), null),
|
|
122
135
|
workers: (() => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
const sup = readJsonSafe<{ worker?: unknown; history?: unknown[] } | null>(supervisorStatePath(dir), null);
|
|
137
|
+
const live = sup?.worker ? [sup.worker] : [];
|
|
138
|
+
const past = Array.isArray(sup?.history) ? sup!.history!.slice(-3).reverse() : [];
|
|
139
|
+
return [...live, ...past];
|
|
140
|
+
})(),
|
|
141
|
+
activity: (() => {
|
|
142
|
+
const raw = readJsonSafe<{ lines?: unknown[] } | null>(activityPath(dir), null);
|
|
143
|
+
return Array.isArray(raw?.lines) ? raw!.lines!.slice(-60) : [];
|
|
129
144
|
})(),
|
|
130
145
|
goalPass:
|
|
131
146
|
typeof config.goalPass === "number" && typeof config.goalMaxPasses === "number"
|
|
@@ -152,6 +167,9 @@ function toDashboardState(s: RemoteState): DashboardState {
|
|
|
152
167
|
sessions: s.sessions,
|
|
153
168
|
goalPass: s.goalPass,
|
|
154
169
|
display: s.display,
|
|
170
|
+
engine: s.engine,
|
|
171
|
+
workers: (s.workers as import("./ui/dashboard.ts").DashWorker[] | null) ?? [],
|
|
172
|
+
activity: (s.activity as import("./ui/dashboard.ts").DashActivity[] | null) ?? [],
|
|
155
173
|
};
|
|
156
174
|
}
|
|
157
175
|
|
|
@@ -173,6 +191,9 @@ export function buildApiPayload(state: RemoteState & { dashboardUrl?: string | n
|
|
|
173
191
|
rework: state.rework,
|
|
174
192
|
awaitingApproval: state.awaitingApproval,
|
|
175
193
|
sessions: state.sessions,
|
|
194
|
+
engine: state.engine,
|
|
195
|
+
workers: state.workers,
|
|
196
|
+
activity: state.activity,
|
|
176
197
|
goalPass: state.goalPass,
|
|
177
198
|
sprints: state.sprints,
|
|
178
199
|
display: state.display,
|
package/src/replan.ts
CHANGED
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
* implementation of each now, in `core/`.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { unlinkSync } from "node:fs";
|
|
23
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
24
24
|
import type { Feature, FeatureList, Subtask, Task, TaskStatus } from "./core/types.ts";
|
|
25
25
|
import { TASK_STATUSES } from "./core/types.ts";
|
|
26
26
|
import { detectCycle, flattenTasks, loadFeatureList, saveFeatureList } from "./core/featureList.ts";
|
|
27
27
|
import { fileExists, readJsonSafe, writeJsonAtomic } from "./core/fsx.ts";
|
|
28
28
|
import { loadConfig } from "./core/config.ts";
|
|
29
|
-
import {
|
|
29
|
+
import { planPath, replanPath } from "./core/paths.ts";
|
|
30
30
|
import { withLockSync } from "./core/lock.ts";
|
|
31
31
|
|
|
32
32
|
/**
|
|
@@ -161,8 +161,12 @@ function appendReplanHistory(projectDir: string, entry: ReplanHistoryEntry): voi
|
|
|
161
161
|
|
|
162
162
|
function readMaxReplans(projectDir: string): number {
|
|
163
163
|
const { config } = loadConfig(projectDir);
|
|
164
|
+
const lim = (config as unknown as { limits?: { maxReplansPerPhase?: unknown } }).limits as { maxReplansPerPhase?: unknown } | undefined;
|
|
165
|
+
let hasLimitsFile = false;
|
|
166
|
+
try { const p = `${projectDir}/harness/config.json`; if (existsSync(p)) { const raw = JSON.parse(readFileSync(p,"utf-8")); if (raw && typeof raw.limits === "object") hasLimitsFile = true; } } catch {}
|
|
164
167
|
const replan = config.replan as { maxReplans?: unknown; maxReplansPerRun?: unknown } | undefined;
|
|
165
168
|
const budgets = config.budgets as { maxReplansPerRun?: unknown } | undefined;
|
|
169
|
+
if (hasLimitsFile && typeof lim?.maxReplansPerPhase === "number") return lim.maxReplansPerPhase;
|
|
166
170
|
if (typeof replan?.maxReplans === "number") return replan.maxReplans;
|
|
167
171
|
if (typeof replan?.maxReplansPerRun === "number") return replan.maxReplansPerRun;
|
|
168
172
|
if (typeof budgets?.maxReplansPerRun === "number") return budgets.maxReplansPerRun;
|
|
@@ -198,7 +202,7 @@ export async function amendPlan(opts: AmendPlanOpts): Promise<AmendPlanResult> {
|
|
|
198
202
|
|
|
199
203
|
// Read, amend, validate, bump, write — one atomic section. Adding to the
|
|
200
204
|
// plan is a read-apply-write over the same file every parallel worker edits.
|
|
201
|
-
const result = withLockSync(
|
|
205
|
+
const result = withLockSync(planPath(projectDir), () => {
|
|
202
206
|
const list = loadPlan(projectDir);
|
|
203
207
|
|
|
204
208
|
let addedSprints = 0;
|
package/src/rework.ts
CHANGED
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
* other. One implementation of each now, in `core/`.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { unlinkSync } from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
21
21
|
import type { FeatureList, Task } from "./core/types.ts";
|
|
22
22
|
import { flattenTasks, loadFeatureList, saveFeatureList } from "./core/featureList.ts";
|
|
23
23
|
import { fileExists, readJsonSafe, writeJsonAtomic } from "./core/fsx.ts";
|
|
24
24
|
import { loadConfig } from "./core/config.ts";
|
|
25
|
-
import {
|
|
25
|
+
import { planPath, reworkPath } from "./core/paths.ts";
|
|
26
26
|
import { withLockSync } from "./core/lock.ts";
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -165,8 +165,14 @@ function appendReworkRecord(projectDir: string, record: ReworkRecord): void {
|
|
|
165
165
|
|
|
166
166
|
function readMaxReworks(projectDir: string): number {
|
|
167
167
|
const { config } = loadConfig(projectDir);
|
|
168
|
+
const lim = (config as unknown as { limits?: { maxReworkPerUnit?: unknown } }).limits as { maxReworkPerUnit?: unknown } | undefined;
|
|
169
|
+
// limits.maxReworkPerUnit wins only when writable: config actually has a limits file with it.
|
|
170
|
+
// Test configs write { rework: {maxReworks: 3} } onto a partial config that still has DEFAULT_LIMITS via merge — without this guard every test would see 2.
|
|
171
|
+
let hasLimitsFile = false;
|
|
172
|
+
try { const p = `${projectDir}/harness/config.json`; if (existsSync(p)) { const raw = JSON.parse(readFileSync(p,"utf-8")); if (raw && typeof raw.limits === "object") hasLimitsFile = true; } } catch {}
|
|
168
173
|
const rework = config.rework as { maxReworks?: unknown } | undefined;
|
|
169
174
|
const budgets = config.budgets as { maxReworksPerRun?: unknown } | undefined;
|
|
175
|
+
if (hasLimitsFile && typeof lim?.maxReworkPerUnit === "number") return lim.maxReworkPerUnit;
|
|
170
176
|
if (typeof rework?.maxReworks === "number") return rework.maxReworks;
|
|
171
177
|
if (typeof budgets?.maxReworksPerRun === "number") return budgets.maxReworksPerRun;
|
|
172
178
|
return DEFAULT_MAX_REWORKS;
|
|
@@ -209,7 +215,7 @@ export async function startRework(opts: StartReworkOpts): Promise<StartReworkRes
|
|
|
209
215
|
|
|
210
216
|
// Read, flip, bump, write — one atomic section. The status flip is a
|
|
211
217
|
// read-apply-write over the same file every parallel worker edits.
|
|
212
|
-
const result = withLockSync(
|
|
218
|
+
const result = withLockSync(planPath(projectDir), () => {
|
|
213
219
|
const list = loadPlan(projectDir);
|
|
214
220
|
const tasks = flattenTasks(list);
|
|
215
221
|
if (!tasks.some((t) => taskKey(t) === originKey)) {
|
package/src/runState.ts
CHANGED
|
@@ -1,121 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*
|
|
17
|
-
* A run is a property of the project, not of the terminal window that started
|
|
18
|
-
* it. It lives on disk.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { runStatePath } from "./core/paths.ts";
|
|
22
|
-
import { readJsonSafe, writeJsonAtomic, removeFile } from "./core/fsx.ts";
|
|
23
|
-
|
|
24
|
-
export type RunState = {
|
|
25
|
-
/** Whether the loop should keep driving. Read on every session start. */
|
|
26
|
-
armed: boolean;
|
|
27
|
-
/** Stable across every session this run spans. */
|
|
28
|
-
runId: string;
|
|
29
|
-
startedAt: string;
|
|
30
|
-
/** How many pi sessions this run has used. Shown in the widget. */
|
|
31
|
-
sessions: number;
|
|
32
|
-
/** Why the run last stopped, so a returning human is not left guessing. */
|
|
33
|
-
stoppedAt: string | null;
|
|
34
|
-
stopReason: string | null;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export function newRunState(runId: string, now = new Date()): RunState {
|
|
38
|
-
return {
|
|
39
|
-
armed: true,
|
|
40
|
-
runId,
|
|
41
|
-
startedAt: now.toISOString(),
|
|
42
|
-
sessions: 1,
|
|
43
|
-
stoppedAt: null,
|
|
44
|
-
stopReason: null,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function loadRunState(targetDir: string): RunState | null {
|
|
49
|
-
const raw = readJsonSafe<Partial<RunState> | null>(runStatePath(targetDir), null);
|
|
50
|
-
if (!raw || typeof raw.runId !== "string" || !raw.runId) return null;
|
|
51
|
-
return {
|
|
52
|
-
armed: raw.armed === true,
|
|
53
|
-
runId: raw.runId,
|
|
54
|
-
startedAt: typeof raw.startedAt === "string" ? raw.startedAt : new Date(0).toISOString(),
|
|
55
|
-
sessions: typeof raw.sessions === "number" && raw.sessions > 0 ? raw.sessions : 1,
|
|
56
|
-
stoppedAt: typeof raw.stoppedAt === "string" ? raw.stoppedAt : null,
|
|
57
|
-
stopReason: typeof raw.stopReason === "string" ? raw.stopReason : null,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function saveRunState(targetDir: string, state: RunState): void {
|
|
62
|
-
try {
|
|
63
|
-
writeJsonAtomic(runStatePath(targetDir), state);
|
|
64
|
-
} catch {
|
|
65
|
-
// Losing the file costs the run its cross-session budgets, which is bad,
|
|
66
|
-
// but throwing here would kill the session, which is worse.
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Arm a run. Reuses the existing run id when one is already armed. */
|
|
71
|
-
export function armRun(targetDir: string, runId: string, now = new Date()): RunState {
|
|
72
|
-
const existing = loadRunState(targetDir);
|
|
73
|
-
const state =
|
|
74
|
-
existing && existing.armed
|
|
75
|
-
? { ...existing, stoppedAt: null, stopReason: null }
|
|
76
|
-
: newRunState(runId, now);
|
|
77
|
-
saveRunState(targetDir, state);
|
|
78
|
-
return state;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function disarmRun(targetDir: string, reason: string, now = new Date()): RunState | null {
|
|
82
|
-
const existing = loadRunState(targetDir);
|
|
83
|
-
if (!existing) return null;
|
|
84
|
-
const state: RunState = {
|
|
85
|
-
...existing,
|
|
86
|
-
armed: false,
|
|
87
|
-
stoppedAt: now.toISOString(),
|
|
88
|
-
stopReason: reason,
|
|
89
|
-
};
|
|
90
|
-
saveRunState(targetDir, state);
|
|
91
|
-
return state;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Count one more pi session against this run. Called from `session_start`. */
|
|
95
|
-
export function countSession(targetDir: string): RunState | null {
|
|
96
|
-
const existing = loadRunState(targetDir);
|
|
97
|
-
if (!existing) return null;
|
|
98
|
-
const state = { ...existing, sessions: existing.sessions + 1 };
|
|
99
|
-
saveRunState(targetDir, state);
|
|
100
|
-
return state;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function clearRunState(targetDir: string): void {
|
|
104
|
-
try {
|
|
105
|
-
removeFile(runStatePath(targetDir));
|
|
106
|
-
} catch {
|
|
107
|
-
/* nothing to clear */
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The run id the loop should use.
|
|
113
|
-
*
|
|
114
|
-
* An armed run keeps its id so `loadLoopState` finds the same budgets after a
|
|
115
|
-
* handoff. With nothing armed, the caller's session id is the run id — an
|
|
116
|
-
* ad-hoc `/infinity:validate` should not inherit a finished run's strikes.
|
|
117
|
-
*/
|
|
118
|
-
export function runIdFor(targetDir: string, fallback: string): string {
|
|
119
|
-
const state = loadRunState(targetDir);
|
|
120
|
-
return state && state.armed ? state.runId : fallback;
|
|
121
|
-
}
|
|
1
|
+
// Re-export from core so both import paths work.
|
|
2
|
+
// Canonical type now lives in src/core/runState.ts (with baseModel/tiers/budget).
|
|
3
|
+
export {
|
|
4
|
+
loadRunState,
|
|
5
|
+
saveRunState,
|
|
6
|
+
armRun,
|
|
7
|
+
disarmRun,
|
|
8
|
+
countSession,
|
|
9
|
+
clearRunState,
|
|
10
|
+
runIdFor,
|
|
11
|
+
newRunState,
|
|
12
|
+
parseBaseModelString,
|
|
13
|
+
createUsageTotals,
|
|
14
|
+
} from "./core/runState.ts";
|
|
15
|
+
export type { RunState, ProviderModel, TierResults, TierPreflight, Budget, UsageTotals } from "./core/runState.ts";
|