pi-plans 0.3.2 → 0.3.3
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 +5 -3
- package/index.ts +92 -6
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +7 -1
- package/references/plan-artifact-template.md +4 -0
- package/references/state-and-config.md +19 -0
- package/src/autocomplete.ts +2 -1
- package/src/code-graph/commands.ts +21 -6
- package/src/compaction.ts +10 -0
- package/src/config-command.ts +2 -0
- package/src/exec.ts +256 -10
- package/src/guard.ts +7 -1
- package/src/resume-command.ts +450 -0
- package/src/resume.ts +205 -0
- package/src/run-context.ts +97 -0
- package/src/run-ownership.ts +310 -0
- package/src/state.ts +24 -1
- package/src/termination-prompt.ts +8 -0
- package/src/workflow-state.ts +1159 -0
- package/tests/ask-choice.test.ts +113 -0
- package/tests/compaction.test.ts +39 -0
- package/tests/exec.test.ts +250 -0
- package/tests/guard.test.ts +46 -0
- package/tests/plans.test.ts +71 -0
- package/tests/refine-resume.test.ts +324 -0
- package/tests/resume-lifecycle.test.ts +385 -0
- package/tests/resume.test.ts +384 -0
- package/tests/run-context.test.ts +119 -0
- package/tests/run-ownership.test.ts +170 -0
- package/tests/state.test.ts +16 -0
- package/tests/workflow-state.test.ts +432 -0
- package/tools/analyze-refs.ts +2 -1
- package/tools/ask-choice.ts +61 -3
- package/tools/code-graph.ts +5 -1
- package/tools/execute-plan.ts +2 -1
- package/tools/plans.ts +119 -0
- package/tools/refine.ts +91 -7
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-bound run attribution (I-002).
|
|
3
|
+
*
|
|
4
|
+
* The shared `active.json` pointer is repo-wide and races across sessions
|
|
5
|
+
* and linked worktrees. This module keeps the CURRENT session's run binding
|
|
6
|
+
* (keyed by the SessionManager identity so it never leaks across session
|
|
7
|
+
* replacement) and resolves "the run this session is working on" with the
|
|
8
|
+
* binding first, falling back to the shared pointer for sessions that never
|
|
9
|
+
* bound a run (legacy behavior).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { getRun, readActive, runDirPath, type ActiveInfo } from "./state.ts";
|
|
14
|
+
|
|
15
|
+
interface RunBinding {
|
|
16
|
+
runId: string;
|
|
17
|
+
workdir: string;
|
|
18
|
+
/** SessionManager identity — stable within one session, replaced on /new, /resume, /fork. */
|
|
19
|
+
session: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let binding: RunBinding | null = null;
|
|
23
|
+
|
|
24
|
+
/** Bind this session to a run. Called on start-run, execution start, and /resume-plans. */
|
|
25
|
+
export function bindRun(session: unknown, workdir: string, runId: string): void {
|
|
26
|
+
binding = { runId, workdir: path.resolve(workdir), session };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Clear the binding (only the session that owns it may clear it). */
|
|
30
|
+
export function clearRunBinding(session: unknown): void {
|
|
31
|
+
if (binding?.session === session) binding = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The run id bound to THIS session for THIS workdir, or null. */
|
|
35
|
+
export function boundRunId(session: unknown, workdir: string): string | null {
|
|
36
|
+
if (binding === null) return null;
|
|
37
|
+
if (binding.session !== session) return null;
|
|
38
|
+
if (path.resolve(binding.workdir) !== path.resolve(workdir)) return null;
|
|
39
|
+
return binding.runId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Test isolation: drop any binding without a session identity check. */
|
|
43
|
+
export function resetRunBindingForTests(): void {
|
|
44
|
+
binding = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolve full ActiveInfo for an explicit run id; null when the run does not exist. */
|
|
48
|
+
export function activeInfoById(workdir: string, runId: string): ActiveInfo | null {
|
|
49
|
+
const runDir = runDirPath(workdir, runId);
|
|
50
|
+
if (runDir === null) return null; // bound run vanished: fall back to the shared pointer
|
|
51
|
+
const run = getRun(workdir, runId);
|
|
52
|
+
if (run === null) return null;
|
|
53
|
+
return { run_id: runId, run_dir: runDir, artifact_dir: run.artifact_dir };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the run this session should attribute work to. A session-bound
|
|
58
|
+
* run always wins; sessions without a binding keep the legacy
|
|
59
|
+
* shared-pointer behavior.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveActiveRun(session: unknown, workdir: string): ActiveInfo | null {
|
|
62
|
+
if (session !== undefined && session !== null) {
|
|
63
|
+
const runId = boundRunId(session, workdir);
|
|
64
|
+
if (runId !== null) {
|
|
65
|
+
const bound = activeInfoById(workdir, runId);
|
|
66
|
+
if (bound !== null) return bound;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return readActive(workdir);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Entry shape accepted by {@link restoreRunBindingFromSession}. */
|
|
73
|
+
export interface RunStartEntryLike {
|
|
74
|
+
type: string;
|
|
75
|
+
customType?: string;
|
|
76
|
+
data?: { runId?: unknown };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Restore the session's run binding from `pi-plans-run-start` entries on the
|
|
81
|
+
* current branch (the same pattern autocomplete restore uses). Only entries
|
|
82
|
+
* on the current branch count — abandoned branches must not steal
|
|
83
|
+
* attribution.
|
|
84
|
+
*/
|
|
85
|
+
export function restoreRunBindingFromSession(session: unknown, workdir: string, entries: RunStartEntryLike[]): string | null {
|
|
86
|
+
let lastRunId: string | null = null;
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.type === "custom" && entry.customType === "pi-plans-run-start") {
|
|
89
|
+
const runId = entry.data?.runId;
|
|
90
|
+
if (typeof runId === "string" && runId !== "") lastRunId = runId;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (lastRunId === null) return null;
|
|
94
|
+
if (runDirPath(workdir, lastRunId) === null) return null; // run deleted since
|
|
95
|
+
bindRun(session, workdir, lastRunId);
|
|
96
|
+
return lastRunId;
|
|
97
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run activity ownership (I-002).
|
|
3
|
+
*
|
|
4
|
+
* At most one live owner per run. An owner record carries host, pid, the
|
|
5
|
+
* process start time (PID-reuse guard), the Pi session id, a random process
|
|
6
|
+
* token, and a generation counter. Acquisition is atomic (O_EXCL create);
|
|
7
|
+
* takeovers require proof the previous owner is dead — process gone, or the
|
|
8
|
+
* pid alive but with a different start time (reused pid). Unknown hosts,
|
|
9
|
+
* corrupt records, and unverifiable liveness are conservatively refused.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import {
|
|
15
|
+
closeSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
openSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import * as os from "node:os";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import { StateError, runDirPath, utcNow } from "./state.ts";
|
|
27
|
+
|
|
28
|
+
export const OWNER_SCHEMA = 1;
|
|
29
|
+
|
|
30
|
+
export interface OwnerRecord {
|
|
31
|
+
schema: number;
|
|
32
|
+
host: string;
|
|
33
|
+
pid: number;
|
|
34
|
+
/** `ps -o lstart=` output; null when unresolvable. Mismatch vs a live pid means reuse. */
|
|
35
|
+
pidStart: string | null;
|
|
36
|
+
sessionId: string | null;
|
|
37
|
+
processToken: string;
|
|
38
|
+
generation: number;
|
|
39
|
+
acquiredAt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class OwnershipError extends StateError {}
|
|
43
|
+
|
|
44
|
+
/** Records this process currently holds: runKey → owner. */
|
|
45
|
+
const held = new Map<string, OwnerRecord>();
|
|
46
|
+
|
|
47
|
+
function runKey(workdir: string, runId: string): string {
|
|
48
|
+
return `${path.resolve(workdir)}::${runId}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ownerFilePath(workdir: string, runId: string): string | null {
|
|
52
|
+
const runDir = runDirPath(workdir, runId);
|
|
53
|
+
return runDir === null ? null : path.join(runDir, "owner.json");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateOwnerRecord(data: unknown, label: string): OwnerRecord {
|
|
57
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
58
|
+
throw new OwnershipError(`${label}: expected an object`);
|
|
59
|
+
}
|
|
60
|
+
const record = data as Record<string, unknown>;
|
|
61
|
+
const allowed = new Set(["schema", "host", "pid", "pidStart", "sessionId", "processToken", "generation", "acquiredAt"]);
|
|
62
|
+
for (const key of Object.keys(record)) {
|
|
63
|
+
if (!allowed.has(key)) throw new OwnershipError(`${label}: unexpected key "${key}"`);
|
|
64
|
+
}
|
|
65
|
+
if (record.schema !== OWNER_SCHEMA) {
|
|
66
|
+
throw new OwnershipError(`${label}.schema: unsupported version ${String(record.schema)}`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof record.host !== "string" || record.host === "") throw new OwnershipError(`${label}.host: expected a non-empty string`);
|
|
69
|
+
if (typeof record.pid !== "number" || !Number.isSafeInteger(record.pid) || record.pid < 1) {
|
|
70
|
+
throw new OwnershipError(`${label}.pid: expected a positive integer`);
|
|
71
|
+
}
|
|
72
|
+
if (record.pidStart !== null && typeof record.pidStart !== "string") {
|
|
73
|
+
throw new OwnershipError(`${label}.pidStart: expected a string or null`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof record.processToken !== "string" || record.processToken === "") {
|
|
76
|
+
throw new OwnershipError(`${label}.processToken: expected a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
if (typeof record.generation !== "number" || !Number.isSafeInteger(record.generation) || record.generation < 1) {
|
|
79
|
+
throw new OwnershipError(`${label}.generation: expected a positive integer`);
|
|
80
|
+
}
|
|
81
|
+
if (typeof record.acquiredAt !== "string") throw new OwnershipError(`${label}.acquiredAt: expected a string`);
|
|
82
|
+
return {
|
|
83
|
+
schema: OWNER_SCHEMA,
|
|
84
|
+
host: record.host,
|
|
85
|
+
pid: record.pid,
|
|
86
|
+
pidStart: (record.pidStart as string | null) ?? null,
|
|
87
|
+
sessionId: (record.sessionId as string | null) ?? null,
|
|
88
|
+
processToken: record.processToken,
|
|
89
|
+
generation: record.generation,
|
|
90
|
+
acquiredAt: record.acquiredAt,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readOwnerFile(filePath: string): OwnerRecord {
|
|
95
|
+
let data: unknown;
|
|
96
|
+
try {
|
|
97
|
+
data = JSON.parse(readFileSync(filePath, "utf8"));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new OwnershipError(`owner record ${filePath} is corrupt (${(error as Error).message}); repair or remove it explicitly`);
|
|
100
|
+
}
|
|
101
|
+
return validateOwnerRecord(data, "owner record");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function writeOwnerFile(filePath: string, record: OwnerRecord): void {
|
|
105
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
106
|
+
const tmp = `${filePath}.tmp`;
|
|
107
|
+
writeFileSync(tmp, `${JSON.stringify(record, null, "\t")}\n`, "utf8");
|
|
108
|
+
renameSync(tmp, filePath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Process start time via `ps -o lstart=`; null when unresolvable. */
|
|
112
|
+
export function processStartOf(pid: number): string | null {
|
|
113
|
+
try {
|
|
114
|
+
const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 5000 });
|
|
115
|
+
if (result.error || result.status !== 0) return null;
|
|
116
|
+
const start = (result.stdout ?? "").trim();
|
|
117
|
+
return start === "" ? null : start;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function processAlive(pid: number, pidStart: string | null): boolean {
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 0);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
128
|
+
if (code === "ESRCH") return false;
|
|
129
|
+
return true; // EPERM etc.: exists but not ours
|
|
130
|
+
}
|
|
131
|
+
if (pidStart === null) return true; // alive, start unverifiable
|
|
132
|
+
// F-006 (implementation review): a live pid whose CURRENT start time cannot
|
|
133
|
+
// be resolved (ps missing/failing) is conservatively ALIVE — never steal.
|
|
134
|
+
const currentStart = processStartOf(pid);
|
|
135
|
+
if (currentStart === null) return true;
|
|
136
|
+
return currentStart === pidStart; // mismatch = reused pid = dead owner
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface AcquireOptions {
|
|
140
|
+
sessionId?: string | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Acquire (or keep) ownership of a run for this process. Re-acquiring a run
|
|
145
|
+
* this process already holds returns the existing record. A live foreign
|
|
146
|
+
* owner, a foreign host, or a corrupt record is refused.
|
|
147
|
+
*/
|
|
148
|
+
export function acquireOwnership(workdir: string, runId: string, options: AcquireOptions = {}): OwnerRecord {
|
|
149
|
+
const key = runKey(workdir, runId);
|
|
150
|
+
const alreadyHeld = held.get(key);
|
|
151
|
+
if (alreadyHeld) {
|
|
152
|
+
// Keep the token; refresh nothing — liveness is checked on demand.
|
|
153
|
+
return alreadyHeld;
|
|
154
|
+
}
|
|
155
|
+
const filePath = ownerFilePath(workdir, runId);
|
|
156
|
+
if (filePath === null) throw new OwnershipError(`run does not exist: ${runId}`);
|
|
157
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
158
|
+
const candidate: OwnerRecord = {
|
|
159
|
+
schema: OWNER_SCHEMA,
|
|
160
|
+
host: os.hostname(),
|
|
161
|
+
pid: process.pid,
|
|
162
|
+
pidStart: processStartOf(process.pid),
|
|
163
|
+
sessionId: options.sessionId ?? null,
|
|
164
|
+
processToken: randomUUID().replace(/-/g, ""),
|
|
165
|
+
generation: 1,
|
|
166
|
+
acquiredAt: utcNow(),
|
|
167
|
+
};
|
|
168
|
+
// Fast path: exclusive create when no owner exists yet.
|
|
169
|
+
try {
|
|
170
|
+
const fd = openSync(filePath, "wx");
|
|
171
|
+
writeFileSync(fd, `${JSON.stringify(candidate, null, "\t")}\n`, "utf8");
|
|
172
|
+
closeSync(fd);
|
|
173
|
+
held.set(key, candidate);
|
|
174
|
+
return candidate;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
177
|
+
}
|
|
178
|
+
// Someone owns it. Same live process re-entering (e.g. extension reload
|
|
179
|
+
// dropped the in-memory map): adopt the existing record when pid+start
|
|
180
|
+
// match, keeping generation continuity.
|
|
181
|
+
const existing = readOwnerFile(filePath);
|
|
182
|
+
if (existing.host === candidate.host && existing.pid === candidate.pid && existing.pidStart === candidate.pidStart) {
|
|
183
|
+
held.set(key, existing);
|
|
184
|
+
return existing;
|
|
185
|
+
}
|
|
186
|
+
if (existing.host !== candidate.host) {
|
|
187
|
+
throw new OwnershipError(
|
|
188
|
+
`run ${runId} is owned by host "${existing.host}"; cross-host liveness cannot be verified — refusing`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (processAlive(existing.pid, existing.pidStart)) {
|
|
192
|
+
throw new OwnershipError(
|
|
193
|
+
`run ${runId} is actively owned by pid ${existing.pid} (generation ${existing.generation}); refusing to steal`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
// Provably dead previous owner: guarded takeover.
|
|
197
|
+
takeoverWithLock(filePath, workdir, runId, existing, candidate, options);
|
|
198
|
+
const next = readOwnerFile(filePath);
|
|
199
|
+
held.set(key, next);
|
|
200
|
+
return next;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function takeoverWithLock(
|
|
204
|
+
filePath: string,
|
|
205
|
+
workdir: string,
|
|
206
|
+
runId: string,
|
|
207
|
+
expected: OwnerRecord,
|
|
208
|
+
candidate: OwnerRecord,
|
|
209
|
+
options: AcquireOptions,
|
|
210
|
+
): void {
|
|
211
|
+
const lockPath = `${filePath}.lock`;
|
|
212
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
213
|
+
try {
|
|
214
|
+
const fd = openSync(lockPath, "wx");
|
|
215
|
+
closeSync(fd);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
218
|
+
// Stale-lock sweep: a lock whose creator is gone cannot deadlock us.
|
|
219
|
+
try {
|
|
220
|
+
const lock = JSON.parse(readFileSync(lockPath, "utf8")) as { pid?: number };
|
|
221
|
+
if (typeof lock.pid === "number" && !processAlive(lock.pid, null)) unlinkSync(lockPath);
|
|
222
|
+
} catch {
|
|
223
|
+
/* unreadable lock: wait and retry */
|
|
224
|
+
}
|
|
225
|
+
const snooze = spawnSync("sleep", ["0.05"], { encoding: "utf8" });
|
|
226
|
+
if (snooze.error) break;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
// Re-validate under the lock: the world may have moved.
|
|
231
|
+
const current = readOwnerFile(filePath);
|
|
232
|
+
if (current.processToken !== expected.processToken) {
|
|
233
|
+
throw new OwnershipError(`run ${runId} changed owners during takeover; retry acquire`);
|
|
234
|
+
}
|
|
235
|
+
if (processAlive(current.pid, current.pidStart)) {
|
|
236
|
+
throw new OwnershipError(`run ${runId} owner pid ${current.pid} is alive again; refusing to steal`);
|
|
237
|
+
}
|
|
238
|
+
const next: OwnerRecord = {
|
|
239
|
+
...candidate,
|
|
240
|
+
sessionId: options.sessionId ?? null,
|
|
241
|
+
generation: current.generation + 1,
|
|
242
|
+
};
|
|
243
|
+
writeOwnerFile(filePath, next);
|
|
244
|
+
writeFileSync(lockPath, `${JSON.stringify({ pid: process.pid, takenAt: utcNow() })}\n`, "utf8");
|
|
245
|
+
} finally {
|
|
246
|
+
try {
|
|
247
|
+
unlinkSync(lockPath);
|
|
248
|
+
} catch {
|
|
249
|
+
/* best-effort */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
throw new OwnershipError(`run ${runId}: could not acquire the takeover lock; another takeover is in flight`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Current owner record without acquiring; null when unowned. Corrupt records throw. */
|
|
258
|
+
export function ownershipRecord(workdir: string, runId: string): OwnerRecord | null {
|
|
259
|
+
const filePath = ownerFilePath(workdir, runId);
|
|
260
|
+
if (filePath === null || !existsSync(filePath)) return null;
|
|
261
|
+
return readOwnerFile(filePath);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** True when a live owner holds the run right now. */
|
|
265
|
+
export function ownershipHeld(workdir: string, runId: string): boolean {
|
|
266
|
+
const record = ownershipRecord(workdir, runId);
|
|
267
|
+
if (record === null) return false;
|
|
268
|
+
return processAlive(record.pid, record.pidStart);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Fail when this session no longer owns the run (takeover or release happened). */
|
|
272
|
+
export function assertOwnership(workdir: string, runId: string, expected: { processToken: string; generation: number }): void {
|
|
273
|
+
const filePath = ownerFilePath(workdir, runId);
|
|
274
|
+
if (filePath === null || !existsSync(filePath)) {
|
|
275
|
+
throw new OwnershipError(`run ${runId} has no owner record; ownership was released`);
|
|
276
|
+
}
|
|
277
|
+
const record = readOwnerFile(filePath);
|
|
278
|
+
if (record.processToken !== expected.processToken || record.generation !== expected.generation) {
|
|
279
|
+
throw new OwnershipError(
|
|
280
|
+
`ownership of ${runId} moved (generation ${expected.generation} → ${record.generation}); this session no longer owns the run`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Release ownership; only the matching token may remove the record. */
|
|
286
|
+
export function releaseOwnership(workdir: string, runId: string, processToken: string): boolean {
|
|
287
|
+
const key = runKey(workdir, runId);
|
|
288
|
+
const filePath = ownerFilePath(workdir, runId);
|
|
289
|
+
if (filePath === null) return false;
|
|
290
|
+
held.delete(key);
|
|
291
|
+
if (!existsSync(filePath)) return false;
|
|
292
|
+
const record = readOwnerFile(filePath);
|
|
293
|
+
if (record.processToken !== processToken) return false;
|
|
294
|
+
try {
|
|
295
|
+
unlinkSync(filePath);
|
|
296
|
+
return true;
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Records held by this process (test/inspection helper). */
|
|
303
|
+
export function heldOwnershipKeys(): string[] {
|
|
304
|
+
return [...held.keys()];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The owner record this process currently holds for a run, if any (F-005). */
|
|
308
|
+
export function heldOwnershipRecord(workdir: string, runId: string): OwnerRecord | null {
|
|
309
|
+
return held.get(runKey(workdir, runId)) ?? null;
|
|
310
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -130,6 +130,8 @@ export interface DecisionEntry {
|
|
|
130
130
|
answer: string;
|
|
131
131
|
answer_source: "user" | "auto-complete";
|
|
132
132
|
artifact?: string;
|
|
133
|
+
/** Stable question id when the ask_choice call carried one (F-005 reconcile). */
|
|
134
|
+
questionId?: string;
|
|
133
135
|
recorded_at: string;
|
|
134
136
|
}
|
|
135
137
|
|
|
@@ -247,7 +249,7 @@ export function resolveStateRootOrNull(workdir: string): string | null {
|
|
|
247
249
|
// Config helpers
|
|
248
250
|
// ---------------------------------------------------------------------------
|
|
249
251
|
|
|
250
|
-
function atomicWriteJson(filePath: string, data: unknown): void {
|
|
252
|
+
export function atomicWriteJson(filePath: string, data: unknown): void {
|
|
251
253
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
252
254
|
const tmp = `${filePath}.tmp`;
|
|
253
255
|
writeFileSync(tmp, `${JSON.stringify(data, null, "\t")}\n`, "utf8");
|
|
@@ -500,6 +502,14 @@ function requireRunDir(workdir: string, runId: string): string {
|
|
|
500
502
|
return runDir;
|
|
501
503
|
}
|
|
502
504
|
|
|
505
|
+
/** Read-only run directory resolution; returns null when the run does not exist. */
|
|
506
|
+
export function runDirPath(workdir: string, runId: string): string | null {
|
|
507
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
508
|
+
if (stateRoot === null) return null;
|
|
509
|
+
const runDir = path.join(stateRoot, "runs", runId);
|
|
510
|
+
return existsSync(runDir) ? runDir : null;
|
|
511
|
+
}
|
|
512
|
+
|
|
503
513
|
export function recordDecision(workdir: string, runId: string, entry: Omit<DecisionEntry, "recorded_at">): DecisionEntry {
|
|
504
514
|
const runDir = requireRunDir(workdir, runId);
|
|
505
515
|
const full: DecisionEntry = { ...entry, recorded_at: utcNow() };
|
|
@@ -521,6 +531,19 @@ export function recordSubagent(workdir: string, runId: string, entry: Omit<Subag
|
|
|
521
531
|
return full;
|
|
522
532
|
}
|
|
523
533
|
|
|
534
|
+
/** Update a run's recorded workdir (cross-worktree migration, D-007). */
|
|
535
|
+
export function updateRunWorkdir(workdir: string, runId: string, newWorkdir: string): RunInfo {
|
|
536
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
537
|
+
if (stateRoot === null) throw new StateError("no pi-plans state found; run init first");
|
|
538
|
+
const runPath = path.join(stateRoot, "runs", runId, "run.json");
|
|
539
|
+
if (!existsSync(runPath)) throw new StateError(`run does not exist: ${runId}`);
|
|
540
|
+
const run = JSON.parse(readFileSync(runPath, "utf8")) as RunInfo;
|
|
541
|
+
run.workdir = path.resolve(newWorkdir);
|
|
542
|
+
run.updated_at = utcNow();
|
|
543
|
+
atomicWriteJson(runPath, run);
|
|
544
|
+
return run;
|
|
545
|
+
}
|
|
546
|
+
|
|
524
547
|
export function setRunStatus(workdir: string, runId: string, status: string): RunInfo {
|
|
525
548
|
if (!VALID_RUN_STATUSES.has(status)) {
|
|
526
549
|
throw new StateError(`status must be one of ${[...VALID_RUN_STATUSES].join(", ")}`);
|
|
@@ -20,3 +20,11 @@ export const TERMINATION_OPTIONS = [
|
|
|
20
20
|
export function renderTerminationOptions(): string {
|
|
21
21
|
return TERMINATION_OPTIONS.map((option, index) => `${index + 1}. ${option}`).join(" ");
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/** Stable question id for the termination question so cross-session resume
|
|
25
|
+
* can deduplicate it and link the answer to the checkpoint (I-004). */
|
|
26
|
+
export const TERMINATION_QUESTION_ID = "termination-condition";
|
|
27
|
+
|
|
28
|
+
/** Instruction appended wherever the termination question is requested. */
|
|
29
|
+
export const TERMINATION_RECORDING_INSTRUCTIONS =
|
|
30
|
+
'Ask it with ask_choice using questionId: "termination-condition" (autoComplete: false). After the user answers, persist the loop configuration: plans record-checkpoint (checkpoint: { transition: "implementation-review-configured", terminationCondition: "<the chosen option>" }).';
|